Implement Owner Panel features and maintenance mode handling
- Introduced the Owner Panel with new routes and UI components for managing guilds, user blacklists, feature flags, and bot presence. - Added maintenance mode functionality to prevent non-owner users from executing commands during maintenance periods. - Enhanced localization with new keys for Owner Panel features in both English and German. - Updated job processing to include a presence refresh job, ensuring the bot's status is regularly updated. - Improved environment configuration to support owner user IDs for access control.
This commit is contained in:
51
apps/webui/src/lib/owner-audit.ts
Normal file
51
apps/webui/src/lib/owner-audit.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||
import type { OwnerAuditEntry } from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
export interface WriteOwnerAuditInput {
|
||||
actorUserId: string;
|
||||
action: string;
|
||||
targetType?: string | null;
|
||||
targetId?: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}
|
||||
|
||||
function toJson(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
|
||||
if (value === undefined || value === null) {
|
||||
return Prisma.JsonNull;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export async function writeOwnerAudit(
|
||||
client: PrismaClient,
|
||||
input: WriteOwnerAuditInput
|
||||
): Promise<void> {
|
||||
await client.ownerAuditLog.create({
|
||||
data: {
|
||||
actorUserId: input.actorUserId,
|
||||
action: input.action,
|
||||
targetType: input.targetType ?? null,
|
||||
targetId: input.targetId ?? null,
|
||||
before: toJson(input.before),
|
||||
after: toJson(input.after)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function listOwnerAudit(limit = 50, offset = 0): Promise<OwnerAuditEntry[]> {
|
||||
const rows = await prisma.ownerAuditLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
actorUserId: row.actorUserId,
|
||||
action: row.action,
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
createdAt: row.createdAt.toISOString()
|
||||
}));
|
||||
}
|
||||
55
apps/webui/src/lib/owner-auth.ts
Normal file
55
apps/webui/src/lib/owner-auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
ownerRoleAtLeast,
|
||||
OwnerRoleSchema,
|
||||
type OwnerRole
|
||||
} from '@nexumi/shared';
|
||||
import { ForbiddenError, UnauthorizedError, requireAuth, requireAuthOrRedirect } from './auth';
|
||||
import { env } from './env';
|
||||
import { prisma } from './prisma';
|
||||
import type { SessionPayload } from './session';
|
||||
|
||||
export type OwnerSession = SessionPayload & { ownerRole: OwnerRole };
|
||||
|
||||
function bootstrapOwnerIds(): string[] {
|
||||
return env.OWNER_USER_IDS ?? [];
|
||||
}
|
||||
|
||||
export async function resolveOwnerRole(userId: string): Promise<OwnerRole | null> {
|
||||
if (bootstrapOwnerIds().includes(userId)) {
|
||||
return 'OWNER';
|
||||
}
|
||||
const member = await prisma.ownerTeamMember.findUnique({ where: { userId } });
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
const parsed = OwnerRoleSchema.safeParse(member.role);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export async function isOwnerUser(userId: string): Promise<boolean> {
|
||||
return (await resolveOwnerRole(userId)) !== null;
|
||||
}
|
||||
|
||||
export async function requireOwner(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
|
||||
const session = await requireAuth();
|
||||
const role = await resolveOwnerRole(session.user.id);
|
||||
if (!role) {
|
||||
throw new ForbiddenError('Owner panel access required');
|
||||
}
|
||||
if (!ownerRoleAtLeast(role, minimum)) {
|
||||
throw new ForbiddenError('Insufficient owner role');
|
||||
}
|
||||
return { ...session, ownerRole: role };
|
||||
}
|
||||
|
||||
export async function requireOwnerOrRedirect(minimum: OwnerRole = 'VIEWER'): Promise<OwnerSession> {
|
||||
const session = await requireAuthOrRedirect();
|
||||
const role = await resolveOwnerRole(session.user.id);
|
||||
if (!role || !ownerRoleAtLeast(role, minimum)) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return { ...session, ownerRole: role };
|
||||
}
|
||||
|
||||
export { UnauthorizedError, ForbiddenError };
|
||||
76
apps/webui/src/lib/owner-jobs.ts
Normal file
76
apps/webui/src/lib/owner-jobs.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { redis } from './redis';
|
||||
|
||||
export const OWNER_QUEUE_NAMES = [
|
||||
'moderation',
|
||||
'backups',
|
||||
'automod',
|
||||
'verification',
|
||||
'reminders',
|
||||
'giveaways',
|
||||
'tickets',
|
||||
'birthdays',
|
||||
'stats',
|
||||
'feeds',
|
||||
'schedules',
|
||||
'guild-backups',
|
||||
'suggestions',
|
||||
'presence'
|
||||
] as const;
|
||||
|
||||
export type OwnerQueueName = (typeof OWNER_QUEUE_NAMES)[number];
|
||||
|
||||
const globalForOwnerQueues = globalThis as unknown as {
|
||||
__nexumiOwnerQueues?: Map<string, Queue>;
|
||||
};
|
||||
|
||||
function getQueue(name: string): Queue {
|
||||
const cache = globalForOwnerQueues.__nexumiOwnerQueues ?? new Map<string, Queue>();
|
||||
globalForOwnerQueues.__nexumiOwnerQueues = cache;
|
||||
let queue = cache.get(name);
|
||||
if (!queue) {
|
||||
queue = new Queue(name, { connection: redis });
|
||||
cache.set(name, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
name: string;
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
export async function getAllQueueStats(): Promise<QueueStats[]> {
|
||||
return Promise.all(
|
||||
OWNER_QUEUE_NAMES.map(async (name) => {
|
||||
const queue = getQueue(name);
|
||||
const counts = await queue.getJobCounts('wait', 'active', 'completed', 'failed', 'delayed');
|
||||
return {
|
||||
name,
|
||||
waiting: counts.wait,
|
||||
active: counts.active,
|
||||
completed: counts.completed,
|
||||
failed: counts.failed,
|
||||
delayed: counts.delayed
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function retryFailedJobs(queueName: string, limit = 25): Promise<number> {
|
||||
if (!(OWNER_QUEUE_NAMES as readonly string[]).includes(queueName)) {
|
||||
throw new Error('Unknown queue');
|
||||
}
|
||||
const queue = getQueue(queueName);
|
||||
const failed = await queue.getFailed(0, limit - 1);
|
||||
let retried = 0;
|
||||
for (const job of failed) {
|
||||
await job.retry();
|
||||
retried += 1;
|
||||
}
|
||||
return retried;
|
||||
}
|
||||
67
apps/webui/src/lib/owner-presence.ts
Normal file
67
apps/webui/src/lib/owner-presence.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
BotPresenceConfigPatchSchema,
|
||||
BotPresenceConfigSchema,
|
||||
PRESENCE_REDIS_KEY,
|
||||
type BotPresenceConfig
|
||||
} from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
import { redis } from './redis';
|
||||
|
||||
function mapRow(row: {
|
||||
status: string;
|
||||
activityType: string;
|
||||
activityText: string;
|
||||
rotatingMessages: unknown;
|
||||
maintenanceMode: boolean;
|
||||
maintenanceMessage: string | null;
|
||||
}): BotPresenceConfig {
|
||||
const rotating = Array.isArray(row.rotatingMessages)
|
||||
? row.rotatingMessages.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
return BotPresenceConfigSchema.parse({
|
||||
status: row.status,
|
||||
activityType: row.activityType,
|
||||
activityText: row.activityText,
|
||||
rotatingMessages: rotating,
|
||||
maintenanceMode: row.maintenanceMode,
|
||||
maintenanceMessage: row.maintenanceMessage
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPresenceConfig(): Promise<BotPresenceConfig> {
|
||||
const row = await prisma.botPresenceConfig.upsert({
|
||||
where: { id: 'singleton' },
|
||||
create: { id: 'singleton' },
|
||||
update: {}
|
||||
});
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
export async function updatePresenceConfig(patch: unknown): Promise<BotPresenceConfig> {
|
||||
const data = BotPresenceConfigPatchSchema.parse(patch);
|
||||
const current = await getPresenceConfig();
|
||||
const next = { ...current, ...data };
|
||||
const row = await prisma.botPresenceConfig.upsert({
|
||||
where: { id: 'singleton' },
|
||||
create: {
|
||||
id: 'singleton',
|
||||
status: next.status,
|
||||
activityType: next.activityType,
|
||||
activityText: next.activityText,
|
||||
rotatingMessages: next.rotatingMessages,
|
||||
maintenanceMode: next.maintenanceMode,
|
||||
maintenanceMessage: next.maintenanceMessage
|
||||
},
|
||||
update: {
|
||||
status: next.status,
|
||||
activityType: next.activityType,
|
||||
activityText: next.activityText,
|
||||
rotatingMessages: next.rotatingMessages,
|
||||
maintenanceMode: next.maintenanceMode,
|
||||
maintenanceMessage: next.maintenanceMessage
|
||||
}
|
||||
});
|
||||
const mapped = mapRow(row);
|
||||
await redis.set(PRESENCE_REDIS_KEY, JSON.stringify(mapped));
|
||||
return mapped;
|
||||
}
|
||||
Reference in New Issue
Block a user