Task 004: add demo auth and role checks

This commit is contained in:
2026-05-21 18:31:40 +03:00
parent 7a11d50aa7
commit 7972e9a95f
26 changed files with 2207 additions and 23 deletions
+1
View File
@@ -5,6 +5,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"test:roles": "node tests/role-navigation.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+67
View File
@@ -19,3 +19,70 @@ main {
min-height: 100vh;
padding: 48px;
}
.pageHeader {
display: flex;
max-width: 920px;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.pageHeader h1 {
margin: 0 0 8px;
font-size: 32px;
line-height: 1.2;
letter-spacing: 0;
}
.pageHeader p {
margin: 0;
color: #667085;
}
.roleBadge {
display: inline-flex;
min-height: 32px;
align-items: center;
border: 1px solid #b7c0cc;
border-radius: 6px;
padding: 0 12px;
background: #ffffff;
color: #344054;
font-size: 14px;
}
.roleNavigation {
display: grid;
max-width: 920px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.roleNavigation a {
display: flex;
min-height: 52px;
align-items: center;
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 0 16px;
background: #ffffff;
color: #1d2733;
font-weight: 600;
text-decoration: none;
}
@media (max-width: 640px) {
main {
padding: 28px;
}
.pageHeader {
align-items: flex-start;
flex-direction: column;
}
}
+10 -5
View File
@@ -1,5 +1,7 @@
import type { ArticleListResponse } from "@pipeline/shared";
import { RoleNavigation } from "@/widgets/role-navigation";
const initialArticles: ArticleListResponse = {
articles: [],
};
@@ -7,11 +9,14 @@ const initialArticles: ArticleListResponse = {
export default function HomePage() {
return (
<main>
<h1>AI Content Pipeline</h1>
<p>Dockerized monorepo foundation is running.</p>
<p>
{initialArticles.articles.length} articles in the contract-backed dashboard.
</p>
<header className="pageHeader">
<div>
<h1>AI Content Pipeline</h1>
<p>{initialArticles.articles.length} active article workflows</p>
</div>
<span className="roleBadge">Editor demo</span>
</header>
<RoleNavigation role="EDITOR" />
</main>
);
}
@@ -0,0 +1,3 @@
export { getRoleNavigationItems, ROLE_NAVIGATION_ITEMS } from "./model";
export type { NavigationItem } from "./model";
export { RoleNavigation } from "./ui";
@@ -0,0 +1,47 @@
import type { Role } from "@pipeline/shared";
export type NavigationItem = {
id: string;
label: string;
href: string;
roles: readonly Role[];
};
export const ROLE_NAVIGATION_ITEMS = [
{
id: "dashboard",
label: "Dashboard",
href: "/",
roles: ["ADMIN", "EDITOR"],
},
{
id: "new-article",
label: "New article",
href: "/articles/new",
roles: ["ADMIN", "EDITOR"],
},
{
id: "review",
label: "Review",
href: "/review",
roles: ["ADMIN", "EDITOR"],
},
{
id: "site-config",
label: "Site configuration",
href: "/admin/sites",
roles: ["ADMIN"],
},
{
id: "script-versions",
label: "Script versions",
href: "/admin/scripts",
roles: ["ADMIN"],
},
] as const satisfies readonly NavigationItem[];
export function getRoleNavigationItems(role: Role): NavigationItem[] {
return ROLE_NAVIGATION_ITEMS.filter((item) =>
item.roles.some((allowedRole) => allowedRole === role),
);
}
@@ -0,0 +1,23 @@
import type { Role } from "@pipeline/shared";
import { getRoleNavigationItems } from "./model";
type RoleNavigationProps = {
role: Role;
};
export function RoleNavigation({ role }: RoleNavigationProps) {
const items = getRoleNavigationItems(role);
return (
<nav aria-label="Primary navigation">
<ul className="roleNavigation">
{items.map((item) => (
<li key={item.id}>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = dirname(fileURLToPath(import.meta.url));
const sourcePath = resolve(testDir, "../src/widgets/role-navigation/model.ts");
const source = readFileSync(sourcePath, "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
});
const exports = {};
new Function("exports", transpiled.outputText)(exports);
const editorItems = exports.getRoleNavigationItems("EDITOR");
const adminItems = exports.getRoleNavigationItems("ADMIN");
assert.deepEqual(
editorItems.map((item) => item.id),
["dashboard", "new-article", "review"],
);
assert.equal(
editorItems.some((item) => item.id === "site-config" || item.id === "script-versions"),
false,
);
assert.equal(
adminItems.some((item) => item.id === "site-config"),
true,
);
assert.equal(
adminItems.some((item) => item.id === "script-versions"),
true,
);
console.log("role navigation hides Admin-only items for Editors");