diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 2612f11..d5917b0 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -2,7 +2,7 @@ name: Quality Gates on: push: - branches: [main, '001-website-foundation'] + branches: [main, '001-website-foundation', '002-auth-rbac'] pull_request: jobs: @@ -46,3 +46,21 @@ jobs: working-directory: apps/Frontend - run: pnpm run check:all working-directory: apps/Frontend + + administration-fe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/Administration-FE/package-lock.json + - run: npm ci + working-directory: apps/Administration-FE + - run: npm run generate:client + working-directory: apps/Administration-FE + - run: npm run check:contracts + working-directory: apps/Administration-FE + - run: npm run check:i18n + working-directory: apps/Administration-FE diff --git a/README.md b/README.md index e1c4390..bba3cd6 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,19 @@ apps/ └── Backend/ deployment/ # Docker Compose, environment config, gateway specs/001-website-foundation/ # Feature spec, plan, contracts, quickstart +specs/002-auth-rbac/ # JWT auth + RBAC spec, plan, contracts, quickstart docs/ # Conventions and onboarding (implementation phase) ``` ## OpenAPI — single source of truth -All cross-boundary shapes live in `specs/001-website-foundation/contracts/` (OpenAPI 3.1). +Foundation payload schemas live in `specs/001-website-foundation/contracts/` (OpenAPI 3.1). Staff auth, RBAC, management, and publish live in `specs/002-auth-rbac/contracts/`. -- **Backend** MUST implement these contracts. +- **Backend** MUST implement these contracts. Staff auth is JWT access + refresh (`Authorization: Bearer`), not cookies or CSRF. - **Frontend** MUST generate or validate build-time types from the content, settings, SEO, and publish schemas. -- **Administration FE** MUST use an OpenAPI-generated API client — no hand-written DTOs that bypass the contract. +- **Administration FE** MUST generate types from `admin-auth.v2`, `admin-rbac.v1`, `admin-management.v2`, and `publish.v2` — no hand-written token or permission DTOs. Tokens stay in memory only. -See [contracts/README.md](specs/001-website-foundation/contracts/README.md). +See [001 contracts](specs/001-website-foundation/contracts/README.md) and [002 contracts](specs/002-auth-rbac/contracts/README.md). ## Deployment @@ -68,7 +69,7 @@ Gateway (default `http://localhost:8080`): After services are healthy: -1. Run Backend migrations and provision an administrator. +1. Run Backend migrations and `flycatch-bootstrap` (two staff users + default roles). `--role` is required on later `flycatch-provision-admin` calls. 2. Export the published snapshot and build `apps/Frontend`. 3. Rebuild containers when app images change: `docker compose -f deployment/docker-compose.yml up -d --build` diff --git a/apps/Administration-FE/Dockerfile b/apps/Administration-FE/Dockerfile index c1d5132..2c20111 100644 --- a/apps/Administration-FE/Dockerfile +++ b/apps/Administration-FE/Dockerfile @@ -3,8 +3,10 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . +COPY --from=specs . /specs +ENV CONTRACTS_DIR=/specs/002-auth-rbac/contracts ENV PUBLIC_ORIGIN=http://localhost:8080 -RUN npm run build +RUN npm run generate:client && npm run build FROM node:22-alpine WORKDIR /app diff --git a/apps/Administration-FE/astro.config.mjs b/apps/Administration-FE/astro.config.mjs index bf51d4b..48c973b 100644 --- a/apps/Administration-FE/astro.config.mjs +++ b/apps/Administration-FE/astro.config.mjs @@ -4,6 +4,7 @@ import react from '@astrojs/react'; export default defineConfig({ output: 'static', base: '/admin', + trailingSlash: 'always', integrations: [react()], vite: { server: { diff --git a/apps/Administration-FE/package.json b/apps/Administration-FE/package.json index adeb50c..e25703f 100644 --- a/apps/Administration-FE/package.json +++ b/apps/Administration-FE/package.json @@ -10,6 +10,7 @@ "check": "astro check", "generate:client": "node scripts/generate-client.mjs", "check:contracts": "node scripts/check-contract-drift.mjs", + "check:i18n": "node scripts/check-i18n.mjs", "test:unit": "vitest run", "test:e2e": "playwright test", "lint": "eslint src --ext .ts,.tsx,.astro" diff --git a/apps/Administration-FE/scripts/check-contract-drift.mjs b/apps/Administration-FE/scripts/check-contract-drift.mjs index 1d2bc89..1fcf934 100644 --- a/apps/Administration-FE/scripts/check-contract-drift.mjs +++ b/apps/Administration-FE/scripts/check-contract-drift.mjs @@ -1,2 +1,44 @@ #!/usr/bin/env node -console.log('Contract drift check: admin-api.ts endpoints aligned to admin-auth, admin-management, publish contracts'); +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const generatedDir = join(root, 'src/generated'); +const required = [ + 'admin-auth.v2.ts', + 'admin-rbac.v1.ts', + 'admin-management.v2.ts', + 'publish.v2.ts', +]; + +let failed = false; +for (const file of required) { + const full = join(generatedDir, file); + if (!existsSync(full)) { + console.error(`Missing generated contract file: ${file}. Run npm run generate:client`); + failed = true; + } +} + +const api = readFileSync(join(root, 'src/lib/admin-api.ts'), 'utf8'); +const forbidden = [ + /export (type|interface) TokenPair\s*\{/, + /export (type|interface) SessionContext\s*\{/, + /export (type|interface) PermissionName\s*=\s*['"]/, + /export (type|interface) PermissionDenied\s*\{/, +]; +for (const pattern of forbidden) { + if (pattern.test(api)) { + console.error(`Hand-written token/permission DTO detected in admin-api.ts: ${pattern}`); + failed = true; + } +} + +if (!api.includes('../generated/admin-auth.v2') || !api.includes('../generated/admin-rbac.v1')) { + console.error('admin-api.ts must import token/permission types from generated 002 contracts'); + failed = true; +} + +if (failed) process.exit(1); +console.log('Administration FE contract drift check passed'); diff --git a/apps/Administration-FE/scripts/check-i18n.mjs b/apps/Administration-FE/scripts/check-i18n.mjs new file mode 100644 index 0000000..055ca56 --- /dev/null +++ b/apps/Administration-FE/scripts/check-i18n.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const srcDir = join(dirname(fileURLToPath(import.meta.url)), '../src'); +const files = [ + 'components/SignInForm.tsx', + 'components/AdminShell.tsx', + 'components/PageEditor.tsx', +]; +const allowedLiteral = new Set(['Title', 'Description', 'Primary heading', 'Summary', 'Body']); + +let failed = false; +for (const file of files) { + const full = join(srcDir, file); + const content = readFileSync(full, 'utf8'); + const jsx = content.split(/return \(/).slice(1).join('\n'); + const textNodes = jsx.match(/>\s*([A-Za-z][^<{]*?)\s*\s*/, '').replace(/\s*<$/, '').trim(); + if (!text || allowedLiteral.has(text)) continue; + if (text.includes('{') || text.includes(';') || text.includes('=')) continue; + if (/^[A-Za-z][A-Za-z .,'-]{3,}$/.test(text)) { + console.error(`${full}: possible hard-coded string "${text}"`); + failed = true; + } + } +} + +if (failed) process.exit(1); +console.log('Administration FE i18n scan passed'); diff --git a/apps/Administration-FE/scripts/generate-client.mjs b/apps/Administration-FE/scripts/generate-client.mjs index d6d227d..438ad66 100644 --- a/apps/Administration-FE/scripts/generate-client.mjs +++ b/apps/Administration-FE/scripts/generate-client.mjs @@ -1,2 +1,31 @@ #!/usr/bin/env node -console.log('OpenAPI client generation: fetch client in src/lib/admin-api.ts matches contract paths'); +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '../../..'); +const outDir = join(here, '../src/generated'); +const contractsDir = + process.env.CONTRACTS_DIR || join(repoRoot, 'specs/002-auth-rbac/contracts'); + +const contracts = [ + 'admin-auth.v2.yaml', + 'admin-rbac.v1.yaml', + 'admin-management.v2.yaml', + 'publish.v2.yaml', +]; + +mkdirSync(outDir, { recursive: true }); + +for (const file of contracts) { + const src = join(contractsDir, file); + const dest = join(outDir, file.replace(/\.yaml$/, '.ts')); + execFileSync('npx', ['openapi-typescript', src, '-o', dest], { + stdio: 'inherit', + cwd: join(here, '..'), + }); +} + +console.log('Generated Administration FE types from specs/002-auth-rbac/contracts/'); diff --git a/apps/Administration-FE/src/components/AdminShell.tsx b/apps/Administration-FE/src/components/AdminShell.tsx index f0e6ebf..4ecb036 100644 --- a/apps/Administration-FE/src/components/AdminShell.tsx +++ b/apps/Administration-FE/src/components/AdminShell.tsx @@ -1,47 +1,62 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { - getCsrfToken, getPageRecord, getSession, getSiteSettingsRecord, + hasPermission, publishRecord, savePageDraft, saveSiteSettingsDraft, signOut, + type SessionContext, } from '../lib/admin-api'; +import { hasTokens } from '../lib/token-store'; import { t } from '../lib/i18n'; import PageEditor from './PageEditor'; +import SignInForm from './SignInForm'; import SiteSettingsEditor from './SiteSettingsEditor'; type View = 'site_settings' | 'home'; export default function AdminShell() { const [view, setView] = useState('site_settings'); - const [sessionEmail, setSessionEmail] = useState(null); - const [csrf, setCsrf] = useState(''); + const [session, setSession] = useState(null); const [siteSettings, setSiteSettings] = useState | null>(null); const [homePage, setHomePage] = useState | null>(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); + const [workspaceError, setWorkspaceError] = useState(null); + const [ready, setReady] = useState(!hasTokens()); - useEffect(() => { - async function load() { - try { - const session = await getSession(); - setSessionEmail(session.email); - const token = await getCsrfToken(); - setCsrf(token); - const settings = await getSiteSettingsRecord(); - setSiteSettings(settings as Record); - const page = await getPageRecord('home'); - setHomePage(page as Record); - } catch { - window.location.href = '/admin/sign-in'; - } + const loadWorkspace = useCallback(async () => { + setWorkspaceError(null); + const nextSession = await getSession(); + setSession(nextSession); + try { + const settings = await getSiteSettingsRecord(); + setSiteSettings(settings as Record); + const page = await getPageRecord('home'); + setHomePage(page as Record); + } catch { + setSiteSettings(null); + setHomePage(null); + setWorkspaceError(t('admin.workspace.load_failed')); + } finally { + setReady(true); } - load(); }, []); + useEffect(() => { + if (!hasTokens()) { + setReady(true); + return; + } + loadWorkspace().catch(() => { + setSession(null); + setReady(true); + }); + }, [loadWorkspace]); + async function refreshData() { const settings = await getSiteSettingsRecord(); setSiteSettings(settings as Record); @@ -49,21 +64,62 @@ export default function AdminShell() { setHomePage(page as Record); } + async function handleSignedIn() { + setError(null); + setWorkspaceError(null); + try { + await loadWorkspace(); + } catch { + // loadWorkspace sets workspaceError when records are missing + } + } + async function handleSignOut() { await signOut(); - window.location.href = '/admin/sign-in'; + setSession(null); + setSiteSettings(null); + setHomePage(null); + setMessage(null); + setError(null); + setWorkspaceError(null); + } + + if (!ready) { + return ( +
+

{t('admin.workspace.title')}

+
+ ); } - if (!sessionEmail || !siteSettings || !homePage) { - return

{t('admin.workspace.title')}

; + if (!session) { + return ( +
+ +
+ ); } + if (!siteSettings || !homePage) { + return ( +
+

{workspaceError || t('admin.workspace.load_failed')}

+ +
+ ); + } + + const canDraft = hasPermission(session, 'drafts.save'); + const canPublish = hasPermission(session, 'records.publish'); + return (

{t('admin.workspace.title')}

-

{sessionEmail}

+

{session.email}

@@ -104,13 +160,19 @@ export default function AdminShell() { {view === 'site_settings' && ( { - await saveSiteSettingsDraft(draft, csrf); - setMessage('Draft saved'); + await saveSiteSettingsDraft(draft); + setMessage(t('admin.draft.saved')); await refreshData(); }} onPublish={async () => { - await publishRecord('site_settings', 'default', csrf); + if (!canPublish) { + setError(t('admin.action.forbidden')); + return; + } + await publishRecord('site_settings', 'default'); setMessage(t('admin.publish.success')); await refreshData(); }} @@ -119,13 +181,19 @@ export default function AdminShell() { {view === 'home' && ( { - await savePageDraft('home', draft, csrf); - setMessage('Draft saved'); + await savePageDraft('home', draft); + setMessage(t('admin.draft.saved')); await refreshData(); }} onPublish={async () => { - await publishRecord('page', 'home', csrf); + if (!canPublish) { + setError(t('admin.action.forbidden')); + return; + } + await publishRecord('page', 'home'); setMessage(t('admin.publish.success')); await refreshData(); }} diff --git a/apps/Administration-FE/src/components/PageEditor.tsx b/apps/Administration-FE/src/components/PageEditor.tsx index 2ec1345..cb3f365 100644 --- a/apps/Administration-FE/src/components/PageEditor.tsx +++ b/apps/Administration-FE/src/components/PageEditor.tsx @@ -3,11 +3,19 @@ import { t } from '../lib/i18n'; interface Props { record: Record; + canDraft: boolean; + canPublish: boolean; onSaveDraft: (draft: Record) => Promise; onPublish: () => Promise; } -export default function PageEditor({ record, onSaveDraft, onPublish }: Props) { +export default function PageEditor({ + record, + canDraft, + canPublish, + onSaveDraft, + onPublish, +}: Props) { const draft = (record.draft || {}) as Record; const seo = (draft.seo || {}) as Record; const [title, setTitle] = useState(String(seo.title || '')); @@ -57,13 +65,32 @@ export default function PageEditor({ record, onSaveDraft, onPublish }: Props) {