diff --git a/apps/webui/Dockerfile b/apps/webui/Dockerfile
new file mode 100644
index 0000000..7b53d70
--- /dev/null
+++ b/apps/webui/Dockerfile
@@ -0,0 +1,30 @@
+FROM node:22-alpine AS base
+WORKDIR /app
+RUN corepack enable
+RUN apk add --no-cache openssl
+
+FROM base AS deps
+COPY package.json pnpm-workspace.yaml turbo.json tsconfig.base.json ./
+COPY apps/webui/package.json apps/webui/package.json
+COPY apps/bot/package.json apps/bot/package.json
+COPY packages/shared/package.json packages/shared/package.json
+RUN pnpm install --frozen-lockfile=false
+
+FROM deps AS build
+COPY apps ./apps
+COPY packages ./packages
+COPY eslint.config.js .eslintrc.cjs .prettierrc ./
+RUN pnpm install --frozen-lockfile=false
+RUN pnpm --filter @nexumi/shared build
+RUN pnpm --filter @nexumi/webui prisma:generate
+RUN pnpm --filter @nexumi/webui build
+
+FROM base AS runner
+ENV NODE_ENV=production
+COPY --from=build /app/apps/webui/.next/standalone ./
+COPY --from=build /app/apps/webui/.next/static ./apps/webui/.next/static
+COPY --from=build /app/apps/webui/public ./apps/webui/public
+EXPOSE 3000
+HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \
+ CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+CMD ["node", "apps/webui/server.js"]
diff --git a/apps/webui/src/app/api/auth/callback/route.ts b/apps/webui/src/app/api/auth/callback/route.ts
new file mode 100644
index 0000000..fd349ef
--- /dev/null
+++ b/apps/webui/src/app/api/auth/callback/route.ts
@@ -0,0 +1,60 @@
+import type { SessionUser } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { exchangeCodeForToken, fetchDiscordUser } from '@/lib/discord-oauth';
+import { env } from '@/lib/env';
+import { redis } from '@/lib/redis';
+import { createSession } from '@/lib/session';
+
+function oauthStateKey(state: string): string {
+ return `webui:oauth:state:${state}`;
+}
+
+function loginRedirect(error: string): NextResponse {
+ const url = new URL('/login', env.WEBUI_URL);
+ url.searchParams.set('error', error);
+ return NextResponse.redirect(url);
+}
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const code = searchParams.get('code');
+ const state = searchParams.get('state');
+ const oauthError = searchParams.get('error');
+
+ if (oauthError) {
+ return loginRedirect('access_denied');
+ }
+
+ if (!code || !state) {
+ return loginRedirect('invalid_request');
+ }
+
+ const stateKey = oauthStateKey(state);
+ const stateExists = await redis.get(stateKey);
+ if (!stateExists) {
+ return loginRedirect('invalid_state');
+ }
+ await redis.del(stateKey);
+
+ try {
+ const token = await exchangeCodeForToken(code);
+ const discordUser = await fetchDiscordUser(token.access_token);
+
+ const user: SessionUser = {
+ id: discordUser.id,
+ username: discordUser.username,
+ globalName: discordUser.global_name ?? null,
+ avatar: discordUser.avatar ?? null
+ };
+
+ await createSession({
+ user,
+ accessToken: token.access_token,
+ tokenExpiresAt: Date.now() + token.expires_in * 1000
+ });
+ } catch {
+ return loginRedirect('oauth_failed');
+ }
+
+ return NextResponse.redirect(new URL('/dashboard', env.WEBUI_URL));
+}
diff --git a/apps/webui/src/app/api/auth/login/route.ts b/apps/webui/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..16fafa1
--- /dev/null
+++ b/apps/webui/src/app/api/auth/login/route.ts
@@ -0,0 +1,16 @@
+import { randomBytes } from 'crypto';
+import { NextResponse } from 'next/server';
+import { buildAuthorizeUrl } from '@/lib/discord-oauth';
+import { redis } from '@/lib/redis';
+
+const OAUTH_STATE_TTL_SECONDS = 600;
+
+function oauthStateKey(state: string): string {
+ return `webui:oauth:state:${state}`;
+}
+
+export async function GET() {
+ const state = randomBytes(24).toString('hex');
+ await redis.set(oauthStateKey(state), '1', 'EX', OAUTH_STATE_TTL_SECONDS);
+ return NextResponse.redirect(buildAuthorizeUrl(state));
+}
diff --git a/apps/webui/src/app/api/auth/logout/route.ts b/apps/webui/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..952aca0
--- /dev/null
+++ b/apps/webui/src/app/api/auth/logout/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from 'next/server';
+import { destroySession } from '@/lib/session';
+
+export async function POST() {
+ await destroySession();
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/webui/src/app/api/auth/me/route.ts b/apps/webui/src/app/api/auth/me/route.ts
new file mode 100644
index 0000000..b540bb1
--- /dev/null
+++ b/apps/webui/src/app/api/auth/me/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from 'next/server';
+import { getSession } from '@/lib/session';
+
+export async function GET() {
+ const session = await getSession();
+ return NextResponse.json({ user: session?.user ?? null });
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/access-rules/route.ts b/apps/webui/src/app/api/guilds/[guildId]/access-rules/route.ts
new file mode 100644
index 0000000..9a75180
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/access-rules/route.ts
@@ -0,0 +1,46 @@
+import { DashboardAccessRulesUpdateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { listAccessRules, replaceAccessRules } from '@/lib/access-rules';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const rules = await listAccessRules(guildId);
+ return NextResponse.json({ rules });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function PUT(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const { rules: patch } = DashboardAccessRulesUpdateSchema.parse(body);
+
+ const before = await listAccessRules(guildId);
+ const after = await replaceAccessRules(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'access-rules.update',
+ path: `/dashboard/${guildId}/access`,
+ before,
+ after
+ });
+
+ return NextResponse.json({ rules: after });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/audit/route.ts b/apps/webui/src/app/api/guilds/[guildId]/audit/route.ts
new file mode 100644
index 0000000..7cf5832
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/audit/route.ts
@@ -0,0 +1,20 @@
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { listRecentDashboardAudit } from '@/lib/audit';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const limitParam = request.nextUrl.searchParams.get('limit');
+ const limit = limitParam ? Math.min(Math.max(Number.parseInt(limitParam, 10) || 10, 1), 50) : 10;
+ const entries = await listRecentDashboardAudit(guildId, limit);
+ return NextResponse.json({ entries });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/modules/route.ts b/apps/webui/src/app/api/guilds/[guildId]/modules/route.ts
new file mode 100644
index 0000000..115fbed
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/modules/route.ts
@@ -0,0 +1,46 @@
+import { ModulesUpdateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getModuleStatuses, updateModuleStatuses } from '@/lib/modules';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const modules = await getModuleStatuses(guildId);
+ return NextResponse.json({ modules });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const { modules: patch } = ModulesUpdateSchema.parse(body);
+
+ const before = await getModuleStatuses(guildId);
+ const after = await updateModuleStatuses(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'modules.update',
+ path: `/dashboard/${guildId}/modules`,
+ before,
+ after
+ });
+
+ return NextResponse.json({ modules: after });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/settings/route.ts b/apps/webui/src/app/api/guilds/[guildId]/settings/route.ts
new file mode 100644
index 0000000..cadac38
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/settings/route.ts
@@ -0,0 +1,46 @@
+import { GuildGeneralSettingsPatchSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getGuildGeneralSettings, updateGuildGeneralSettings } from '@/lib/guild-settings';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const settings = await getGuildGeneralSettings(guildId);
+ return NextResponse.json(settings);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = GuildGeneralSettingsPatchSchema.parse(body);
+
+ const before = await getGuildGeneralSettings(guildId);
+ const after = await updateGuildGeneralSettings(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'settings.update',
+ path: `/dashboard/${guildId}/settings`,
+ before,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/route.ts b/apps/webui/src/app/api/guilds/route.ts
new file mode 100644
index 0000000..7379f20
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/route.ts
@@ -0,0 +1,13 @@
+import { NextResponse } from 'next/server';
+import { requireAuth, toApiErrorResponse } from '@/lib/auth';
+import { getManageableGuilds } from '@/lib/guilds';
+
+export async function GET() {
+ try {
+ const session = await requireAuth();
+ const guilds = await getManageableGuilds(session);
+ return NextResponse.json({ guilds });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/health/route.ts b/apps/webui/src/app/api/health/route.ts
new file mode 100644
index 0000000..2ca9e91
--- /dev/null
+++ b/apps/webui/src/app/api/health/route.ts
@@ -0,0 +1,5 @@
+import { NextResponse } from 'next/server';
+
+export async function GET() {
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx b/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx
new file mode 100644
index 0000000..5c5a8cd
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx
@@ -0,0 +1,39 @@
+import { DASHBOARD_MODULES } from '@nexumi/shared';
+import { Construction } from 'lucide-react';
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import { Card, CardContent } from '@/components/ui/card';
+import { getLocale, t } from '@/lib/i18n';
+
+interface ModulePlaceholderPageProps {
+ params: Promise<{ guildId: string; module: string }>;
+}
+
+export default async function ModulePlaceholderPage({ params }: ModulePlaceholderPageProps) {
+ const { guildId, module: moduleHref } = await params;
+ const moduleEntry = DASHBOARD_MODULES.find((entry) => entry.href === moduleHref);
+
+ if (!moduleEntry) {
+ notFound();
+ }
+
+ const locale = await getLocale();
+ const moduleLabel = t(locale, `modules.${moduleEntry.id}.label`);
+
+ return (
+
+
+
+
+
+
+
{t(locale, 'modulePlaceholder.title', { module: moduleLabel })}
+
{t(locale, 'modulePlaceholder.message')}
+
+
+ {t(locale, 'modulePlaceholder.backToModules')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/access/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/access/loading.tsx
new file mode 100644
index 0000000..db7fe08
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/access/loading.tsx
@@ -0,0 +1,10 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function AccessLoading() {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/access/page.tsx b/apps/webui/src/app/dashboard/[guildId]/access/page.tsx
new file mode 100644
index 0000000..4c202f3
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/access/page.tsx
@@ -0,0 +1,13 @@
+import { AccessRulesForm } from '@/components/settings/access-rules-form';
+import { listAccessRules } from '@/lib/access-rules';
+
+interface AccessPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function GuildAccessPage({ params }: AccessPageProps) {
+ const { guildId } = await params;
+ const rules = await listAccessRules(guildId);
+
+ return ;
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/layout.tsx b/apps/webui/src/app/dashboard/[guildId]/layout.tsx
new file mode 100644
index 0000000..c5a4048
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/layout.tsx
@@ -0,0 +1,29 @@
+import { notFound } from 'next/navigation';
+import type { ReactNode } from 'react';
+import { DashboardShell } from '@/components/layout/dashboard-shell';
+import { requireGuildAccessOrRedirect } from '@/lib/auth';
+import { getManageableGuilds } from '@/lib/guilds';
+
+interface GuildLayoutProps {
+ children: ReactNode;
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function GuildLayout({ children, params }: GuildLayoutProps) {
+ const { guildId } = await params;
+ const session = await requireGuildAccessOrRedirect(guildId);
+ const guilds = await getManageableGuilds(session);
+ const currentGuild = guilds.find((guild) => guild.id === guildId);
+
+ if (!currentGuild) {
+ notFound();
+ }
+
+ const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/loading.tsx
new file mode 100644
index 0000000..0a0c96c
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/loading.tsx
@@ -0,0 +1,15 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function GuildOverviewLoading() {
+ return (
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/modules/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/modules/loading.tsx
new file mode 100644
index 0000000..ddbd9ca
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/modules/loading.tsx
@@ -0,0 +1,15 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function ModulesLoading() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/modules/page.tsx b/apps/webui/src/app/dashboard/[guildId]/modules/page.tsx
new file mode 100644
index 0000000..df967c1
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/modules/page.tsx
@@ -0,0 +1,22 @@
+import { ModuleTogglesForm } from '@/components/settings/module-toggles-form';
+import { getLocale, t } from '@/lib/i18n';
+import { getModuleStatuses } from '@/lib/modules';
+
+interface ModulesPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function GuildModulesPage({ params }: ModulesPageProps) {
+ const { guildId } = await params;
+ const [locale, statuses] = await Promise.all([getLocale(), getModuleStatuses(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modulesPage.title')}
+
{t(locale, 'modulesPage.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/page.tsx b/apps/webui/src/app/dashboard/[guildId]/page.tsx
new file mode 100644
index 0000000..891b37b
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/page.tsx
@@ -0,0 +1,87 @@
+import Link from 'next/link';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { listRecentDashboardAudit } from '@/lib/audit';
+import { getLocale, t } from '@/lib/i18n';
+import { getModuleStatuses } from '@/lib/modules';
+
+interface OverviewPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+function formatDate(iso: string, locale: string): string {
+ return new Date(iso).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', {
+ dateStyle: 'medium',
+ timeStyle: 'short'
+ });
+}
+
+export default async function GuildOverviewPage({ params }: OverviewPageProps) {
+ const { guildId } = await params;
+ const locale = await getLocale();
+ const [modules, auditEntries] = await Promise.all([
+ getModuleStatuses(guildId),
+ listRecentDashboardAudit(guildId, 10)
+ ]);
+
+ return (
+
+
{t(locale, 'dashboard.overview.title')}
+
+
+
+
{t(locale, 'dashboard.overview.moduleStatusTitle')}
+
+ {t(locale, 'dashboard.overview.viewAllModules')}
+
+
+ {modules.length === 0 ? (
+ {t(locale, 'dashboard.overview.moduleStatusEmpty')}
+ ) : (
+
+ {modules.map((module) => (
+
+
+ {t(locale, `modules.${module.id}.label`)}
+
+ {t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {t(locale, 'dashboard.overview.recentAuditTitle')}
+
+
+
+ {t(locale, 'dashboard.overview.recentAuditTitle')}
+
+
+
+ {auditEntries.length === 0 ? (
+ {t(locale, 'dashboard.overview.auditEmpty')}
+ ) : (
+
+ {auditEntries.map((entry) => (
+ -
+
+
{entry.action}
+ {entry.path &&
{entry.path}
}
+
+
+ {formatDate(entry.createdAt, locale)}
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/settings/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/settings/loading.tsx
new file mode 100644
index 0000000..3f33012
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/settings/loading.tsx
@@ -0,0 +1,10 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function SettingsLoading() {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/settings/page.tsx b/apps/webui/src/app/dashboard/[guildId]/settings/page.tsx
new file mode 100644
index 0000000..6fd0d97
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/settings/page.tsx
@@ -0,0 +1,13 @@
+import { GeneralSettingsForm } from '@/components/settings/general-settings-form';
+import { getGuildGeneralSettings } from '@/lib/guild-settings';
+
+interface SettingsPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function GuildSettingsPage({ params }: SettingsPageProps) {
+ const { guildId } = await params;
+ const settings = await getGuildGeneralSettings(guildId);
+
+ return ;
+}
diff --git a/apps/webui/src/app/dashboard/loading.tsx b/apps/webui/src/app/dashboard/loading.tsx
new file mode 100644
index 0000000..2b68748
--- /dev/null
+++ b/apps/webui/src/app/dashboard/loading.tsx
@@ -0,0 +1,19 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function DashboardListLoading() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/page.tsx b/apps/webui/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..794d2ee
--- /dev/null
+++ b/apps/webui/src/app/dashboard/page.tsx
@@ -0,0 +1,93 @@
+import type { DashboardGuild } from '@nexumi/shared';
+import Link from 'next/link';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { requireAuthOrRedirect } from '@/lib/auth';
+import { env } from '@/lib/env';
+import { getManageableGuilds } from '@/lib/guilds';
+import { getLocale, t } from '@/lib/i18n';
+
+function guildIconUrl(guild: Pick): string | undefined {
+ return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined;
+}
+
+function initials(name: string): string {
+ return (
+ name
+ .split(' ')
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join('') || '?'
+ );
+}
+
+function buildInviteUrl(guildId: string): string {
+ const url = new URL('https://discord.com/api/oauth2/authorize');
+ url.searchParams.set('client_id', env.BOT_CLIENT_ID);
+ url.searchParams.set('permissions', '8');
+ url.searchParams.set('scope', 'bot applications.commands');
+ url.searchParams.set('guild_id', guildId);
+ return url.toString();
+}
+
+export default async function DashboardGuildListPage() {
+ const session = await requireAuthOrRedirect();
+ const guilds = await getManageableGuilds(session);
+ const locale = await getLocale();
+
+ return (
+
+
+
+
{t(locale, 'dashboard.guildList.title')}
+
{t(locale, 'dashboard.guildList.subtitle')}
+
+
+ {guilds.length === 0 ? (
+
+
+ {t(locale, 'dashboard.guildList.empty')}
+
+
+ ) : (
+
+ {guilds.map((guild) => (
+
+
+
+
+
+ {initials(guild.name)}
+
+
+
{guild.name}
+ {!guild.botPresent && (
+
+ {t(locale, 'dashboard.guildList.botMissing')}
+
+ )}
+
+
+ {guild.botPresent ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/webui/src/app/layout.tsx b/apps/webui/src/app/layout.tsx
index f7fa87e..8fdc879 100644
--- a/apps/webui/src/app/layout.tsx
+++ b/apps/webui/src/app/layout.tsx
@@ -1,33 +1,31 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
+import type { Metadata } from 'next';
+import { Inter } from 'next/font/google';
+import type { ReactNode } from 'react';
+import { Toaster } from 'sonner';
+import { LocaleProvider } from '@/components/locale-provider';
+import { ThemeProvider } from '@/components/layout/theme-provider';
+import { getLocale, MESSAGES } from '@/lib/i18n';
+import './globals.css';
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: 'Nexumi Dashboard',
+ description: 'Manage your Nexumi Discord bot'
};
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+export default async function RootLayout({ children }: { children: ReactNode }) {
+ const locale = await getLocale();
+
return (
-
-
- {children}
+
+
+
+
+ {children}
+
+
+
);
diff --git a/apps/webui/src/app/login/page.tsx b/apps/webui/src/app/login/page.tsx
new file mode 100644
index 0000000..1ec7b6e
--- /dev/null
+++ b/apps/webui/src/app/login/page.tsx
@@ -0,0 +1,52 @@
+import { redirect } from 'next/navigation';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { getLocale, t } from '@/lib/i18n';
+import { getSession } from '@/lib/session';
+
+interface LoginPageProps {
+ searchParams: Promise<{ error?: string }>;
+}
+
+const KNOWN_ERRORS = ['access_denied', 'invalid_request', 'invalid_state', 'oauth_failed'] as const;
+
+export default async function LoginPage({ searchParams }: LoginPageProps) {
+ const session = await getSession();
+ if (session) {
+ redirect('/dashboard');
+ }
+
+ const { error } = await searchParams;
+ const locale = await getLocale();
+ const errorMessage =
+ error && (KNOWN_ERRORS as readonly string[]).includes(error)
+ ? t(locale, `login.errors.${error}`)
+ : undefined;
+
+ return (
+
+
+
+
+ N
+
+ {t(locale, 'login.title')}
+ {t(locale, 'login.subtitle')}
+
+
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+ {t(locale, 'login.cta')}
+
+ {t(locale, 'login.footer')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/page.tsx b/apps/webui/src/app/page.tsx
index a932894..a2759c5 100644
--- a/apps/webui/src/app/page.tsx
+++ b/apps/webui/src/app/page.tsx
@@ -1,103 +1,7 @@
-import Image from "next/image";
+import { redirect } from 'next/navigation';
+import { getSession } from '@/lib/session';
-export default function Home() {
- return (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.tsx
-
- .
-
- -
- Save and see your changes instantly.
-
-
-
-
-
-
-
- );
+export default async function RootPage() {
+ const session = await getSession();
+ redirect(session ? '/dashboard' : '/login');
}
diff --git a/apps/webui/src/components/settings/access-rules-form.tsx b/apps/webui/src/components/settings/access-rules-form.tsx
new file mode 100644
index 0000000..6570fae
--- /dev/null
+++ b/apps/webui/src/components/settings/access-rules-form.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { DASHBOARD_MODULES, type DashboardAccessRule, type DashboardModuleId } from '@nexumi/shared';
+import { Plus, Trash2 } from 'lucide-react';
+import { useId } from 'react';
+import { useTranslations } from '@/components/locale-provider';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import type { SettingsSaveResult } from './settings-form';
+import { SettingsForm } from './settings-form';
+
+interface LocalRule {
+ clientKey: string;
+ roleId: string;
+ modulesMode: 'all' | 'specific';
+ selectedModules: DashboardModuleId[];
+}
+
+function toLocalRules(rules: DashboardAccessRule[]): LocalRule[] {
+ return rules.map((rule, index) => ({
+ clientKey: rule.id ?? `existing-${index}`,
+ roleId: rule.roleId,
+ modulesMode: rule.modules === 'all' ? 'all' : 'specific',
+ selectedModules: rule.modules === 'all' ? [] : rule.modules
+ }));
+}
+
+function toRemoteRules(rules: LocalRule[]): DashboardAccessRule[] {
+ return rules.map((rule) => ({
+ roleId: rule.roleId,
+ modules: rule.modulesMode === 'all' ? 'all' : rule.selectedModules
+ }));
+}
+
+async function saveAccessRules(guildId: string, rules: LocalRule[]): Promise {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/access-rules`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rules: toRemoteRules(rules) })
+ });
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
+ }
+ return { ok: true };
+ } catch {
+ return { ok: false, error: 'Network error' };
+ }
+}
+
+function AccessRuleRow({
+ rule,
+ onChange,
+ onRemove
+}: {
+ rule: LocalRule;
+ onChange: (rule: LocalRule) => void;
+ onRemove: () => void;
+}) {
+ const t = useTranslations();
+ const roleInputId = useId();
+
+ function toggleModule(moduleId: DashboardModuleId, checked: boolean) {
+ const next = checked
+ ? [...rule.selectedModules, moduleId]
+ : rule.selectedModules.filter((id) => id !== moduleId);
+ onChange({ ...rule, selectedModules: next });
+ }
+
+ return (
+
+
+
+
+
onChange({ ...rule, roleId: event.target.value.trim() })}
+ placeholder={t('access.roleIdPlaceholder')}
+ className="max-w-xs"
+ />
+
{t('access.roleIdHint')}
+
+
+
+
+
+
+
+
+ {rule.modulesMode === 'specific' && (
+
+ {DASHBOARD_MODULES.map((module) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+interface AccessRulesFormProps {
+ guildId: string;
+ initialRules: DashboardAccessRule[];
+}
+
+export function AccessRulesForm({ guildId, initialRules }: AccessRulesFormProps) {
+ const t = useTranslations();
+
+ return (
+
+ initialValue={toLocalRules(initialRules)}
+ onSave={(value) => saveAccessRules(guildId, value)}
+ >
+ {({ value, setValue }) => (
+
+
+ {t('access.title')}
+ {t('access.description')}
+
+
+ {value.length === 0 && {t('access.noRules')}
}
+
+ {value.map((rule) => (
+
+ setValue((prev) => prev.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry)))
+ }
+ onRemove={() => setValue((prev) => prev.filter((entry) => entry.clientKey !== rule.clientKey))}
+ />
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/webui/src/components/settings/module-toggles-form.tsx b/apps/webui/src/components/settings/module-toggles-form.tsx
new file mode 100644
index 0000000..1fc4905
--- /dev/null
+++ b/apps/webui/src/components/settings/module-toggles-form.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { DASHBOARD_MODULES, type DashboardModuleGroup, type DashboardModuleId, type ModuleStatus } from '@nexumi/shared';
+import { ArrowUpRight } from 'lucide-react';
+import Link from 'next/link';
+import { useTranslations } from '@/components/locale-provider';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Switch } from '@/components/ui/switch';
+import type { SettingsSaveResult } from './settings-form';
+import { SettingsForm } from './settings-form';
+
+type ModuleToggleValue = Record;
+
+const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
+ 'moderation',
+ 'engagement',
+ 'community',
+ 'utility',
+ 'integrations'
+];
+
+function toValue(statuses: ModuleStatus[]): ModuleToggleValue {
+ return statuses.reduce((acc, status) => {
+ acc[status.id] = status.enabled;
+ return acc;
+ }, {} as ModuleToggleValue);
+}
+
+async function saveModules(guildId: string, value: ModuleToggleValue): Promise {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/modules`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ modules: value })
+ });
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
+ }
+ return { ok: true };
+ } catch {
+ return { ok: false, error: 'Network error' };
+ }
+}
+
+interface ModuleTogglesFormProps {
+ guildId: string;
+ initialStatuses: ModuleStatus[];
+}
+
+export function ModuleTogglesForm({ guildId, initialStatuses }: ModuleTogglesFormProps) {
+ const t = useTranslations();
+
+ return (
+
+ initialValue={toValue(initialStatuses)}
+ onSave={(value) => saveModules(guildId, value)}
+ >
+ {({ value, setValue }) => (
+
+ {MODULE_GROUP_ORDER.map((group) => {
+ const modules = DASHBOARD_MODULES.filter((module) => module.group === group);
+ return (
+
+
+ {t(`moduleGroups.${group}`)}
+
+
+ {modules.map((module) => (
+
+
+
+
{t(`modules.${module.id}.label`)}
+
+ {t('modulesPage.configure')}
+
+
+
+
+ {t(`modules.${module.id}.description`)}
+
+
+
+ setValue((prev) => ({ ...prev, [module.id]: checked }))
+ }
+ />
+
+ ))}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/webui/src/middleware.ts b/apps/webui/src/middleware.ts
new file mode 100644
index 0000000..2e99e39
--- /dev/null
+++ b/apps/webui/src/middleware.ts
@@ -0,0 +1,28 @@
+import { NextResponse, type NextRequest } from 'next/server';
+
+// Kept as a literal (not imported from `lib/session.ts`) on purpose: the
+// Edge middleware runtime cannot bundle the Redis/Node-only code that
+// `lib/session.ts` pulls in, so this file must stay dependency-free.
+const SESSION_COOKIE_NAME = 'nexumi_session';
+
+/**
+ * Lightweight edge-safe guard: only checks that the session cookie exists.
+ * The actual session and permission validation happens in
+ * `requireAuthOrRedirect` / `requireGuildAccessOrRedirect` inside the
+ * dashboard layouts, since that requires Redis/Prisma access which is not
+ * available in the middleware runtime.
+ */
+export function middleware(request: NextRequest) {
+ const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
+
+ if (!hasSession) {
+ const loginUrl = new URL('/login', request.url);
+ return NextResponse.redirect(loginUrl);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ['/dashboard/:path*']
+};
diff --git a/docker-compose.yml b/docker-compose.yml
index 52a13e8..6bb89e5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -48,15 +48,31 @@ services:
start_period: 40s
webui:
- image: node:22-alpine
- working_dir: /app
- command: sh -c "node -e \"require('http').createServer((_,res)=>res.end('webui scaffold')).listen(3000)\""
+ build:
+ context: .
+ dockerfile: apps/webui/Dockerfile
+ env_file: .env
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
ports:
- "3000:3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.nexumi.rule=Host(`nexumi.de`)"
- "traefik.http.services.nexumi.loadbalancer.server.port=3000"
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "node -e \"fetch('http://127.0.0.1:3000/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""
+ ]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
volumes:
postgres_data: