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 (
+
+
+
+
+
+
+
+
+ {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
+}