feat(r0.11): apps/web — connexion + sélecteur démo, coquille, /design, client typé

- React 19 + Vite + Tailwind v4 : tokens.css copié tel quel (source de
  vérité), classes composants extraites de la maquette validée, Manrope
  auto-hébergée, primitives shadcn-style possédées (Button cva, menu Radix)
- connexion fidèle à la maquette ; liste démo affichée seulement si
  /auth/demo-accounts répond (404 ⇒ rien — ADR-002, l'API seule source)
- coquille : sidebar 4 groupes avec rail safran actif (écrans futurs marqués
  R1-R3), topbar recherche/thème/chip DÉMO + sélecteur de rôle (bascule 101 ms,
  critère < 3 s) ; page /design = référence vivante des revues pixel
- règle d'or : schema.d.ts généré depuis docs/openapi.json et committé
  (openapi-typescript + openapi-fetch)
- tokens.css : tokens --nav-* ajoutés (valeurs de la maquette validée) ;
  seed réaligné sur les personas de la maquette
- vérifié en navigateur réel (Playwright + Chrome headless) : 7 captures,
  parcours démo complet, bi-thème, zéro erreur console ; typecheck + vitest + build OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-15 22:09:40 +01:00
parent 8adb561b63
commit 4f3af5af4f
27 changed files with 4733 additions and 22 deletions

View File

@@ -0,0 +1,24 @@
import createClient from 'openapi-fetch';
import type { paths } from './schema';
/**
* Client typé — règle d'or (ADR-001) : `paths` est GÉNÉRÉ depuis
* docs/openapi.json (pnpm generate:client), régénéré dans le même commit
* que tout changement d'API. Le front ne peut pas dériver du back.
*/
const TOKEN_KEY = 'siop.token';
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
export const api = createClient<paths>({ baseUrl: '/api' });
api.use({
onRequest({ request }) {
const token = getToken();
if (token) request.headers.set('Authorization', `Bearer ${token}`);
return request;
},
});

318
apps/web/src/api/schema.d.ts vendored Normal file
View File

@@ -0,0 +1,318 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Connexion par e-mail et mot de passe */
post: operations["login"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/demo-accounts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Comptes de démonstration (ADR-002 — jamais de secret)
* @description ADR-002 : cette route est absente (404) quand DEMO_MODE nest pas actif.
*/
get: operations["listDemoAccounts"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/demo-login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Connexion 1 clic sur un compte de démonstration (ADR-002)
* @description ADR-002 : cette route est absente (404) quand DEMO_MODE nest pas actif.
*/
post: operations["demoLogin"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Profil courant + matrice de permissions du rôle */
get: operations["getMe"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** État des dépendances (base, Redis, stockage) */
get: operations["getHealth"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AuthResponse: {
accessToken: string;
user: {
/** Format: uuid */
id: string;
/** Format: email */
email: string;
displayName: string;
role: {
/** Format: uuid */
id: string;
/** @enum {string} */
name: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule";
};
isDemo: boolean;
};
};
LoginRequest: {
/** Format: email */
email: string;
password: string;
};
DemoAccountsResponse: {
accounts: {
/** Format: uuid */
id: string;
displayName: string;
/** @enum {string} */
roleName: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule";
initials: string;
}[];
};
DemoLoginRequest: {
/** Format: uuid */
userId: string;
};
MeResponse: {
/** Format: uuid */
id: string;
/** Format: email */
email: string;
displayName: string;
role: {
/** Format: uuid */
id: string;
/** @enum {string} */
name: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule";
};
isDemo: boolean;
permissions: {
/** @enum {string} */
objectCategory: "WORK_ORDERS" | "REQUESTS" | "ASSETS" | "LOCATIONS" | "METERS" | "PARTS" | "PURCHASE_ORDERS" | "PEOPLE_TEAMS" | "ANALYTICS" | "SETTINGS";
canView: boolean;
canViewOther: boolean;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}[];
};
HealthResponse: {
/** @enum {string} */
status: "ok" | "degraded";
services: {
/** @enum {string} */
database: "up" | "down";
/** @enum {string} */
redis: "up" | "down";
/** @enum {string} */
storage: "up" | "down";
};
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginRequest"];
};
};
responses: {
/** @description Jeton émis */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AuthResponse"];
};
};
/** @description Identifiants invalides ou compte inactif */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listDemoAccounts: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Comptes isDemo actifs */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DemoAccountsResponse"];
};
};
};
};
demoLogin: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DemoLoginRequest"];
};
};
responses: {
/** @description Jeton émis */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AuthResponse"];
};
};
/** @description Le compte nest pas un compte de démonstration */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Compte inconnu */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getMe: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Profil */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeResponse"];
};
};
/** @description Non authentifié */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getHealth: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description État agrégé */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HealthResponse"];
};
};
};
};
}