diff --git a/TODO.md b/TODO.md index de8dd22..8d4c007 100644 --- a/TODO.md +++ b/TODO.md @@ -41,11 +41,11 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] Separate Next.js admin app in monorepo - [x] [P1] App Router + TypeScript + `src/` structure - [x] [P1] Shared DB access via `@cms/db` -- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`) +- [x] [P2] Base admin dashboard shell and roadmap page (`/todo`) - [x] [P1] Authentication and session model (`admin`, `editor`, `manager`) - [x] [P1] Protected admin routes and session handling -- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation -- [~] [P1] Core admin IA (pages/media/users/commissions/settings) +- [x] [P1] Temporary admin posts CRUD sandbox for baseline functional validation +- [x] [P1] Core admin IA (pages/media/users/commissions/settings) ### Public App @@ -203,6 +203,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] Admin self-registration policy is now managed via `/settings` and persisted in `system_setting`; env var is fallback/default only. - [2026-02-10] E2E now runs with deterministic preparation (`test:e2e:prepare`: generate + migrate deploy + seed) before Playwright execution. - [2026-02-10] CI quality workflow `.gitea/workflows/ci.yml` enforces `check`, `typecheck`, `test`, and `test:e2e` against a PostgreSQL service. +- [2026-02-10] Admin app now uses a shared shell with permission-aware navigation and dedicated IA routes (`/pages`, `/media`, `/users`, `/commissions`). ## How We Use This File diff --git a/apps/admin/src/app/commissions/page.tsx b/apps/admin/src/app/commissions/page.tsx new file mode 100644 index 0000000..4826442 --- /dev/null +++ b/apps/admin/src/app/commissions/page.tsx @@ -0,0 +1,34 @@ +import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder" +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +export default async function CommissionsManagementPage() { + const role = await requirePermissionForRoute({ + nextPath: "/commissions", + permission: "commissions:read", + scope: "own", + }) + + return ( + + + + ) +} diff --git a/apps/admin/src/app/media/page.tsx b/apps/admin/src/app/media/page.tsx new file mode 100644 index 0000000..56b9eae --- /dev/null +++ b/apps/admin/src/app/media/page.tsx @@ -0,0 +1,34 @@ +import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder" +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +export default async function MediaManagementPage() { + const role = await requirePermissionForRoute({ + nextPath: "/media", + permission: "media:read", + scope: "team", + }) + + return ( + + + + ) +} diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index 03e3216..691f99d 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -5,11 +5,10 @@ import { revalidatePath } from "next/cache" import Link from "next/link" import { redirect } from "next/navigation" -import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher" +import { AdminShell } from "@/components/admin-shell" import { translateMessage } from "@/i18n/messages" import { getAdminMessages, resolveAdminLocale } from "@/i18n/server" -import { resolveRoleFromServerContext } from "@/lib/access-server" -import { LogoutButton } from "./logout-button" +import { requirePermissionForRoute } from "@/lib/route-guards" export const dynamic = "force-dynamic" @@ -39,11 +38,11 @@ function readOptionalField(formData: FormData, field: string): string | undefine } async function requireNewsWritePermission() { - const role = await resolveRoleFromServerContext() - - if (!role || !hasPermission(role, "news:write", "team")) { - redirect("/unauthorized?required=news:write&scope=team") - } + await requirePermissionForRoute({ + nextPath: "/", + permission: "news:write", + scope: "team", + }) } function redirectWithState(params: { notice?: string; error?: string }) { @@ -156,15 +155,11 @@ export default async function AdminHomePage({ }: { searchParams: Promise }) { - const role = await resolveRoleFromServerContext() - - if (!role) { - redirect("/login?next=/") - } - - if (!hasPermission(role, "news:read", "team")) { - redirect("/unauthorized?required=news:read&scope=team") - } + const role = await requirePermissionForRoute({ + nextPath: "/", + permission: "news:read", + scope: "team", + }) const [resolvedSearchParams, locale, posts] = await Promise.all([ searchParams, @@ -179,21 +174,14 @@ export default async function AdminHomePage({ const canCreatePost = hasPermission(role, "news:write", "team") return ( -
-
-
-

- {t("dashboard.badge", "Admin App")} -

- -
-

- {t("dashboard.title", "Content Dashboard")} -

-

- {t("dashboard.description", "Manage posts from a dedicated admin surface.")} -

-
+ {t("settings.title", "Settings")} - -
-
- + + } + > {notice ? (
{notice} @@ -413,6 +400,6 @@ export default async function AdminHomePage({ ))}
-
+ ) } diff --git a/apps/admin/src/app/pages/page.tsx b/apps/admin/src/app/pages/page.tsx new file mode 100644 index 0000000..41a6e32 --- /dev/null +++ b/apps/admin/src/app/pages/page.tsx @@ -0,0 +1,34 @@ +import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder" +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +export default async function PagesManagementPage() { + const role = await requirePermissionForRoute({ + nextPath: "/pages", + permission: "pages:read", + scope: "team", + }) + + return ( + + + + ) +} diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx index e1793d0..fda5ec3 100644 --- a/apps/admin/src/app/settings/page.tsx +++ b/apps/admin/src/app/settings/page.tsx @@ -1,14 +1,13 @@ -import { hasPermission } from "@cms/content/rbac" import { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "@cms/db" import { Button } from "@cms/ui/button" import { revalidatePath } from "next/cache" import Link from "next/link" import { redirect } from "next/navigation" -import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher" +import { AdminShell } from "@/components/admin-shell" import { translateMessage } from "@/i18n/messages" import { getAdminMessages, resolveAdminLocale } from "@/i18n/server" -import { resolveRoleFromServerContext } from "@/lib/access-server" +import { requirePermissionForRoute } from "@/lib/route-guards" type SearchParamsInput = Promise> @@ -21,15 +20,11 @@ function toSingleValue(input: string | string[] | undefined): string | null { } async function requireSettingsPermission() { - const role = await resolveRoleFromServerContext() - - if (!role) { - redirect("/login?next=/settings") - } - - if (!hasPermission(role, "users:manage_roles", "global")) { - redirect("/unauthorized?required=users:manage_roles&scope=global") - } + await requirePermissionForRoute({ + nextPath: "/settings", + permission: "users:manage_roles", + scope: "global", + }) } async function getSettingsTranslator() { @@ -85,7 +80,11 @@ async function updateRegistrationPolicyAction(formData: FormData) { } export default async function SettingsPage({ searchParams }: { searchParams: SearchParamsInput }) { - await requireSettingsPermission() + const role = await requirePermissionForRoute({ + nextPath: "/settings", + permission: "users:manage_roles", + scope: "global", + }) const [params, locale, isRegistrationEnabled] = await Promise.all([ searchParams, @@ -99,31 +98,24 @@ export default async function SettingsPage({ searchParams }: { searchParams: Sea const error = toSingleValue(params.error) return ( -
-
-
-

- {t("settings.badge", "Admin Settings")} -

- -
-

{t("settings.title", "Settings")}

-

- {t( - "settings.description", - "Manage runtime policies for the admin authentication and onboarding flow.", - )} -

-
- - {t("settings.actions.backToDashboard", "Back to dashboard")} - -
-
- + + {t("settings.actions.backToDashboard", "Back to dashboard")} + + } + > {notice ? (
{notice} @@ -183,6 +175,6 @@ export default async function SettingsPage({ searchParams }: { searchParams: Sea
-
+ ) } diff --git a/apps/admin/src/app/todo/page.tsx b/apps/admin/src/app/todo/page.tsx index 34878ff..f9805ef 100644 --- a/apps/admin/src/app/todo/page.tsx +++ b/apps/admin/src/app/todo/page.tsx @@ -1,10 +1,9 @@ import { readFile } from "node:fs/promises" import path from "node:path" -import { hasPermission } from "@cms/content/rbac" import Link from "next/link" -import { redirect } from "next/navigation" -import { resolveRoleFromServerContext } from "@/lib/access-server" +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" export const dynamic = "force-dynamic" @@ -405,15 +404,11 @@ function filterButtonClass(active: boolean): string { export default async function AdminTodoPage(props: { searchParams?: SearchParamsInput | Promise }) { - const role = await resolveRoleFromServerContext() - - if (!role) { - redirect("/login?next=/todo") - } - - if (!hasPermission(role, "roadmap:read", "global")) { - redirect("/unauthorized?required=roadmap:read&scope=global") - } + const role = await requirePermissionForRoute({ + nextPath: "/todo", + permission: "roadmap:read", + scope: "global", + }) const content = await getTodoMarkdown() const sections = parseTodo(content) @@ -434,26 +429,21 @@ export default async function AdminTodoPage(props: { } return ( -
-
-

Admin App

-
-
-

Roadmap and Progress

-

- Structured view from root `TODO.md` (single source of truth). -

-
- - - Back to dashboard - -
-
- + + Back to dashboard + + } + >

Weighted completion

@@ -607,6 +597,6 @@ export default async function AdminTodoPage(props: { {content} -
+ ) } diff --git a/apps/admin/src/app/users/page.tsx b/apps/admin/src/app/users/page.tsx new file mode 100644 index 0000000..ad6acd9 --- /dev/null +++ b/apps/admin/src/app/users/page.tsx @@ -0,0 +1,34 @@ +import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder" +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +export default async function UsersManagementPage() { + const role = await requirePermissionForRoute({ + nextPath: "/users", + permission: "users:read", + scope: "own", + }) + + return ( + + + + ) +} diff --git a/apps/admin/src/components/admin-section-placeholder.tsx b/apps/admin/src/components/admin-section-placeholder.tsx new file mode 100644 index 0000000..bad9fd6 --- /dev/null +++ b/apps/admin/src/components/admin-section-placeholder.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react" + +type AdminSectionPlaceholderProps = { + feature: string + summary: string + requiredPermission: string + nextSteps: string[] + children?: ReactNode +} + +export function AdminSectionPlaceholder({ + feature, + summary, + requiredPermission, + nextSteps, + children, +}: AdminSectionPlaceholderProps) { + return ( +
+
+

{feature}

+

{summary}

+

+ Required permission: {requiredPermission} +

+
+ + {children} + +
+

Planned next steps

+
    + {nextSteps.map((step) => ( +
  • {step}
  • + ))} +
+
+
+ ) +} diff --git a/apps/admin/src/components/admin-shell.tsx b/apps/admin/src/components/admin-shell.tsx new file mode 100644 index 0000000..0457d7d --- /dev/null +++ b/apps/admin/src/components/admin-shell.tsx @@ -0,0 +1,117 @@ +import { hasPermission, type Permission, type PermissionScope, type Role } from "@cms/content/rbac" +import Link from "next/link" +import type { ReactNode } from "react" + +import { LogoutButton } from "@/app/logout-button" +import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher" + +type AdminShellProps = { + role: Role + activePath: string + badge: string + title: string + description: string + actions?: ReactNode + children: ReactNode +} + +type NavItem = { + href: string + label: string + permission: Permission + scope: PermissionScope +} + +const navItems: NavItem[] = [ + { href: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" }, + { href: "/pages", label: "Pages", permission: "pages:read", scope: "team" }, + { href: "/media", label: "Media", permission: "media:read", scope: "team" }, + { href: "/users", label: "Users", permission: "users:read", scope: "own" }, + { href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" }, + { href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" }, + { href: "/todo", label: "Roadmap", permission: "roadmap:read", scope: "global" }, +] + +function navItemClass(active: boolean): string { + if (active) { + return "bg-neutral-900 text-white border-neutral-900" + } + + return "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100" +} + +function isActiveRoute(activePath: string, href: string): boolean { + if (href === "/") { + return activePath === "/" + } + + return activePath === href || activePath.startsWith(`${href}/`) +} + +export function AdminShell({ + role, + activePath, + badge, + title, + description, + actions, + children, +}: AdminShellProps) { + return ( +
+ + +
+ + +
+
+

{badge}

+
+ + +
+
+

{title}

+

{description}

+ {actions ?
{actions}
: null} +
+ + {children} +
+
+ ) +} diff --git a/apps/admin/src/lib/access.test.ts b/apps/admin/src/lib/access.test.ts index 717e061..6e10102 100644 --- a/apps/admin/src/lib/access.test.ts +++ b/apps/admin/src/lib/access.test.ts @@ -21,4 +21,23 @@ describe("admin route access rules", () => { scope: "global", }) }) + + it("maps new admin IA routes to dedicated permissions", () => { + expect(getRequiredPermission("/pages")).toEqual({ + permission: "pages:read", + scope: "team", + }) + expect(getRequiredPermission("/media")).toEqual({ + permission: "media:read", + scope: "team", + }) + expect(getRequiredPermission("/users")).toEqual({ + permission: "users:read", + scope: "own", + }) + expect(getRequiredPermission("/commissions")).toEqual({ + permission: "commissions:read", + scope: "own", + }) + }) }) diff --git a/apps/admin/src/lib/access.ts b/apps/admin/src/lib/access.ts index dec492c..ea8aa8c 100644 --- a/apps/admin/src/lib/access.ts +++ b/apps/admin/src/lib/access.ts @@ -43,6 +43,34 @@ const guardRules: GuardRule[] = [ scope: "global", }, }, + { + route: /^\/pages(?:\/|$)/, + requirement: { + permission: "pages:read", + scope: "team", + }, + }, + { + route: /^\/media(?:\/|$)/, + requirement: { + permission: "media:read", + scope: "team", + }, + }, + { + route: /^\/users(?:\/|$)/, + requirement: { + permission: "users:read", + scope: "own", + }, + }, + { + route: /^\/commissions(?:\/|$)/, + requirement: { + permission: "commissions:read", + scope: "own", + }, + }, { route: /^\/settings(?:\/|$)/, requirement: { diff --git a/apps/admin/src/lib/route-guards.ts b/apps/admin/src/lib/route-guards.ts new file mode 100644 index 0000000..b7cef35 --- /dev/null +++ b/apps/admin/src/lib/route-guards.ts @@ -0,0 +1,30 @@ +import { hasPermission, type Permission, type PermissionScope, type Role } from "@cms/content/rbac" +import { redirect } from "next/navigation" + +import { resolveRoleFromServerContext } from "@/lib/access-server" + +type RequirePermissionParams = { + nextPath: string + permission: Permission + scope: PermissionScope +} + +export async function requireRoleForRoute(nextPath: string): Promise { + const role = await resolveRoleFromServerContext() + + if (!role) { + redirect(`/login?next=${encodeURIComponent(nextPath)}`) + } + + return role +} + +export async function requirePermissionForRoute(params: RequirePermissionParams): Promise { + const role = await requireRoleForRoute(params.nextPath) + + if (!hasPermission(role, params.permission, params.scope)) { + redirect(`/unauthorized?required=${params.permission}&scope=${params.scope}`) + } + + return role +}