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 ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- - -
- -
- ); +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: