Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/quality-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`

Expand Down
4 changes: 3 additions & 1 deletion apps/Administration-FE/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/Administration-FE/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import react from '@astrojs/react';
export default defineConfig({
output: 'static',
base: '/admin',
trailingSlash: 'always',
integrations: [react()],
vite: {
server: {
Expand Down
1 change: 1 addition & 0 deletions apps/Administration-FE/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 43 additions & 1 deletion apps/Administration-FE/scripts/check-contract-drift.mjs
Original file line number Diff line number Diff line change
@@ -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');
32 changes: 32 additions & 0 deletions apps/Administration-FE/scripts/check-i18n.mjs
Original file line number Diff line number Diff line change
@@ -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*</g) || [];
for (const node of textNodes) {
const text = node.replace(/^>\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');
31 changes: 30 additions & 1 deletion apps/Administration-FE/scripts/generate-client.mjs
Original file line number Diff line number Diff line change
@@ -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/');
126 changes: 97 additions & 29 deletions apps/Administration-FE/src/components/AdminShell.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,125 @@
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<View>('site_settings');
const [sessionEmail, setSessionEmail] = useState<string | null>(null);
const [csrf, setCsrf] = useState<string>('');
const [session, setSession] = useState<SessionContext | null>(null);
const [siteSettings, setSiteSettings] = useState<Record<string, unknown> | null>(null);
const [homePage, setHomePage] = useState<Record<string, unknown> | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [workspaceError, setWorkspaceError] = useState<string | null>(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<string, unknown>);
const page = await getPageRecord('home');
setHomePage(page as Record<string, unknown>);
} 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<string, unknown>);
const page = await getPageRecord('home');
setHomePage(page as Record<string, unknown>);
} 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<string, unknown>);
const page = await getPageRecord('home');
setHomePage(page as Record<string, unknown>);
}

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 (
<main id="main" className="container">
<p>{t('admin.workspace.title')}</p>
</main>
);
}

if (!sessionEmail || !siteSettings || !homePage) {
return <p>{t('admin.workspace.title')}</p>;
if (!session) {
return (
<main id="main" className="container">
<SignInForm onSignedIn={handleSignedIn} />
</main>
);
}

if (!siteSettings || !homePage) {
return (
<main id="main" className="container">
<p role="alert">{workspaceError || t('admin.workspace.load_failed')}</p>
<button type="button" onClick={handleSignOut}>
{t('admin.sign_out')}
</button>
</main>
);
}

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

return (
<div>
<header className="admin-header">
<div className="container">
<h1>{t('admin.workspace.title')}</h1>
<p>{sessionEmail}</p>
<p>{session.email}</p>
<button type="button" onClick={handleSignOut}>
{t('admin.sign_out')}
</button>
Expand Down Expand Up @@ -104,13 +160,19 @@ export default function AdminShell() {
{view === 'site_settings' && (
<SiteSettingsEditor
record={siteSettings}
canDraft={canDraft}
canPublish={canPublish}
onSaveDraft={async (draft) => {
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();
}}
Expand All @@ -119,13 +181,19 @@ export default function AdminShell() {
{view === 'home' && (
<PageEditor
record={homePage}
canDraft={canDraft}
canPublish={canPublish}
onSaveDraft={async (draft) => {
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();
}}
Expand Down
Loading
Loading