From 6e16db4b7ee581714a2c324fe3ed057c487c6546 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Thu, 10 Sep 2026 07:42:50 +0200 Subject: [PATCH] =?UTF-8?q?Frontend=20:=20connexion=20(mot=20de=20passe=20?= =?UTF-8?q?+=20lien=20magique)=20et=20onglets=20par=20r=C3=B4le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentification alignée sur le back-end (Étapes 7/8) : - src/api : schema auth (AccountRead, TokenResponse…), session.ts (jeton Bearer partagé + callback 401), méthodes auth du client HTTP (POST .../auth/login, .../auth/magic-link, .../auth/verify, GET /auth/me) et du mock (comptes coordination + référents + bénévoles, tokens magiques, sessions). - src/auth : AuthProvider/useAuth (jeton en localStorage, réhydratation via /auth/me, déconnexion sur 401), RequireAuth (garde + contrôle de rôle), HomeRedirect, table ROUTE_ROLES. - Pages : /connexion (lien magique, avec lien de démo affiché en mode mock), /connexion/coordination (email + mot de passe). - Router : toutes les routes métier passées derrière ; redirection vers l'accueil du rôle. - AppShell : onglets filtrés par rôle (01/02/03/06 coordination, 04 bénévole, 05 référent), email + rôle + bouton Déconnexion. - Mobile bénévole : cible le bénévole du compte connecté ; dégradation propre quand l'API refuse le détail des créneaux à un bénévole. - Mobile référent : cible le poste dont le compte est référent. - i18n : clés auth / rôles. - Vérifs : build tsc + vite, eslint (0 problème), npm run smoke (flux login / lien magique + rendu des 6 écrans par rôle et des pages publiques). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2 --- frontend/README.md | 43 +++- frontend/scripts/smoke.tsx | 227 ++++++++++++++------- frontend/src/api/hooks.ts | 23 ++- frontend/src/api/http-client.ts | 21 +- frontend/src/api/index.ts | 15 +- frontend/src/api/mock/dataset.ts | 61 +++++- frontend/src/api/mock/mock-client.ts | 73 +++++++ frontend/src/api/schema.ts | 36 ++++ frontend/src/api/session.ts | 22 ++ frontend/src/api/types.ts | 12 ++ frontend/src/app/AppShell.tsx | 56 +++-- frontend/src/app/router.tsx | 30 ++- frontend/src/auth/RequireAuth.tsx | 31 +++ frontend/src/auth/auth-context.tsx | 137 +++++++++++++ frontend/src/auth/roles.ts | 39 ++++ frontend/src/i18n/locales/fr.json | 30 +++ frontend/src/main.tsx | 5 +- frontend/src/pages/MobileLeadPage.tsx | 9 +- frontend/src/pages/MobileVolunteerPage.tsx | 118 +++++++---- frontend/src/pages/auth/AdminLoginPage.tsx | 96 +++++++++ frontend/src/pages/auth/AuthLayout.tsx | 29 +++ frontend/src/pages/auth/ConnexionPage.tsx | 145 +++++++++++++ 22 files changed, 1102 insertions(+), 156 deletions(-) create mode 100644 frontend/src/api/session.ts create mode 100644 frontend/src/auth/RequireAuth.tsx create mode 100644 frontend/src/auth/auth-context.tsx create mode 100644 frontend/src/auth/roles.ts create mode 100644 frontend/src/pages/auth/AdminLoginPage.tsx create mode 100644 frontend/src/pages/auth/AuthLayout.tsx create mode 100644 frontend/src/pages/auth/ConnexionPage.tsx diff --git a/frontend/README.md b/frontend/README.md index 6ccdbaf..817b8a2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -19,6 +19,31 @@ assignments/validate · matrix`). Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par `src/api/` (client HTTP ou mock) et les hooks React Query de `src/api/hooks.ts`. +## Authentification & rôles + +Toutes les pages métier sont derrière `` (`src/auth/`). + +- **Coordination** → `/connexion/coordination` : email + mot de passe + (`POST /editions/{id}/auth/login`). +- **Référents / bénévoles** → `/connexion` : lien magique + (`POST …/auth/magic-link` puis `…/auth/verify`). L'email étant stubé, le + mode `mock` affiche directement le lien de démo à cliquer. +- Le jeton JWT est stocké en `localStorage`, envoyé en `Authorization: Bearer` + sur chaque requête ; une réponse 401 déconnecte. +- Comptes de démo (mode `mock`) : coordination + `coordination@lessonsdautomne.fr` / `demo` ; référents + `referent1..3@lessonsdautomne.fr` ; chaque bénévole ayant un email. + +Visibilité des onglets par rôle : + +| Onglet | Coordination | Référent | Bénévole | +|--------|:---:|:---:|:---:| +| 01 Grille · 02 Vue bénévole · 03 Confirmations · 06 Configuration | ✅ | — | — | +| 04 Bénévole · mobile | — | — | ✅ | +| 05 Référent · mobile | — | ✅ | — | + +L'accueil après connexion dépend du rôle (`homePath`). + ## Écrans (onglets) | # | Route | Source de données | Actions | @@ -26,10 +51,14 @@ Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par | 01 | `/grille` | `GET /matrix` | clic cellule → panneau : `GET /assignments?shift_id`, `POST /assignments/validate` par candidat, `POST` / `PATCH` / `DELETE /assignments` | | 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) | | 03 | `/publication` | `assignments` (statut `PROPOSED`) | lecture — publication versionnée & emails **hors périmètre** (CLAUDE.md §4) | -| 04 | `/mobile-benevole` | `assignments?volunteer_id` | `PATCH /assignments/{id}` (confirmer) | -| 05 | `/mobile-referent` | `assignments?shift_id` | pointage **local** (hors périmètre) | +| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | `PATCH /assignments/{id}` (confirmer)¹ | +| 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) | | 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. | +¹ En mode `http`, l'API n'expose pas encore aux bénévoles le détail des +créneaux ni l'auto-confirmation : l'écran dégrade proprement (créneaux sans +détail, message d'info). Fonctionne intégralement en mode `mock`. + ## Commandes | Action | Commande | @@ -56,14 +85,16 @@ Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par src/ ├── main.tsx ├── api/ -│ ├── schema.ts # types miroir du back-end +│ ├── schema.ts # types miroir du back-end (dont auth) │ ├── types.ts # interface PlanningApi + ApiError +│ ├── session.ts # jeton Bearer partagé + callback 401 │ ├── http-client.ts # implémentation HTTP -│ ├── mock/ # implémentation mémoire (dataset + client + moteur) +│ ├── mock/ # implémentation mémoire (dataset + client + comptes + moteur) │ ├── index.ts # sélection mock / http, EDITION_ID │ └── hooks.ts # hooks React Query (lectures + mutations) -├── app/ AppShell (onglets) · router -├── pages/ un fichier par écran (+ grille/, config/, mobile/) +├── auth/ auth-context (AuthProvider/useAuth) · RequireAuth · roles +├── app/ AppShell (onglets filtrés par rôle) · router +├── pages/ un fichier par écran (+ auth/, grille/, config/, mobile/) ├── components/ ui.tsx (primitives) · state.tsx (Loading / ErrorNote) ├── lib/ time.ts · planning.ts (treillis, heures, surcharge, violationText) · env.ts · cx.ts └── i18n/ t() + locales/fr.json diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 2d1dab0..5ee9554 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -1,26 +1,87 @@ -// Rendu headless de chaque écran, avec les données de démonstration -// préchargées, pour vérifier que la chaîne API → sélecteurs → rendu ne lève pas. +// Vérifs headless : flux d'auth du mock + rendu de chaque écran. // Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx -import { createElement } from 'react'; +import { createElement, type ReactNode } from 'react'; import { renderToString } from 'react-dom/server'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { RouterProvider, createMemoryRouter } from 'react-router-dom'; +import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router-dom'; -import { EDITION_ID, api } from '@/api'; +import { + ApiError, + DEMO_COORDINATOR_EMAIL, + DEMO_COORDINATOR_PASSWORD, + EDITION_ID, + api, + devMagicToken, + setAuthToken, +} from '@/api'; +import type { AccountRead } from '@/api/schema'; import { queryKeys } from '@/api/hooks'; +import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context'; import { routes } from '@/app/router'; +import { ConfigPage } from '@/pages/ConfigPage'; +import { GrillePage } from '@/pages/GrillePage'; +import { MobileLeadPage } from '@/pages/MobileLeadPage'; +import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; +import { PublicationPage } from '@/pages/PublicationPage'; +import { VolunteerViewPage } from '@/pages/VolunteerViewPage'; -const paths = [ - '/grille', - '/benevoles', - '/publication', - '/mobile-benevole', - '/mobile-referent', - '/configuration', -]; +const e = EDITION_ID; +let failures = 0; +const fail = (label: string, err: unknown) => { + failures += 1; + console.error(`FAIL ${label}`, err); +}; -async function prefetch(client: QueryClient) { - const e = EDITION_ID; +let coordinator: AccountRead | null = null; +let volunteerAccount: AccountRead | null = null; +let leadAccount: AccountRead | null = null; +let coordinatorToken = ''; + +async function checkAuth() { + let badRejected = 'non'; + try { + await api.auth.login(e, { email: DEMO_COORDINATOR_EMAIL, password: 'faux' }); + } catch (err) { + badRejected = err instanceof ApiError ? err.code : String(err); + } + + const session = await api.auth.login(e, { + email: DEMO_COORDINATOR_EMAIL, + password: DEMO_COORDINATOR_PASSWORD, + }); + coordinator = session.account; + coordinatorToken = session.access_token; + setAuthToken(coordinatorToken); + const me = await api.auth.me(); + + const vols = await api.volunteers.list(e); + const volEmail = vols.find((v) => v.email)?.email ?? ''; + await api.auth.requestMagicLink(e, { email: volEmail }); + const raw = devMagicToken(volEmail); + volunteerAccount = raw ? (await api.auth.verifyMagicLink(e, { token: raw })).account : null; + + await api.auth.requestMagicLink(e, { email: 'referent1@lessonsdautomne.fr' }); + const leadRaw = devMagicToken('referent1@lessonsdautomne.fr'); + leadAccount = leadRaw ? (await api.auth.verifyMagicLink(e, { token: leadRaw })).account : null; + + setAuthToken(coordinatorToken); + console.log( + JSON.stringify( + { + badPasswordRejectedWith: badRejected, + loginRole: session.account.role, + meRole: me.role, + magicVolunteerRole: volunteerAccount?.role, + magicVolunteerLinked: Boolean(volunteerAccount?.volunteer_id), + magicLeadRole: leadAccount?.role, + }, + null, + 2, + ), + ); +} + +async function prefetchCore(client: QueryClient) { await Promise.all([ client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }), client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }), @@ -30,70 +91,94 @@ async function prefetch(client: QueryClient) { ]); } -async function checkMockApi() { - const e = EDITION_ID; - const [volunteers, positions, shifts, assignments, matrix] = await Promise.all([ - api.volunteers.list(e), - api.positions.list(e), - api.shifts.list(e), - api.assignments.list(e), - api.matrix.get(e), - ]); - - const openShift = matrix.positions.flatMap((p) => p.shifts).find((s) => s.confirmed + s.proposed < s.min_staff); - const candidate = volunteers.find( - (v) => !assignments.some((a) => a.shift_id === openShift?.shift_id && a.volunteer_id === v.id), - ); - const validation = openShift && candidate - ? await api.assignments.validate(e, { volunteer_id: candidate.id, shift_id: openShift.shift_id }) - : { blocking: false, violations: [] }; - - let dupError = 'none'; - const existing = assignments[0]; - try { - await api.assignments.create(e, { volunteer_id: existing.volunteer_id, shift_id: existing.shift_id }); - } catch (err) { - dupError = err instanceof Error ? err.message : String(err); - } - - console.log( - JSON.stringify( - { - volunteers: volunteers.length, - positions: positions.length, - shifts: shifts.length, - assignments: assignments.length, - matrixPositions: matrix.positions.length, - matrixShifts: matrix.positions.reduce((n, p) => n + p.shifts.length, 0), - sampleValidation: validation.violations.map((v) => v.code), - duplicateCreateRejectedWith: dupError, - }, - null, - 2, - ), - ); +function authValue(account: AccountRead): AuthContextValue { + return { + status: 'authed', + account, + loginWithPassword: async () => account, + requestMagicLink: async () => {}, + verifyMagicLink: async () => account, + logout: () => {}, + }; } -let failures = 0; - -await checkMockApi().catch((err) => { - failures += 1; - console.error('FAIL checkMockApi', err); -}); - -for (const path of paths) { +async function renderProtected( + label: string, + account: AccountRead, + node: ReactNode, + extra?: (client: QueryClient) => Promise, +) { try { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - await prefetch(client); + await prefetchCore(client); + if (extra) await extra(client); + const html = renderToString( + createElement( + QueryClientProvider, + { client }, + createElement( + AuthContext.Provider, + { value: authValue(account) }, + createElement(MemoryRouter, null, node), + ), + ), + ); + console.log(`ok ${label.padEnd(26)} ${html.length} octets`); + } catch (err) { + fail(label, err); + } +} + +function renderPublic(path: string) { + try { + const client = new QueryClient(); const router = createMemoryRouter(routes, { initialEntries: [path] }); const html = renderToString( - createElement(QueryClientProvider, { client }, createElement(RouterProvider, { router })), + createElement( + QueryClientProvider, + { client }, + createElement(AuthProvider, null, createElement(RouterProvider, { router })), + ), ); - console.log(`ok ${path.padEnd(20)} ${html.length} octets`); - } catch (error) { - failures += 1; - console.error(`FAIL ${path}`, error); + console.log(`ok ${path.padEnd(26)} ${html.length} octets`); + } catch (err) { + fail(path, err); } } +// --------------------------------------------------------------------------- // +await checkAuth().catch((err) => fail('checkAuth', err)); + +renderPublic('/connexion'); +renderPublic('/connexion/coordination'); + +if (coordinator) { + setAuthToken(coordinatorToken); + await renderProtected('/grille', coordinator, createElement(GrillePage)); + await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); + await renderProtected('/publication', coordinator, createElement(PublicationPage)); + await renderProtected('/configuration', coordinator, createElement(ConfigPage)); +} +if (volunteerAccount) { + const vid = volunteerAccount.volunteer_id as string; + await renderProtected( + '/mobile-benevole', + volunteerAccount, + createElement(MobileVolunteerPage), + async (client) => { + await client.prefetchQuery({ + queryKey: queryKeys.assignments({ volunteerId: vid }), + queryFn: () => api.assignments.list(e, { volunteerId: vid }), + }); + await client.prefetchQuery({ + queryKey: ['volunteer', e, vid], + queryFn: () => api.volunteers.get(e, vid), + }); + }, + ); +} +if (leadAccount) { + await renderProtected('/mobile-referent', leadAccount, createElement(MobileLeadPage)); +} + process.exit(failures ? 1 : 0); diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 798ef32..6ab1ae6 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -34,14 +34,27 @@ export const queryKeys = { // --------------------------------------------------------------------------- // // Lectures // // --------------------------------------------------------------------------- // -export function useVolunteers() { - return useQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(edition) }); +export function useVolunteers(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.volunteers, + queryFn: () => api.volunteers.list(edition), + enabled: options?.enabled ?? true, + }); } export function usePositions() { return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) }); } +/** Fiche d'un bénévole (un bénévole connecté peut lire la sienne). */ +export function useVolunteer(id: string | null | undefined) { + return useQuery({ + queryKey: ['volunteer', edition, id ?? null], + queryFn: () => api.volunteers.get(edition, id as string), + enabled: Boolean(id), + }); +} + export function useShifts(positionId?: string) { return useQuery({ queryKey: queryKeys.shifts(positionId), @@ -49,10 +62,14 @@ export function useShifts(positionId?: string) { }); } -export function useAssignments(params?: { volunteerId?: string; shiftId?: string }) { +export function useAssignments( + params?: { volunteerId?: string; shiftId?: string }, + options?: { enabled?: boolean }, +) { return useQuery({ queryKey: queryKeys.assignments(params), queryFn: () => api.assignments.list(edition, params), + enabled: options?.enabled ?? true, }); } diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index af70091..7af0f39 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -2,21 +2,30 @@ import type { AssignmentCreate, AssignmentUpdate, AssignmentValidateRequest, + LoginRequest, + MagicLinkRequest, PositionCreate, PositionUpdate, ShiftCreate, ShiftUpdate, + VerifyRequest, VolunteerCreate, VolunteerUpdate, } from './schema'; +import { getAuthToken, notifyUnauthorized } from './session'; import { ApiError, type PlanningApi } from './types'; // Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end. export function createHttpApi(baseUrl: string): PlanningApi { async function request(path: string, init?: RequestInit): Promise { + const token = getAuthToken(); const res = await fetch(`${baseUrl}${path}`, { ...init, - headers: { 'Content-Type': 'application/json', ...init?.headers }, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init?.headers, + }, credentials: 'include', }); @@ -38,6 +47,7 @@ export function createHttpApi(baseUrl: string): PlanningApi { detail && typeof detail === 'object' && 'violations' in detail ? ((detail as { violations: [] }).violations ?? []) : []; + if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized(); throw new ApiError(res.status, code, violations); } return body as T; @@ -109,5 +119,14 @@ export function createHttpApi(baseUrl: string): PlanningApi { matrix: { get: (editionId) => request(`${base(editionId)}/matrix`), }, + auth: { + login: (editionId, body: LoginRequest) => + request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }), + requestMagicLink: (editionId, body: MagicLinkRequest) => + request(`${base(editionId)}/auth/magic-link`, { method: 'POST', body: JSON.stringify(body) }), + verifyMagicLink: (editionId, body: VerifyRequest) => + request(`${base(editionId)}/auth/verify`, { method: 'POST', body: JSON.stringify(body) }), + me: () => request(`/auth/me`), + }, }; } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 439a27d..7e4c532 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -2,7 +2,7 @@ import { ENV } from '@/lib/env'; import { createHttpApi } from './http-client'; import { MOCK_EDITION_ID } from './mock/dataset'; -import { createMockApi } from './mock/mock-client'; +import { createMockApi, peekMockMagicToken } from './mock/mock-client'; import type { PlanningApi } from './types'; // `VITE_API_MODE` : `mock` (défaut, jeu de démonstration en mémoire) ou `http` @@ -14,6 +14,19 @@ export const EDITION_ID: string = ENV.editionId ?? MOCK_EDITION_ID; export const api: PlanningApi = API_MODE === 'http' ? createHttpApi(ENV.apiBaseUrl) : createMockApi(); +/** + * Mode mock uniquement : dernier lien magique émis pour un email (l'envoi + * d'email est stubé). Renvoie `undefined` en mode HTTP. + */ +export function devMagicToken(email: string): string | undefined { + return API_MODE === 'mock' ? peekMockMagicToken(email) : undefined; +} + export { ApiError } from './types'; +export { setAuthToken, setOnUnauthorized } from './session'; +export { + DEMO_COORDINATOR_EMAIL, + DEMO_COORDINATOR_PASSWORD, +} from './mock/dataset'; export type { PlanningApi } from './types'; export * from './schema'; diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 54c366d..46328a1 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -1,6 +1,7 @@ import { hoursBetween } from '@/lib/time'; import type { + AccountRead, AssignmentRead, AssignmentStatus, PositionCategory, @@ -14,11 +15,18 @@ import type { export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001'; +/** Identifiants de démonstration (mode mock uniquement). */ +export const DEMO_COORDINATOR_EMAIL = 'coordination@lessonsdautomne.fr'; +export const DEMO_COORDINATOR_PASSWORD = 'demo'; + export interface MockState { volunteers: VolunteerRead[]; positions: PositionRead[]; shifts: ShiftRead[]; assignments: AssignmentRead[]; + accounts: AccountRead[]; + /** accountId -> mot de passe en clair (mock uniquement). */ + passwords: Map; /** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */ availability: Map>; seq: number; @@ -181,6 +189,7 @@ export function buildMockState(): MockState { } const me = volunteers[0]; + me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo availability.set( me.id, new Set( @@ -284,7 +293,57 @@ export function buildMockState(): MockState { seq += 1; }); - return { volunteers, positions, shifts, assignments, availability, seq }; + // ---- Comptes ---- + const accounts: AccountRead[] = []; + const passwords = new Map(); + + const coordinator: AccountRead = { + id: `acc${seq++}`, + edition_id, + email: DEMO_COORDINATOR_EMAIL, + role: 'COORDINATOR', + volunteer_id: null, + auth_method: 'password', + created_at: NOW, + updated_at: NOW, + }; + accounts.push(coordinator); + passwords.set(coordinator.id, DEMO_COORDINATOR_PASSWORD); + + // Trois référents, rattachés à trois postes (lien magique, sans mot de passe). + ['MONS', 'MONI', 'REG'].forEach((positionId, i) => { + const account: AccountRead = { + id: `acc${seq++}`, + edition_id, + email: `referent${i + 1}@lessonsdautomne.fr`, + role: 'LEAD', + volunteer_id: null, + auth_method: 'magic_link', + created_at: NOW, + updated_at: NOW, + }; + accounts.push(account); + const position = positions.find((p) => p.id === positionId); + if (position) position.lead_account_id = account.id; + }); + + // Bénévoles connectés : tous ceux qui ont une adresse email. + volunteers + .filter((v) => v.email) + .forEach((v) => { + accounts.push({ + id: `acc${seq++}`, + edition_id, + email: v.email as string, + role: 'VOLUNTEER', + volunteer_id: v.id, + auth_method: 'magic_link', + created_at: NOW, + updated_at: NOW, + }); + }); + + return { volunteers, positions, shifts, assignments, accounts, passwords, availability, seq }; } function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean { diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index c5e7ab3..e0e2097 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -8,6 +8,8 @@ import type { Violation, } from '../schema'; import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema'; +import type { AccountRead, TokenResponse } from '../schema'; +import { getAuthToken } from '../session'; import { ApiError, type PlanningApi } from '../types'; import { buildMockState, type MockState } from './dataset'; @@ -16,12 +18,42 @@ import { buildMockState, type MockState } from './dataset'; const LATENCY_MS = 120; const EIGHT_HOURS_MIN = 8 * 60; +const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; let state: MockState = buildMockState(); +/** token de session -> id de compte. */ +const sessions = new Map(); +/** token magique brut -> compte + expiration. */ +const magicTokens = new Map(); +/** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */ +const lastMagicLink = new Map(); + /** Réinitialise le jeu de démonstration (utile en test). */ export function resetMockState(): void { state = buildMockState(); + sessions.clear(); + magicTokens.clear(); + lastMagicLink.clear(); +} + +/** + * Mode mock uniquement : récupère le dernier lien magique émis pour un email. + * En mode HTTP le back-end l'envoie par mail (ici stubé dans ses logs). + */ +export function peekMockMagicToken(email: string): string | undefined { + return lastMagicLink.get(email.trim().toLowerCase()); +} + +function accountByEmail(email: string): AccountRead | undefined { + const target = email.trim().toLowerCase(); + return state.accounts.find((a) => a.email.toLowerCase() === target); +} + +function issue(account: AccountRead): TokenResponse { + const token = `mock.${account.id}.${Math.random().toString(36).slice(2, 10)}`; + sessions.set(token, account.id); + return { access_token: token, token_type: 'bearer', account }; } const delay = (value: T): Promise => @@ -411,6 +443,47 @@ export function createMockApi(): PlanningApi { matrix: { get: () => delay(buildMatrix()), }, + + auth: { + login: (_edition, { email, password }) => { + const account = accountByEmail(email); + if (!account || account.auth_method !== 'password' || state.passwords.get(account.id) !== password) { + throw new ApiError(401, 'auth.invalid_credentials'); + } + return delay(issue(account)); + }, + requestMagicLink: (_edition, { email }) => { + const account = accountByEmail(email); + if (account && account.auth_method === 'magic_link') { + const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; + magicTokens.set(raw, { + accountId: account.id, + expiresAt: Date.now() + MAGIC_LINK_TTL_MS, + used: false, + }); + lastMagicLink.set(account.email.toLowerCase(), raw); + } + // Réponse identique dans tous les cas (pas de divulgation). + return delay(undefined); + }, + verifyMagicLink: (_edition, { token }) => { + const entry = magicTokens.get(token.trim()); + if (!entry || entry.used || entry.expiresAt < Date.now()) { + throw new ApiError(401, 'auth.invalid_token'); + } + entry.used = true; + const account = state.accounts.find((a) => a.id === entry.accountId); + if (!account) throw new ApiError(401, 'auth.invalid_token'); + return delay(issue(account)); + }, + me: () => { + const token = getAuthToken(); + const accountId = token ? sessions.get(token) : undefined; + const account = accountId ? state.accounts.find((a) => a.id === accountId) : undefined; + if (!account) throw new ApiError(401, 'error.unauthorized'); + return delay(account); + }, + }, }; } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 77fc98d..37eed0f 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -16,6 +16,8 @@ export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLE export type AssignmentSource = 'SOLVER' | 'MANUAL'; export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL'; export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE'; +export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER'; +export type AuthMethod = 'password' | 'magic_link'; /** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */ export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED']; @@ -208,3 +210,37 @@ export interface MatrixResponse { edition_id: string; positions: MatrixPosition[]; } + +// --------------------------------------------------------------------------- // +// Authentification // +// --------------------------------------------------------------------------- // +export interface AccountRead { + id: string; + edition_id: string; + email: string; + role: Role; + volunteer_id: string | null; + /** "password" (coordination) ou "magic_link" (référents, bénévoles). */ + auth_method: AuthMethod; + created_at: string; + updated_at: string; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + account: AccountRead; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface MagicLinkRequest { + email: string; +} + +export interface VerifyRequest { + token: string; +} diff --git a/frontend/src/api/session.ts b/frontend/src/api/session.ts new file mode 100644 index 0000000..ffbef7a --- /dev/null +++ b/frontend/src/api/session.ts @@ -0,0 +1,22 @@ +// Jeton d'accès partagé par les deux implémentations de l'API (HTTP et mock). +// Posé par le provider d'auth ; lu à chaque requête. + +let token: string | null = null; +let onUnauthorized: (() => void) | null = null; + +export function getAuthToken(): string | null { + return token; +} + +export function setAuthToken(value: string | null): void { + token = value; +} + +/** Enregistre le callback appelé sur une réponse 401 (déconnexion). */ +export function setOnUnauthorized(callback: (() => void) | null): void { + onUnauthorized = callback; +} + +export function notifyUnauthorized(): void { + onUnauthorized?.(); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ef045ea..aa909fc 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,10 +1,13 @@ import type { + AccountRead, AssignmentCreate, AssignmentRead, AssignmentUpdate, AssignmentValidateRequest, AssignmentValidateResult, AssignmentWriteResult, + LoginRequest, + MagicLinkRequest, MatrixResponse, PositionCreate, PositionRead, @@ -12,6 +15,8 @@ import type { ShiftCreate, ShiftRead, ShiftUpdate, + TokenResponse, + VerifyRequest, Violation, VolunteerCreate, VolunteerRead, @@ -61,6 +66,13 @@ export interface PlanningApi { matrix: { get(editionId: string): Promise; }; + auth: { + login(editionId: string, body: LoginRequest): Promise; + /** 202 sans divulgation : ne dit pas si le compte existe. */ + requestMagicLink(editionId: string, body: MagicLinkRequest): Promise; + verifyMagicLink(editionId: string, body: VerifyRequest): Promise; + me(): Promise; + }; } /** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */ diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx index 75f08ad..af6b3a8 100644 --- a/frontend/src/app/AppShell.tsx +++ b/frontend/src/app/AppShell.tsx @@ -2,23 +2,23 @@ import { NavLink, Outlet } from 'react-router-dom'; import { API_MODE } from '@/api'; import { useAssignments, useVolunteers } from '@/api/hooks'; +import { useAuth } from '@/auth/auth-context'; +import { NAV_TABS, canAccess, roleLabelKey } from '@/auth/roles'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; -const TABS = [ - { to: '/grille', num: '01', labelKey: 'tab.grille' }, - { to: '/benevoles', num: '02', labelKey: 'tab.volunteers' }, - { to: '/publication', num: '03', labelKey: 'tab.publication' }, - { to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' }, - { to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' }, - { to: '/configuration', num: '06', labelKey: 'tab.config' }, -] as const; - export function AppShell() { - const volunteers = useVolunteers(); - const assignments = useAssignments(); + const { account, logout } = useAuth(); + const isCoordinator = account?.role === 'COORDINATOR'; + + const volunteers = useVolunteers({ enabled: isCoordinator }); + const assignments = useAssignments(undefined, { enabled: isCoordinator }); const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length; + const tabs = account ? NAV_TABS.filter((tab) => canAccess(tab.to, account.role)) : []; + const initials = account + ? account.email.slice(0, 2).toUpperCase() + : '··'; return (
@@ -27,13 +27,19 @@ export function AppShell() {
{t('header.title')}
-
- {t('header.meta', { count: volunteers.data?.length ?? 0 })} -
+ {isCoordinator ? ( +
+ {t('header.meta', { count: volunteers.data?.length ?? 0 })} +
+ ) : ( +
+ {account ? t(roleLabelKey(account.role)) : ''} +
+ )}