Refactor layout and home page for improved localization and session handling
- Updated RootLayout to include locale support and integrated ThemeProvider and LocaleProvider for better internationalization. - Replaced static home page content with a redirect based on user session status, enhancing user experience by directing to the appropriate dashboard or login page. - Switched font from Geist to Inter for improved typography consistency.
This commit is contained in:
30
apps/webui/Dockerfile
Normal file
30
apps/webui/Dockerfile
Normal file
@@ -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"]
|
||||||
60
apps/webui/src/app/api/auth/callback/route.ts
Normal file
60
apps/webui/src/app/api/auth/callback/route.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
16
apps/webui/src/app/api/auth/login/route.ts
Normal file
16
apps/webui/src/app/api/auth/login/route.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
7
apps/webui/src/app/api/auth/logout/route.ts
Normal file
7
apps/webui/src/app/api/auth/logout/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
7
apps/webui/src/app/api/auth/me/route.ts
Normal file
7
apps/webui/src/app/api/auth/me/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/webui/src/app/api/guilds/[guildId]/audit/route.ts
Normal file
20
apps/webui/src/app/api/guilds/[guildId]/audit/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/modules/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/modules/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/settings/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/settings/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/webui/src/app/api/guilds/route.ts
Normal file
13
apps/webui/src/app/api/guilds/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/webui/src/app/api/health/route.ts
Normal file
5
apps/webui/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
39
apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx
Normal file
39
apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-16 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Construction className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold">{t(locale, 'modulePlaceholder.title', { module: moduleLabel })}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modulePlaceholder.message')}</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/dashboard/${guildId}/modules`} className="text-sm text-primary hover:underline">
|
||||||
|
{t(locale, 'modulePlaceholder.backToModules')}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/webui/src/app/dashboard/[guildId]/access/loading.tsx
Normal file
10
apps/webui/src/app/dashboard/[guildId]/access/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function AccessLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-56" />
|
||||||
|
<Skeleton className="h-48 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/access/page.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/access/page.tsx
Normal file
@@ -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 <AccessRulesForm guildId={guildId} initialRules={rules} />;
|
||||||
|
}
|
||||||
29
apps/webui/src/app/dashboard/[guildId]/layout.tsx
Normal file
29
apps/webui/src/app/dashboard/[guildId]/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<DashboardShell guildId={guildId} currentGuild={currentGuild} otherGuilds={otherGuilds} user={session.user}>
|
||||||
|
{children}
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function GuildOverviewLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-16 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/modules/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/modules/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function ModulesLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
<Skeleton className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-40 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/modules/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/modules/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'modulesPage.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'modulesPage.description')}</p>
|
||||||
|
</div>
|
||||||
|
<ModuleTogglesForm guildId={guildId} initialStatuses={statuses} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
apps/webui/src/app/dashboard/[guildId]/page.tsx
Normal file
87
apps/webui/src/app/dashboard/[guildId]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'dashboard.overview.title')}</h1>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
|
||||||
|
<Link href={`/dashboard/${guildId}/modules`} className="text-sm text-primary hover:underline">
|
||||||
|
{t(locale, 'dashboard.overview.viewAllModules')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{modules.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.moduleStatusEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{modules.map((module) => (
|
||||||
|
<Card key={module.id}>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
|
||||||
|
<Badge variant={module.enabled ? 'success' : 'secondary'}>
|
||||||
|
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.overview.recentAuditTitle')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{auditEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.auditEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{auditEntries.map((entry) => (
|
||||||
|
<li key={entry.id} className="flex items-center justify-between gap-4 py-2 text-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{entry.action}</p>
|
||||||
|
{entry.path && <p className="truncate text-xs text-muted-foreground">{entry.path}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatDate(entry.createdAt, locale)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/webui/src/app/dashboard/[guildId]/settings/loading.tsx
Normal file
10
apps/webui/src/app/dashboard/[guildId]/settings/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function SettingsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-40 rounded-lg" />
|
||||||
|
<Skeleton className="h-24 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/webui/src/app/dashboard/[guildId]/settings/page.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/settings/page.tsx
Normal file
@@ -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 <GeneralSettingsForm guildId={guildId} initialSettings={settings} />;
|
||||||
|
}
|
||||||
19
apps/webui/src/app/dashboard/loading.tsx
Normal file
19
apps/webui/src/app/dashboard/loading.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function DashboardListLoading() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen justify-center px-6 py-10">
|
||||||
|
<div className="w-full max-w-[1200px] space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-96" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-32 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/webui/src/app/dashboard/page.tsx
Normal file
93
apps/webui/src/app/dashboard/page.tsx
Normal file
@@ -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<DashboardGuild, 'id' | 'icon'>): 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 (
|
||||||
|
<main className="flex min-h-screen justify-center px-6 py-10">
|
||||||
|
<div className="w-full max-w-[1200px] space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{t(locale, 'dashboard.guildList.title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.guildList.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guilds.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.guildList.empty')}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{guilds.map((guild) => (
|
||||||
|
<Card key={guild.id}>
|
||||||
|
<CardContent className="flex flex-col gap-4 p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-10">
|
||||||
|
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
|
||||||
|
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{guild.name}</p>
|
||||||
|
{!guild.botPresent && (
|
||||||
|
<Badge variant="outline" className="mt-1">
|
||||||
|
{t(locale, 'dashboard.guildList.botMissing')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{guild.botPresent ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/dashboard/${guild.id}`}>{t(locale, 'dashboard.guildList.manage')}</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<a href={buildInviteUrl(guild.id)} target="_blank" rel="noreferrer">
|
||||||
|
{t(locale, 'dashboard.guildList.invite')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,31 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from 'next/font/google';
|
||||||
import "./globals.css";
|
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({
|
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'Nexumi Dashboard',
|
||||||
description: "Generated by create next app",
|
description: 'Manage your Nexumi Discord bot'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
children,
|
const locale = await getLocale();
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang={locale} className={inter.variable} suppressHydrationWarning>
|
||||||
<body
|
<body className="antialiased">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<ThemeProvider>
|
||||||
>
|
<LocaleProvider locale={locale} messages={MESSAGES[locale]}>
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</LocaleProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
52
apps/webui/src/app/login/page.tsx
Normal file
52
apps/webui/src/app/login/page.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex size-12 items-center justify-center rounded-xl bg-primary text-lg font-semibold text-primary-foreground">
|
||||||
|
N
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">{t(locale, 'login.title')}</CardTitle>
|
||||||
|
<CardDescription>{t(locale, 'login.subtitle')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/api/auth/login"
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{t(locale, 'login.cta')}
|
||||||
|
</a>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">{t(locale, 'login.footer')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,103 +1,7 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
export default function Home() {
|
export default async function RootPage() {
|
||||||
return (
|
const session = await getSession();
|
||||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
redirect(session ? '/dashboard' : '/login');
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
184
apps/webui/src/components/settings/access-rules-form.tsx
Normal file
184
apps/webui/src/components/settings/access-rules-form.tsx
Normal file
@@ -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<SettingsSaveResult> {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4 rounded-md border border-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id={roleInputId}
|
||||||
|
value={rule.roleId}
|
||||||
|
onChange={(event) => onChange({ ...rule, roleId: event.target.value.trim() })}
|
||||||
|
placeholder={t('access.roleIdPlaceholder')}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('access.roleIdHint')}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label={t('access.removeRule')}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('access.modulesLabel')}</Label>
|
||||||
|
<Select
|
||||||
|
value={rule.modulesMode}
|
||||||
|
onValueChange={(mode) => onChange({ ...rule, modulesMode: mode as 'all' | 'specific' })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-56">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t('access.allModulesOption')}</SelectItem>
|
||||||
|
<SelectItem value="specific">{t('access.specificModulesOption')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{rule.modulesMode === 'specific' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2 sm:grid-cols-3">
|
||||||
|
{DASHBOARD_MODULES.map((module) => (
|
||||||
|
<label key={module.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 rounded border-input accent-primary"
|
||||||
|
checked={rule.selectedModules.includes(module.id)}
|
||||||
|
onChange={(event) => toggleModule(module.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
{t(`modules.${module.id}.label`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccessRulesFormProps {
|
||||||
|
guildId: string;
|
||||||
|
initialRules: DashboardAccessRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccessRulesForm({ guildId, initialRules }: AccessRulesFormProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsForm<LocalRule[]>
|
||||||
|
initialValue={toLocalRules(initialRules)}
|
||||||
|
onSave={(value) => saveAccessRules(guildId, value)}
|
||||||
|
>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('access.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('access.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{value.length === 0 && <p className="text-sm text-muted-foreground">{t('access.noRules')}</p>}
|
||||||
|
|
||||||
|
{value.map((rule) => (
|
||||||
|
<AccessRuleRow
|
||||||
|
key={rule.clientKey}
|
||||||
|
rule={rule}
|
||||||
|
onChange={(updated) =>
|
||||||
|
setValue((prev) => prev.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry)))
|
||||||
|
}
|
||||||
|
onRemove={() => setValue((prev) => prev.filter((entry) => entry.clientKey !== rule.clientKey))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setValue((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
clientKey: `new-${Date.now()}-${prev.length}`,
|
||||||
|
roleId: '',
|
||||||
|
modulesMode: 'all',
|
||||||
|
selectedModules: []
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t('access.addRule')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
apps/webui/src/components/settings/module-toggles-form.tsx
Normal file
102
apps/webui/src/components/settings/module-toggles-form.tsx
Normal file
@@ -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<DashboardModuleId, boolean>;
|
||||||
|
|
||||||
|
const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
|
||||||
|
'moderation',
|
||||||
|
'engagement',
|
||||||
|
'community',
|
||||||
|
'utility',
|
||||||
|
'integrations'
|
||||||
|
];
|
||||||
|
|
||||||
|
function toValue(statuses: ModuleStatus[]): ModuleToggleValue {
|
||||||
|
return statuses.reduce<ModuleToggleValue>((acc, status) => {
|
||||||
|
acc[status.id] = status.enabled;
|
||||||
|
return acc;
|
||||||
|
}, {} as ModuleToggleValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModules(guildId: string, value: ModuleToggleValue): Promise<SettingsSaveResult> {
|
||||||
|
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 (
|
||||||
|
<SettingsForm<ModuleToggleValue>
|
||||||
|
initialValue={toValue(initialStatuses)}
|
||||||
|
onSave={(value) => saveModules(guildId, value)}
|
||||||
|
>
|
||||||
|
{({ value, setValue }) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{MODULE_GROUP_ORDER.map((group) => {
|
||||||
|
const modules = DASHBOARD_MODULES.filter((module) => module.group === group);
|
||||||
|
return (
|
||||||
|
<Card key={group}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t(`moduleGroups.${group}`)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="divide-y divide-border">
|
||||||
|
{modules.map((module) => (
|
||||||
|
<div key={module.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">{t(`modules.${module.id}.label`)}</p>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${guildId}/${module.href}`}
|
||||||
|
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('modulesPage.configure')}
|
||||||
|
<ArrowUpRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{t(`modules.${module.id}.description`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={value[module.id] ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setValue((prev) => ({ ...prev, [module.id]: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/webui/src/middleware.ts
Normal file
28
apps/webui/src/middleware.ts
Normal file
@@ -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*']
|
||||||
|
};
|
||||||
@@ -48,15 +48,31 @@ services:
|
|||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: node:22-alpine
|
build:
|
||||||
working_dir: /app
|
context: .
|
||||||
command: sh -c "node -e \"require('http').createServer((_,res)=>res.end('webui scaffold')).listen(3000)\""
|
dockerfile: apps/webui/Dockerfile
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.nexumi.rule=Host(`nexumi.de`)"
|
- "traefik.http.routers.nexumi.rule=Host(`nexumi.de`)"
|
||||||
- "traefik.http.services.nexumi.loadbalancer.server.port=3000"
|
- "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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user