Update fun module configuration and enhance CSS styles
- Added 'enabled' property to FunConfigSchema with a default value of true for better configuration management. - Refactored getFunConfig to return a complete default configuration when no data is found. - Improved globals.css by expanding CSS variables for light and dark themes, enhancing styling consistency across the application.
This commit is contained in:
38
apps/webui/src/lib/access-rules.ts
Normal file
38
apps/webui/src/lib/access-rules.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { DashboardAccessRule } from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
export async function listAccessRules(guildId: string): Promise<DashboardAccessRule[]> {
|
||||
const rules = await prisma.dashboardAccessRule.findMany({
|
||||
where: { guildId },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
return rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
roleId: rule.roleId,
|
||||
modules: rule.modules as DashboardAccessRule['modules']
|
||||
}));
|
||||
}
|
||||
|
||||
export async function replaceAccessRules(
|
||||
guildId: string,
|
||||
rules: DashboardAccessRule[]
|
||||
): Promise<DashboardAccessRule[]> {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.dashboardAccessRule.deleteMany({ where: { guildId } }),
|
||||
...rules.map((rule) =>
|
||||
prisma.dashboardAccessRule.create({
|
||||
data: {
|
||||
guildId,
|
||||
roleId: rule.roleId,
|
||||
modules: rule.modules as Prisma.InputJsonValue
|
||||
}
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
return listAccessRules(guildId);
|
||||
}
|
||||
55
apps/webui/src/lib/audit.ts
Normal file
55
apps/webui/src/lib/audit.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||
import type { DashboardAuditEntry } from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
export interface WriteDashboardAuditInput {
|
||||
guildId?: string | null;
|
||||
actorUserId: string;
|
||||
action: string;
|
||||
path?: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}
|
||||
|
||||
function toJsonInput(value: unknown): Prisma.InputJsonValue | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export async function writeDashboardAudit(
|
||||
prisma: PrismaClient,
|
||||
input: WriteDashboardAuditInput
|
||||
): Promise<void> {
|
||||
await prisma.dashboardAuditLog.create({
|
||||
data: {
|
||||
guildId: input.guildId ?? null,
|
||||
actorUserId: input.actorUserId,
|
||||
action: input.action,
|
||||
path: input.path ?? null,
|
||||
before: toJsonInput(input.before) ?? Prisma.JsonNull,
|
||||
after: toJsonInput(input.after) ?? Prisma.JsonNull
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRecentDashboardAudit(
|
||||
guildId: string,
|
||||
limit = 10
|
||||
): Promise<DashboardAuditEntry[]> {
|
||||
const entries = await prisma.dashboardAuditLog.findMany({
|
||||
where: { guildId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit
|
||||
});
|
||||
|
||||
return entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
guildId: entry.guildId,
|
||||
actorUserId: entry.actorUserId,
|
||||
action: entry.action,
|
||||
path: entry.path,
|
||||
createdAt: entry.createdAt.toISOString()
|
||||
}));
|
||||
}
|
||||
99
apps/webui/src/lib/auth.ts
Normal file
99
apps/webui/src/lib/auth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ZodError } from 'zod';
|
||||
import { hasManageGuildPermission } from '@nexumi/shared';
|
||||
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||
import { prisma } from './prisma';
|
||||
import { getSession, type SessionPayload } from './session';
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message = 'Authentication required') {
|
||||
super(message);
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor(message = 'You do not have access to this resource') {
|
||||
super(message);
|
||||
this.name = 'ForbiddenError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use in API routes. Throws {@link UnauthorizedError} when no session exists.
|
||||
*/
|
||||
export async function requireAuth(): Promise<SessionPayload> {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use in Server Components / layouts. Redirects to /login instead of throwing,
|
||||
* since there is no JSON response to return.
|
||||
*/
|
||||
export async function requireAuthOrRedirect(): Promise<SessionPayload> {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect('/login');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the current session user has Manage Guild permission on the given
|
||||
* guild (via Discord OAuth guild list) and that the bot is actually installed
|
||||
* there (tracked via the `Guild` table). Use in API routes.
|
||||
*/
|
||||
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
|
||||
const session = await requireAuth();
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
throw new ForbiddenError('Missing Manage Guild permission for this server');
|
||||
}
|
||||
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
|
||||
if (!dbGuild) {
|
||||
throw new ForbiddenError('Nexumi is not installed on this server');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link requireGuildAccess} but for Server Components: redirects
|
||||
* instead of throwing.
|
||||
*/
|
||||
export async function requireGuildAccessOrRedirect(guildId: string): Promise<SessionPayload> {
|
||||
const session = await requireAuthOrRedirect();
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
|
||||
if (!dbGuild) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function toApiErrorResponse(error: unknown): NextResponse {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 });
|
||||
}
|
||||
if (error instanceof ForbiddenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', issues: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[api]', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
115
apps/webui/src/lib/discord-oauth.ts
Normal file
115
apps/webui/src/lib/discord-oauth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { env } from './env';
|
||||
import { redis } from './redis';
|
||||
|
||||
const DISCORD_API_BASE = 'https://discord.com/api/v10';
|
||||
const OAUTH_SCOPES = ['identify', 'guilds'] as const;
|
||||
const USER_GUILDS_CACHE_TTL_SECONDS = 30;
|
||||
|
||||
export function getRedirectUri(): string {
|
||||
return `${env.WEBUI_URL}/api/auth/callback`;
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl(state: string): string {
|
||||
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
|
||||
url.searchParams.set('redirect_uri', getRedirectUri());
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', OAUTH_SCOPES.join(' '));
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('prompt', 'consent');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export interface DiscordTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(code: string): Promise<DiscordTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.BOT_CLIENT_ID,
|
||||
client_secret: env.BOT_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: getRedirectUri()
|
||||
});
|
||||
|
||||
const response = await fetch(`${DISCORD_API_BASE}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord token exchange failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as DiscordTokenResponse;
|
||||
}
|
||||
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
avatar: string | null;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
|
||||
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Discord user with status ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as DiscordUser;
|
||||
}
|
||||
|
||||
export interface DiscordUserGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
export async function fetchDiscordUserGuilds(accessToken: string): Promise<DiscordUserGuild[]> {
|
||||
const response = await fetch(`${DISCORD_API_BASE}/users/@me/guilds`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Discord guilds with status ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as DiscordUserGuild[];
|
||||
}
|
||||
|
||||
function guildsCacheKey(accessToken: string): string {
|
||||
return `webui:discordguilds:${createHash('sha256').update(accessToken).digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached variant of {@link fetchDiscordUserGuilds} to avoid hitting Discord's
|
||||
* rate limits when a single dashboard page load needs the guild list more
|
||||
* than once (e.g. layout + page + server switcher).
|
||||
*/
|
||||
export async function fetchDiscordUserGuildsCached(accessToken: string): Promise<DiscordUserGuild[]> {
|
||||
const cacheKey = guildsCacheKey(accessToken);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as DiscordUserGuild[];
|
||||
}
|
||||
const guilds = await fetchDiscordUserGuilds(accessToken);
|
||||
await redis.set(cacheKey, JSON.stringify(guilds), 'EX', USER_GUILDS_CACHE_TTL_SECONDS);
|
||||
return guilds;
|
||||
}
|
||||
19
apps/webui/src/lib/env.ts
Normal file
19
apps/webui/src/lib/env.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { config } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
config();
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url(),
|
||||
BOT_CLIENT_ID: z.string().min(1),
|
||||
BOT_CLIENT_SECRET: z.string().min(1),
|
||||
WEBUI_URL: z.string().url().default('http://localhost:3000'),
|
||||
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de')
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
export type Env = typeof env;
|
||||
41
apps/webui/src/lib/guild-settings.ts
Normal file
41
apps/webui/src/lib/guild-settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { GuildGeneralSettings, GuildGeneralSettingsPatch } from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
async function ensureGuildSettings(guildId: string) {
|
||||
await prisma.guild.upsert({
|
||||
where: { id: guildId },
|
||||
update: {},
|
||||
create: { id: guildId }
|
||||
});
|
||||
|
||||
return prisma.guildSettings.upsert({
|
||||
where: { guildId },
|
||||
update: {},
|
||||
create: { guildId }
|
||||
});
|
||||
}
|
||||
|
||||
function toGeneralSettings(settings: { locale: string; timezone: string; moderationEnabled: boolean }): GuildGeneralSettings {
|
||||
return {
|
||||
locale: settings.locale === 'en' ? 'en' : 'de',
|
||||
timezone: settings.timezone,
|
||||
moderationEnabled: settings.moderationEnabled
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGuildGeneralSettings(guildId: string): Promise<GuildGeneralSettings> {
|
||||
const settings = await ensureGuildSettings(guildId);
|
||||
return toGeneralSettings(settings);
|
||||
}
|
||||
|
||||
export async function updateGuildGeneralSettings(
|
||||
guildId: string,
|
||||
patch: GuildGeneralSettingsPatch
|
||||
): Promise<GuildGeneralSettings> {
|
||||
await ensureGuildSettings(guildId);
|
||||
const updated = await prisma.guildSettings.update({
|
||||
where: { guildId },
|
||||
data: patch
|
||||
});
|
||||
return toGeneralSettings(updated);
|
||||
}
|
||||
51
apps/webui/src/lib/guilds.ts
Normal file
51
apps/webui/src/lib/guilds.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { hasManageGuildPermission, type DashboardGuild } from '@nexumi/shared';
|
||||
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||
import { prisma } from './prisma';
|
||||
import type { SessionPayload } from './session';
|
||||
|
||||
/**
|
||||
* Returns the guilds the current session user can manage (Manage Guild
|
||||
* permission), enriched with whether Nexumi is already installed there.
|
||||
* Guilds where the bot is present are sorted first.
|
||||
*/
|
||||
export async function getManageableGuilds(session: SessionPayload): Promise<DashboardGuild[]> {
|
||||
const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions));
|
||||
|
||||
if (manageable.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const presentGuilds = await prisma.guild.findMany({
|
||||
where: { id: { in: manageable.map((guild) => guild.id) } },
|
||||
select: { id: true }
|
||||
});
|
||||
const presentIds = new Set(presentGuilds.map((guild) => guild.id));
|
||||
|
||||
return manageable
|
||||
.map<DashboardGuild>((guild) => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
botPresent: presentIds.has(guild.id),
|
||||
permissions: guild.permissions
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.botPresent !== b.botPresent) {
|
||||
return a.botPresent ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a single manageable guild by id, or null when the session user
|
||||
* does not manage it.
|
||||
*/
|
||||
export async function getManageableGuild(
|
||||
session: SessionPayload,
|
||||
guildId: string
|
||||
): Promise<DashboardGuild | null> {
|
||||
const guilds = await getManageableGuilds(session);
|
||||
return guilds.find((guild) => guild.id === guildId) ?? null;
|
||||
}
|
||||
54
apps/webui/src/lib/i18n.ts
Normal file
54
apps/webui/src/lib/i18n.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import deMessages from '@/messages/de.json';
|
||||
import enMessages from '@/messages/en.json';
|
||||
import { env } from './env';
|
||||
|
||||
export type Locale = 'de' | 'en';
|
||||
|
||||
export const LOCALE_COOKIE_NAME = 'nexumi_locale';
|
||||
|
||||
export type Messages = typeof enMessages;
|
||||
|
||||
export const MESSAGES: Record<Locale, Messages> = {
|
||||
de: deMessages,
|
||||
en: enMessages
|
||||
};
|
||||
|
||||
function resolve(messages: Messages, key: string): string | undefined {
|
||||
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === 'object' && part in (acc as Record<string, unknown>)) {
|
||||
return (acc as Record<string, unknown>)[part];
|
||||
}
|
||||
return undefined;
|
||||
}, messages);
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) {
|
||||
return template;
|
||||
}
|
||||
return Object.entries(vars).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
template
|
||||
);
|
||||
}
|
||||
|
||||
export function t(locale: Locale, key: string, vars?: Record<string, string | number>): string {
|
||||
const dict = MESSAGES[locale] ?? MESSAGES[env.DEFAULT_LOCALE];
|
||||
return interpolate(resolve(dict, key) ?? key, vars);
|
||||
}
|
||||
|
||||
function isLocale(value: string | undefined): value is Locale {
|
||||
return value === 'de' || value === 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the active locale for the current request from the
|
||||
* `nexumi_locale` cookie, falling back to the server's configured default.
|
||||
*/
|
||||
export async function getLocale(): Promise<Locale> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get(LOCALE_COOKIE_NAME)?.value;
|
||||
return isLocale(cookieLocale) ? cookieLocale : env.DEFAULT_LOCALE;
|
||||
}
|
||||
253
apps/webui/src/lib/modules.ts
Normal file
253
apps/webui/src/lib/modules.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { DASHBOARD_MODULES, type DashboardModuleId, type ModuleStatus } from '@nexumi/shared';
|
||||
import { prisma } from './prisma';
|
||||
import { redis } from './redis';
|
||||
|
||||
/**
|
||||
* Modules that have no dedicated "enabled" column on any Prisma model.
|
||||
* Their dashboard toggle state is tracked in a Redis hash instead.
|
||||
*/
|
||||
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
|
||||
'giveaways',
|
||||
'tags',
|
||||
'selfroles',
|
||||
'feeds',
|
||||
'scheduler',
|
||||
'guildbackup'
|
||||
]);
|
||||
|
||||
function redisModulesKey(guildId: string): string {
|
||||
return `dashboard:modules:${guildId}`;
|
||||
}
|
||||
|
||||
function funConfigKey(guildId: string): string {
|
||||
return `fun:config:${guildId}`;
|
||||
}
|
||||
|
||||
interface FunConfigLike {
|
||||
enabled: boolean;
|
||||
mediaEnabled: boolean;
|
||||
}
|
||||
|
||||
async function getRedisModuleFlags(guildId: string): Promise<Partial<Record<DashboardModuleId, boolean>>> {
|
||||
const raw = await redis.hgetall(redisModulesKey(guildId));
|
||||
const result: Partial<Record<DashboardModuleId, boolean>> = {};
|
||||
for (const [moduleId, value] of Object.entries(raw)) {
|
||||
result[moduleId as DashboardModuleId] = value === 'true';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function setRedisModuleFlag(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise<void> {
|
||||
await redis.hset(redisModulesKey(guildId), moduleId, enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
async function getFunConfig(guildId: string): Promise<FunConfigLike> {
|
||||
const raw = await redis.get(funConfigKey(guildId));
|
||||
if (!raw) {
|
||||
return { enabled: true, mediaEnabled: true };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { enabled?: boolean; mediaEnabled?: boolean };
|
||||
return { enabled: parsed.enabled ?? true, mediaEnabled: parsed.mediaEnabled ?? true };
|
||||
} catch {
|
||||
return { enabled: true, mediaEnabled: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function setFunConfigEnabled(guildId: string, enabled: boolean): Promise<void> {
|
||||
const current = await getFunConfig(guildId);
|
||||
await redis.set(funConfigKey(guildId), JSON.stringify({ ...current, enabled }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the enabled/disabled status of every dashboard module for a
|
||||
* guild, reading from each module's Prisma config table (when it has an
|
||||
* `enabled` column), the Fun module's Redis config, or the generic
|
||||
* `dashboard:modules:{guildId}` Redis hash for modules without any
|
||||
* dedicated storage yet.
|
||||
*/
|
||||
export async function getModuleStatuses(guildId: string): Promise<ModuleStatus[]> {
|
||||
const [
|
||||
guildSettings,
|
||||
autoModConfig,
|
||||
loggingConfig,
|
||||
welcomeConfig,
|
||||
verificationConfig,
|
||||
levelingConfig,
|
||||
economyConfig,
|
||||
funConfig,
|
||||
ticketConfig,
|
||||
starboardConfig,
|
||||
suggestionConfig,
|
||||
birthdayConfig,
|
||||
tempVoiceConfig,
|
||||
statsConfig,
|
||||
redisFlags
|
||||
] = await Promise.all([
|
||||
prisma.guildSettings.findUnique({ where: { guildId } }),
|
||||
prisma.autoModConfig.findUnique({ where: { guildId } }),
|
||||
prisma.loggingConfig.findUnique({ where: { guildId } }),
|
||||
prisma.welcomeConfig.findUnique({ where: { guildId } }),
|
||||
prisma.verificationConfig.findUnique({ where: { guildId } }),
|
||||
prisma.levelingConfig.findUnique({ where: { guildId } }),
|
||||
prisma.economyConfig.findUnique({ where: { guildId } }),
|
||||
getFunConfig(guildId),
|
||||
prisma.ticketConfig.findUnique({ where: { guildId } }),
|
||||
prisma.starboardConfig.findUnique({ where: { guildId } }),
|
||||
prisma.suggestionConfig.findUnique({ where: { guildId } }),
|
||||
prisma.birthdayConfig.findUnique({ where: { guildId } }),
|
||||
prisma.tempVoiceConfig.findUnique({ where: { guildId } }),
|
||||
prisma.statsConfig.findUnique({ where: { guildId } }),
|
||||
getRedisModuleFlags(guildId)
|
||||
]);
|
||||
|
||||
const enabledById: Record<DashboardModuleId, boolean> = {
|
||||
moderation: guildSettings?.moderationEnabled ?? true,
|
||||
automod: autoModConfig?.enabled ?? true,
|
||||
logging: loggingConfig?.enabled ?? true,
|
||||
welcome: Boolean(welcomeConfig?.welcomeEnabled || welcomeConfig?.leaveEnabled),
|
||||
verification: verificationConfig?.enabled ?? false,
|
||||
leveling: levelingConfig?.enabled ?? true,
|
||||
economy: economyConfig?.enabled ?? true,
|
||||
fun: funConfig.enabled,
|
||||
giveaways: redisFlags.giveaways ?? true,
|
||||
tickets: ticketConfig?.enabled ?? true,
|
||||
selfroles: redisFlags.selfroles ?? true,
|
||||
tags: redisFlags.tags ?? true,
|
||||
starboard: starboardConfig?.enabled ?? false,
|
||||
suggestions: suggestionConfig?.enabled ?? true,
|
||||
birthdays: birthdayConfig?.enabled ?? true,
|
||||
tempvoice: tempVoiceConfig?.enabled ?? false,
|
||||
stats: statsConfig?.enabled ?? false,
|
||||
feeds: redisFlags.feeds ?? true,
|
||||
scheduler: redisFlags.scheduler ?? true,
|
||||
guildbackup: redisFlags.guildbackup ?? true
|
||||
};
|
||||
|
||||
return DASHBOARD_MODULES.map((module) => ({
|
||||
id: module.id,
|
||||
group: module.group,
|
||||
href: module.href,
|
||||
enabled: enabledById[module.id]
|
||||
}));
|
||||
}
|
||||
|
||||
async function setModuleEnabled(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise<void> {
|
||||
if (REDIS_ONLY_MODULES.has(moduleId)) {
|
||||
await setRedisModuleFlag(guildId, moduleId, enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (moduleId) {
|
||||
case 'moderation':
|
||||
await prisma.guildSettings.upsert({
|
||||
where: { guildId },
|
||||
update: { moderationEnabled: enabled },
|
||||
create: { guildId, moderationEnabled: enabled }
|
||||
});
|
||||
return;
|
||||
case 'automod':
|
||||
await prisma.autoModConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'logging':
|
||||
await prisma.loggingConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'welcome':
|
||||
await prisma.welcomeConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { welcomeEnabled: enabled, leaveEnabled: enabled },
|
||||
create: { guildId, welcomeEnabled: enabled, leaveEnabled: enabled }
|
||||
});
|
||||
return;
|
||||
case 'verification':
|
||||
await prisma.verificationConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'leveling':
|
||||
await prisma.levelingConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'economy':
|
||||
await prisma.economyConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'fun':
|
||||
await setFunConfigEnabled(guildId, enabled);
|
||||
return;
|
||||
case 'tickets':
|
||||
await prisma.ticketConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'starboard':
|
||||
await prisma.starboardConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'suggestions':
|
||||
await prisma.suggestionConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'birthdays':
|
||||
await prisma.birthdayConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'tempvoice':
|
||||
await prisma.tempVoiceConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
case 'stats':
|
||||
await prisma.statsConfig.upsert({
|
||||
where: { guildId },
|
||||
update: { enabled },
|
||||
create: { guildId, enabled }
|
||||
});
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unknown or unsupported module: ${String(moduleId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateModuleStatuses(
|
||||
guildId: string,
|
||||
updates: Partial<Record<DashboardModuleId, boolean>>
|
||||
): Promise<ModuleStatus[]> {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
|
||||
for (const [moduleId, enabled] of Object.entries(updates)) {
|
||||
if (enabled === undefined) continue;
|
||||
await setModuleEnabled(guildId, moduleId as DashboardModuleId, enabled);
|
||||
}
|
||||
|
||||
return getModuleStatuses(guildId);
|
||||
}
|
||||
10
apps/webui/src/lib/prisma.ts
Normal file
10
apps/webui/src/lib/prisma.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { env } from './env';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { __nexumiPrisma?: PrismaClient };
|
||||
|
||||
export const prisma: PrismaClient = globalForPrisma.__nexumiPrisma ?? new PrismaClient();
|
||||
|
||||
if (env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.__nexumiPrisma = prisma;
|
||||
}
|
||||
12
apps/webui/src/lib/redis.ts
Normal file
12
apps/webui/src/lib/redis.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import Redis from 'ioredis';
|
||||
import { env } from './env';
|
||||
|
||||
const globalForRedis = globalThis as unknown as { __nexumiRedis?: Redis };
|
||||
|
||||
export const redis: Redis = globalForRedis.__nexumiRedis ?? new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3
|
||||
});
|
||||
|
||||
if (env.NODE_ENV !== 'production') {
|
||||
globalForRedis.__nexumiRedis = redis;
|
||||
}
|
||||
105
apps/webui/src/lib/session.ts
Normal file
105
apps/webui/src/lib/session.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
import { SessionUserSchema } from '@nexumi/shared';
|
||||
import { env } from './env';
|
||||
import { redis } from './redis';
|
||||
|
||||
export const SESSION_COOKIE_NAME = 'nexumi_session';
|
||||
const SESSION_KEY_PREFIX = 'webui:session:';
|
||||
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
const SessionPayloadSchema = z.object({
|
||||
user: SessionUserSchema,
|
||||
accessToken: z.string().min(1),
|
||||
tokenExpiresAt: z.number().optional()
|
||||
});
|
||||
|
||||
export type SessionPayload = z.infer<typeof SessionPayloadSchema>;
|
||||
|
||||
export class SessionRequiredError extends Error {
|
||||
constructor() {
|
||||
super('Session required');
|
||||
this.name = 'SessionRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
function sign(value: string): string {
|
||||
return createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex');
|
||||
}
|
||||
|
||||
function buildCookieValue(sessionId: string): string {
|
||||
return `${sessionId}.${sign(sessionId)}`;
|
||||
}
|
||||
|
||||
function parseCookieValue(cookieValue: string): string | null {
|
||||
const separatorIndex = cookieValue.lastIndexOf('.');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
const sessionId = cookieValue.slice(0, separatorIndex);
|
||||
const signature = cookieValue.slice(separatorIndex + 1);
|
||||
const expected = sign(sessionId);
|
||||
const provided = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (provided.length !== expectedBuffer.length || !timingSafeEqual(provided, expectedBuffer)) {
|
||||
return null;
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function sessionRedisKey(sessionId: string): string {
|
||||
return `${SESSION_KEY_PREFIX}${sessionId}`;
|
||||
}
|
||||
|
||||
export async function createSession(payload: SessionPayload): Promise<void> {
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS);
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, buildCookieValue(sessionId), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: SESSION_TTL_SECONDS
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionPayload | null> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const sessionId = parseCookieValue(raw);
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
const stored = await redis.get(sessionRedisKey(sessionId));
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored));
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export async function destroySession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (raw) {
|
||||
const sessionId = parseCookieValue(raw);
|
||||
if (sessionId) {
|
||||
await redis.del(sessionRedisKey(sessionId));
|
||||
}
|
||||
}
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
|
||||
export async function requireSession(): Promise<SessionPayload> {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
throw new SessionRequiredError();
|
||||
}
|
||||
return session;
|
||||
}
|
||||
16
apps/webui/src/lib/utils.test.ts
Normal file
16
apps/webui/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cn } from './utils';
|
||||
|
||||
describe('cn', () => {
|
||||
it('merges class names', () => {
|
||||
expect(cn('flex', 'items-center')).toBe('flex items-center');
|
||||
});
|
||||
|
||||
it('resolves conflicting tailwind classes in favor of the last one', () => {
|
||||
expect(cn('px-2', 'px-4')).toBe('px-4');
|
||||
});
|
||||
|
||||
it('drops falsy values', () => {
|
||||
expect(cn('base', false && 'hidden', undefined, null, 'visible')).toBe('base visible');
|
||||
});
|
||||
});
|
||||
6
apps/webui/src/lib/utils.ts
Normal file
6
apps/webui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user