feat(portal-admin): skeleton app per ADR-0020
CI / scan (pull_request) Successful in 2m34s
CI / commits (pull_request) Successful in 2m43s
CI / check (pull_request) Successful in 2m49s
CI / a11y (pull_request) Successful in 2m36s
CI / perf (pull_request) Successful in 6m37s

First step of the admin track. Scaffold the second Angular SPA in
the workspace alongside `portal-shell`, with the architectural
decisions from the v1 ADRs already wired so subsequent feature PRs
can drop straight into modules.

What lands:

- App generated via `nx g @nx/angular:application` (with the
  matching e2e project skeleton). Standalone, zoneless-ready,
  prefix `app`, scss component styles, css root stylesheet, no SSR.
- Tags `scope:portal-admin, type:app` so module-boundary lint
  treats `portal-admin` distinctly from `portal-shell` and only
  allows it to depend on `scope:portal-admin` + `scope:shared`
  libs.
- Tailwind v4 wired (`.postcssrc.json` + `@import 'tailwindcss'`),
  class-based dark mode (`@custom-variant dark ...`), brand palette
  imported from `libs/shared/tokens/src/brand-tokens.css` —
  identical visual baseline to portal-shell. ADR-0016 a11y baseline
  applies; ADR-0017 perf budget relaxed to **500 KB gzip initial**
  per ADR-0020 §"Performance budgets".
- i18n configured to mirror portal-shell (per ADR-0019): sourceLocale
  `en`, target `fr`, baseHref `/{locale}/` per locale,
  `@angular/localize/init` polyfill, `localize: ["en", "fr"]` on
  production, `i18nMissingTranslation: "error"` so CI blocks any PR
  that adds a marker without translating it. Seed `messages.fr.xlf`
  carries the four marked strings used by the placeholder page +
  skip-link + route title.
- Skeleton home page at `/` with a placeholder `<h1>`, intro
  paragraph, and a small "Skeleton — coming soon" status chip in
  the brand-accent palette. Marked for i18n.
- Skip-link landmark (WCAG 2.4.1) at the App level, with the same
  styling as portal-shell (component-scoped scss).
- Wildcard catch-all route bounces unknown paths to home, same
  defensive pattern as PR #96 on portal-shell.
- Dev server on port **4300** (vs 4200 for portal-shell) so both
  can run in parallel during cross-app development.
- `serve-static` target without `spa: true` — locale-prefixed
  routing in production will be handled by the reverse proxy (per
  ADR-0019), same as portal-shell since PR #92.

Production build: 62 KB gzip initial per locale (vs the 500 KB
relaxed budget). Both `dist/apps/portal-admin/browser/{en,fr}/`
emitted with the right `<html lang>` and `<base href>`.

Out of scope (each its own follow-up PR):

- Admin app shell (header / sidebar / footer with "Admin" badge).
- OpenTelemetry tracing setup (will register as
  `service.name=portal-admin` per ADR-0012).
- `environment.ts` wiring (per ADR-0018) for the admin's own BFF
  endpoints.
- BFF `AdminModule` + `AdminRoleGuard` + first `/api/admin/me`
  smoke route (per ADR-0020 §"Auth").
- First functional module (CMS / menu / users / audit viewer).

CLAUDE.md picks up the second app in its naming + commands sections.
This commit is contained in:
Julien Gautier
2026-05-12 01:41:50 +02:00
parent 8329fa133d
commit a9b36efb75
25 changed files with 520 additions and 2 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ These constraints were set by the project lead at kickoff. They apply to every c
The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR. The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR.
- **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](docs/decisions/0002-adopt-nx-monorepo-apps-preset.md). - **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](docs/decisions/0002-adopt-nx-monorepo-apps-preset.md).
- **Naming:** workspace `apf-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](docs/decisions/0003-workspace-and-app-naming-convention.md). - **Naming:** workspace `apf-portal`; apps `portal-shell` (end-user SPA), `portal-admin` (admin SPA, skeleton in place — see ADR-0020), and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](docs/decisions/0003-workspace-and-app-naming-convention.md).
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](docs/decisions/0004-frontend-stack-angular-csr-zoneless-signals.md). - **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](docs/decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](docs/decisions/0005-backend-stack-nestjs.md). - **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](docs/decisions/0005-backend-stack-nestjs.md).
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md). - **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md).
@@ -60,7 +60,7 @@ If asked to "build", "test", or "run" anything, first verify whether the workspa
## Commands once the workspace exists ## Commands once the workspace exists
App-scoped — `<app>` is one of `portal-shell`, `portal-bff`: App-scoped — `<app>` is one of `portal-shell`, `portal-admin`, `portal-bff`:
```bash ```bash
pnpm nx serve <app> # dev server pnpm nx serve <app> # dev server
+12
View File
@@ -0,0 +1,12 @@
import playwright from 'eslint-plugin-playwright';
import baseConfig from '../../eslint.config.mjs';
export default [
playwright.configs['flat/recommended'],
...baseConfig,
{
files: ['**/*.ts', '**/*.js'],
// Override or add rules here
rules: {},
},
];
@@ -0,0 +1,68 @@
import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';
// For CI, you may want to set BASE_URL to the deployed application.
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm exec nx run portal-admin:serve',
url: 'http://localhost:4200',
reuseExistingServer: true,
cwd: workspaceRoot,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Uncomment for mobile browsers support
/* {
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
}, */
// Uncomment for branded browsers
/* {
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
} */
],
});
+9
View File
@@ -0,0 +1,9 @@
{
"name": "portal-admin-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/portal-admin-e2e/src",
"implicitDependencies": ["portal-admin"],
"// targets": "to see all targets run: nx show project portal-admin-e2e --web",
"targets": {}
}
@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('/');
// Expect h1 to contain a substring.
expect(await page.locator('h1').innerText()).toContain('Welcome');
});
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.spec.ts",
"src/**/*.spec.js",
"src/**/*.test.ts",
"src/**/*.test.js",
"src/**/*.d.ts"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+123
View File
@@ -0,0 +1,123 @@
{
"name": "portal-admin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/portal-admin/src",
"tags": ["scope:portal-admin", "type:app"],
"i18n": {
"sourceLocale": {
"code": "en",
"baseHref": "/en/"
},
"locales": {
"fr": {
"translation": "apps/portal-admin/src/locale/messages.fr.xlf",
"baseHref": "/fr/"
}
}
},
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/portal-admin",
"browser": "apps/portal-admin/src/main.ts",
"polyfills": ["@angular/localize/init"],
"tsConfig": "apps/portal-admin/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "apps/portal-admin/public"
}
],
"styles": ["apps/portal-admin/src/styles.css"]
},
"configurations": {
"production": {
"localize": ["en", "fr"],
"i18nMissingTranslation": "error",
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5mb",
"maximumError": "1.5mb"
},
{
"type": "anyScript",
"maximumWarning": "500kb",
"maximumError": "500kb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "5kb",
"maximumError": "6kb"
},
{
"type": "bundle",
"name": "styles",
"maximumWarning": "150kb",
"maximumError": "150kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"extract-i18n": {
"executor": "@angular/build:extract-i18n",
"options": {
"buildTarget": "portal-admin:build",
"outputPath": "apps/portal-admin/src/locale",
"outFile": "messages.xlf"
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"options": {
"port": 4300
},
"configurations": {
"production": {
"buildTarget": "portal-admin:build:production"
},
"development": {
"buildTarget": "portal-admin:build:development"
}
},
"defaultConfiguration": "development"
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
},
"configurations": {
"watch": {
"watch": true
}
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "portal-admin:build",
"port": 4300,
"staticFilePath": "dist/apps/portal-admin/browser"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+7
View File
@@ -0,0 +1,7 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
};
+4
View File
@@ -0,0 +1,4 @@
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
<main id="main-content" tabindex="-1" class="min-h-screen bg-gray-50 dark:bg-gray-950">
<router-outlet />
</main>
+22
View File
@@ -0,0 +1,22 @@
import { Route } from '@angular/router';
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
export const appRoutes: Route[] = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
title: homeTitle,
},
// Catch-all — unknown paths bounce to home. Same role as the
// wildcard in portal-shell (per PR #96): in production each locale
// bundle ships with its own `<base href="/{locale}/">` and the
// router never sees the locale segment, so this only fires for
// genuine 404 paths; in dev (`nx serve`) it stops the router from
// throwing NG04002 when a stray `/fr/`-style URL is typed.
{
path: '**',
redirectTo: '',
},
];
+23
View File
@@ -0,0 +1,23 @@
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
// visible and focusable when reached via Tab from the address bar.
// Plain CSS rather than the `sr-only` Tailwind utilities so the
// visual state on focus is as predictable as possible across themes.
.skip-link {
position: absolute;
top: -40px;
left: 0.75rem;
z-index: 50;
padding: 0.5rem 0.75rem;
background: var(--color-brand-primary-500);
color: #fff;
border-radius: 0.25rem;
text-decoration: none;
transition: top 0.15s ease-out;
&:focus,
&:focus-visible {
top: 0.5rem;
outline: 2px solid var(--color-brand-accent-300);
outline-offset: 2px;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});
it('renders the layout shell — skip link, main landmark', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('a.skip-link')).not.toBeNull();
expect(root.querySelector('main#main-content')).not.toBeNull();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {}
@@ -0,0 +1,24 @@
<section class="mx-auto max-w-3xl px-6 py-12">
<h1
class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
i18n="@@page.home.title"
>
APF Portal Admin
</h1>
<p
class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"
i18n="@@page.home.intro"
>
Administrative back-office for the APF Portal. The first functional modules — content
management, menu administration, user list, audit log viewer — land in upcoming PRs per
ADR-0020.
</p>
<p
class="mt-6 inline-flex items-center rounded-full border border-brand-accent-300 bg-brand-accent-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-brand-accent-700 dark:border-brand-accent-700 dark:bg-brand-accent-950/40 dark:text-brand-accent-200"
role="status"
i18n="@@page.home.status"
>
Skeleton — coming soon
</p>
</section>
@@ -0,0 +1,16 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
/**
* Smoke-test landing page for portal-admin.
*
* v1 ships a placeholder. The real admin shell (header + sidebar +
* footer with the "Admin" badge, per ADR-0020) lands in a follow-up
* PR together with the first functional module (CMS pages, menu
* management, user list, or audit log viewer).
*/
@Component({
selector: 'app-home',
templateUrl: './home.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Home {}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>APF Portal Admin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<!-- app -->
<trans-unit id="app.skipLink" datatype="html">
<source>Skip to main content</source>
<target>Aller au contenu principal</target>
</trans-unit>
<!-- route -->
<trans-unit id="route.home.title" datatype="html">
<source>APF Portal Admin</source>
<target>Administration APF Portal</target>
</trans-unit>
<!-- page.home -->
<trans-unit id="page.home.title" datatype="html">
<source> APF Portal Admin </source>
<target> Administration APF Portal </target>
</trans-unit>
<trans-unit id="page.home.intro" datatype="html">
<source> Administrative back-office for the APF Portal. The first functional modules — content management, menu administration, user list, audit log viewer — land in upcoming PRs per ADR-0020. </source>
<target> Back-office dadministration pour le portail APF. Les premiers modules fonctionnels — gestion de contenu, gestion du menu, liste des utilisateurs, visualiseur daudit log — arrivent dans les PRs à venir conformément à lADR-0020. </target>
</trans-unit>
<trans-unit id="page.home.status" datatype="html">
<source> Skeleton — coming soon </source>
<target> Squelette — bientôt disponible </target>
</trans-unit>
</body>
</file>
</xliff>
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+18
View File
@@ -0,0 +1,18 @@
/*
* Global styles for portal-admin.
*
* Same baseline as portal-shell — Tailwind CSS 4 (CSS-first config),
* class-based dark mode, brand palette tokens shared from
* `libs/shared/tokens`. The two apps wear the same visual chrome so
* an APF user moving between portal and admin doesn't feel a context
* switch in the design system itself; what differentiates the admin
* surface is the data density and the "Admin" badge in the shell
* (added once the admin shell PR ships).
*/
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Brand palette tokens — shared with portal-shell. */
@import '../../../libs/shared/tokens/src/brand-tokens.css';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals", "@angular/localize"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}