diff --git a/src/app/admin/[section]/page.tsx b/src/app/admin/[section]/page.tsx new file mode 100644 index 000000000..9a3454459 --- /dev/null +++ b/src/app/admin/[section]/page.tsx @@ -0,0 +1,50 @@ +import { notFound } from "next/navigation"; + +import { AdminShell } from "@/components/admin/admin-shell"; +import { AdminSectionRenderer } from "./renderer"; +import { + ADMIN_SECTION_SLUGS, + isAdminSectionSlug, +} from "@/components/admin/section-slugs"; + +/** + * Dynamic admin section route — mirrors the v1.4 settings split. Each of + * the `ADMIN_SECTION_SLUGS` is pre-rendered at build via + * `generateStaticParams()` so the URLs are statically known to Next.js, + * while `dynamicParams = false` tells the router to 404 (rather than + * attempting on-demand rendering) for any slug not in the list. + * + * The actual section components are mounted by the client-only + * `` because most of them use TanStack Query and + * the auth hook (server-rendered they'd just paint a loader). The shell, + * however, is server-rendered so the sidebar paints with no JS hydration + * lag. + */ + +export const dynamicParams = false; + +export function generateStaticParams() { + return ADMIN_SECTION_SLUGS.map((section) => ({ section })); +} + +interface PageProps { + // Next.js 16 made route `params` an async Promise. We `await` it before use. + params: Promise<{ section: string }>; +} + +export default async function AdminSectionPage({ params }: PageProps) { + const { section } = await params; + + // Defence-in-depth — `dynamicParams = false` already 404s unknown slugs at + // routing time, but we re-check here so a hand-rolled override of the + // route config can never silently fall through to a typo'd slug. + if (!isAdminSectionSlug(section)) { + notFound(); + } + + return ( + + + + ); +} diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx new file mode 100644 index 000000000..203a03771 --- /dev/null +++ b/src/app/admin/[section]/renderer.tsx @@ -0,0 +1,175 @@ +"use client"; + +/** + * Client-only renderer that picks the right admin section component for + * a given slug. Lives next to the route page so the shell can stay a + * server component while the auth-gated body remains a client component. + * + * This is the equivalent of `SECTION_COMPONENTS` in + * `src/app/settings/[section]/page.tsx`, lifted to a separate file so + * the route page can be a server component (Settings can keep + * `"use client"`-leaning components in its page because none of them + * read `useAuth()` to gate rendering). + */ + +import type { JSX } from "react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; +import { BackupsSection } from "@/components/admin/backups-section"; +import { DangerZoneSection } from "@/components/admin/danger-zone-section"; +import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section"; +import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; +import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section"; +import { LoginOverviewSection } from "@/components/admin/login-overview-section"; +import { RemindersSection } from "@/components/admin/reminders-section"; +import { ServicesSection } from "@/components/admin/services-section"; +import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { UserManagementSection } from "@/components/admin/user-management-section"; +import type { AdminSectionSlug } from "@/components/admin/section-slugs"; + +interface RendererProps { + slug: AdminSectionSlug; +} + +export function AdminSectionRenderer({ + slug, +}: RendererProps): JSX.Element | null { + const { user } = useAuth(); + const { t } = useTranslations(); + + // The admin shell layout already gates non-admins via ``, + // but the auth hook is async — return nothing on the first render so + // we don't flash sub-section UI to a non-admin who's about to get + // redirected to `/`. + if (!user || user.role !== "ADMIN") return null; + + switch (slug) { + case "system-status": + return ( + + + + ); + case "general": + return ( + + + + ); + case "services": + return ( + + + + ); + case "integrations": + return ( + + + + ); + case "feedback": + return ( + + + + ); + case "reminders": + return ( + + + + ); + case "users": + return ( + + + + ); + case "api-tokens": + return ( + + + + ); + case "login-overview": + return ( + + + + ); + case "backups": + return ( + + + + ); + case "danger-zone": + return ( + + + + ); + default: { + // exhaustiveness guard — TS will flag a missing case here + const _exhaustive: never = slug; + void _exhaustive; + return null; + } + } +} + +interface SectionFrameProps { + title: string; + subtitle?: string; + children: React.ReactNode; +} + +function SectionFrame({ title, subtitle, children }: SectionFrameProps) { + return ( +
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ {children} +
+ ); +} diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx new file mode 100644 index 000000000..4eedc23b0 --- /dev/null +++ b/src/components/admin/admin-shell.tsx @@ -0,0 +1,232 @@ +"use client"; + +/** + * `` — sidebar + main column shell for the v1.5 admin split. + * + * Mirrors `` (`src/components/settings/settings-shell.tsx`): + * each section lives at its own route under `/admin/[section]/page.tsx` and + * mounts inside this shell. The shell only owns navigation; the page itself + * renders the section heading and content. + * + * Mobile-first per `docs/ui-guidelines.md`: + * - =md: sticky 220px sidebar + content column with `max-w-screen-xl`. + */ + +import * as React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + AlertTriangle, + Bell, + Database, + KeyRound, + Inbox, + Plug, + ScrollText, + Server, + Settings, + ShieldAlert, + Users, + type LucideIcon, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { + ADMIN_SECTION_SLUGS, + isAdminSectionSlug, + type AdminSectionSlug, +} from "./section-slugs"; + +// Re-export so callers don't have to chase two modules. New server-side +// callers (e.g. `generateStaticParams()`) should still import from +// `./section-slugs` directly — values exported through this client +// boundary become unusable proxies on the server. +export { ADMIN_SECTION_SLUGS, isAdminSectionSlug, type AdminSectionSlug }; + +interface AdminSection { + slug: AdminSectionSlug; + /** i18n key under `admin.section..title`. */ + titleKey: string; + icon: LucideIcon; +} + +/** + * Source of truth for the admin section list. Order matches the in-app + * navigation order so the sidebar, mobile strip, and `generateStaticParams()` + * all line up. Don't reorder without updating tests. + */ +export const ADMIN_SECTIONS: readonly AdminSection[] = [ + { + slug: "system-status", + titleKey: "admin.section.system-status.title", + icon: Server, + }, + { + slug: "general", + titleKey: "admin.section.general.title", + icon: Settings, + }, + { + slug: "services", + titleKey: "admin.section.services.title", + icon: Plug, + }, + { + slug: "integrations", + titleKey: "admin.section.integrations.title", + icon: AlertTriangle, + }, + { + slug: "feedback", + titleKey: "admin.section.feedback.title", + icon: Inbox, + }, + { + slug: "reminders", + titleKey: "admin.section.reminders.title", + icon: Bell, + }, + { + slug: "users", + titleKey: "admin.section.users.title", + icon: Users, + }, + { + slug: "api-tokens", + titleKey: "admin.section.api-tokens.title", + icon: KeyRound, + }, + { + slug: "login-overview", + titleKey: "admin.section.login-overview.title", + icon: ScrollText, + }, + { + slug: "backups", + titleKey: "admin.section.backups.title", + icon: Database, + }, + { + slug: "danger-zone", + titleKey: "admin.section.danger-zone.title", + icon: ShieldAlert, + }, +] as const; + +export interface AdminShellProps { + /** + * Optional override for the active section. When omitted the shell reads + * the active slug from `usePathname()` (the production behaviour). Tests + * pass an explicit value so they don't need to mock the router. + */ + active?: AdminSectionSlug; + children: React.ReactNode; +} + +function deriveActiveSlug( + pathname: string | null, + override?: AdminSectionSlug, +): AdminSectionSlug | null { + if (override) return override; + if (!pathname) return null; + // Match `/admin/` and ignore any trailing segments. The + // `/admin` overview page falls through to `null` so no entry is + // marked active. + const match = pathname.match(/^\/admin\/([^/]+)/); + const candidate = match?.[1] ?? ""; + return isAdminSectionSlug(candidate) ? candidate : null; +} + +export function AdminShell({ active, children }: AdminShellProps) { + const pathname = usePathname(); + const { t } = useTranslations(); + const activeSlug = deriveActiveSlug(pathname, active); + + return ( +
+ {/* Mobile section strip — horizontal scroll, hidden on md+. */} + + +
+ {/* Desktop sticky sidebar */} + + + {/* Main column — page renders its own h1 + subtitle */} +
{children}
+
+
+ ); +} diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts new file mode 100644 index 000000000..a4776b753 --- /dev/null +++ b/src/components/admin/section-slugs.ts @@ -0,0 +1,52 @@ +/** + * Server-safe constants for the v1.5 admin split. Mirrors the pattern in + * `src/components/settings/section-slugs.ts` so server-rendered code paths + * (`generateStaticParams()`, the page guard, route-existence tests) can + * import the slug list without dragging the `` client bundle + * into the server graph. + * + * Order is the in-app navigation order — sidebar nav, mobile strip, and + * `generateStaticParams()` all consume it directly. + */ + +export const ADMIN_SECTION_SLUGS = [ + "system-status", + "general", + "services", + "integrations", + "feedback", + "reminders", + "users", + "api-tokens", + "login-overview", + "backups", + "danger-zone", +] as const; + +export type AdminSectionSlug = (typeof ADMIN_SECTION_SLUGS)[number]; + +export function isAdminSectionSlug(value: string): value is AdminSectionSlug { + return (ADMIN_SECTION_SLUGS as readonly string[]).includes(value); +} + +/** + * Legacy hash-anchor → new slug mapping. Used by `` to + * rewrite hrefs and by `proxy.ts` to handle any server-side legacy URL + * forms (path-style anchors are redirectable; pure `#fragments` never + * reach the server, so those are fixed at the call-sites instead). + */ +export const LEGACY_ANCHOR_TO_SLUG: Record = { + "section-system-status": "system-status", + "section-admin-general": "general", + "section-admin-services": "services", + "section-admin-umami": "integrations", + "section-admin-glitchtip": "integrations", + "section-admin-webpush": "integrations", + "section-admin-bugreport": "integrations", + "section-admin-feedback": "feedback", + "section-admin-reminders": "reminders", + "section-user-management": "users", + "section-api-tokens": "api-tokens", + "section-login-overview": "login-overview", + "section-danger-zone": "danger-zone", +};