feat(rbac): enforce admin access checks and document permission model
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
import { hasPermission } from "@cms/content/rbac"
|
||||
import { listPosts } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { resolveRoleFromServerContext } from "@/lib/access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminHomePage() {
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (!role || !hasPermission(role, "news:read", "team")) {
|
||||
redirect("/unauthorized?required=news:read&scope=team")
|
||||
}
|
||||
|
||||
const canCreatePost = hasPermission(role, "news:write", "team")
|
||||
const posts = await listPosts()
|
||||
|
||||
return (
|
||||
@@ -26,7 +37,7 @@ export default async function AdminHomePage() {
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">Posts</h2>
|
||||
<Button>Create post</Button>
|
||||
<Button disabled={!canCreatePost}>Create post</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -401,6 +405,12 @@ function filterButtonClass(active: boolean): string {
|
||||
export default async function AdminTodoPage(props: {
|
||||
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (!role || !hasPermission(role, "roadmap:read", "global")) {
|
||||
redirect("/unauthorized?required=roadmap:read&scope=global")
|
||||
}
|
||||
|
||||
const content = await getTodoMarkdown()
|
||||
const sections = parseTodo(content)
|
||||
const progress = getProgressCounts(sections)
|
||||
|
||||
57
apps/admin/src/app/unauthorized/page.tsx
Normal file
57
apps/admin/src/app/unauthorized/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import Link from "next/link"
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||
|
||||
function getSingleValue(input: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(input)) {
|
||||
return input[0]
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
export default async function UnauthorizedPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const params = await searchParams
|
||||
|
||||
const required = getSingleValue(params.required)
|
||||
const scope = getSingleValue(params.scope)
|
||||
const reason = getSingleValue(params.reason)
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-xl flex-col gap-6 px-6 py-20">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Access denied</h1>
|
||||
<p className="text-neutral-600">
|
||||
You do not have the required role/permission for this admin route.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-5">
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="text-neutral-500">Reason</dt>
|
||||
<dd className="font-medium text-neutral-800">{reason ?? "insufficient-permission"}</dd>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="text-neutral-500">Required permission</dt>
|
||||
<dd className="font-medium text-neutral-800">{required ?? "n/a"}</dd>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="text-neutral-500">Required scope</dt>
|
||||
<dd className="font-medium text-neutral-800">{scope ?? "n/a"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex w-fit rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user