Frontend : connexion (mot de passe + lien magique) et onglets par rôle

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 <RequireAuth> ;
  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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
AdamThomas
2026-09-10 07:43:00 +02:00
co-authored by Claude Sonnet 5
parent 464635656a
commit 6e16db4b7e
22 changed files with 1102 additions and 156 deletions
+37 -6
View File
@@ -19,6 +19,31 @@ assignments/validate · matrix`).
Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par 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`. `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 `<RequireAuth>` (`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) ## Écrans (onglets)
| # | Route | Source de données | Actions | | # | 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` | | 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) | | 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) | | 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) | | 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | `PATCH /assignments/{id}` (confirmer)¹ |
| 05 | `/mobile-referent` | `assignments?shift_id` | pointage **local** (hors périmètre) | | 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. | | 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 ## Commandes
| Action | Commande | | Action | Commande |
@@ -56,14 +85,16 @@ Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par
src/ src/
├── main.tsx ├── main.tsx
├── api/ ├── api/
│ ├── schema.ts # types miroir du back-end │ ├── schema.ts # types miroir du back-end (dont auth)
│ ├── types.ts # interface PlanningApi + ApiError │ ├── types.ts # interface PlanningApi + ApiError
│ ├── session.ts # jeton Bearer partagé + callback 401
│ ├── http-client.ts # implémentation HTTP │ ├── 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 │ ├── index.ts # sélection mock / http, EDITION_ID
│ └── hooks.ts # hooks React Query (lectures + mutations) │ └── hooks.ts # hooks React Query (lectures + mutations)
├── app/ AppShell (onglets) · router ├── auth/ auth-context (AuthProvider/useAuth) · RequireAuth · roles
├── pages/ un fichier par écran (+ grille/, config/, mobile/) ├── 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) ├── components/ ui.tsx (primitives) · state.tsx (Loading / ErrorNote)
├── lib/ time.ts · planning.ts (treillis, heures, surcharge, violationText) · env.ts · cx.ts ├── lib/ time.ts · planning.ts (treillis, heures, surcharge, violationText) · env.ts · cx.ts
└── i18n/ t() + locales/fr.json └── i18n/ t() + locales/fr.json
+156 -71
View File
@@ -1,26 +1,87 @@
// Rendu headless de chaque écran, avec les données de démonstration // Vérifs headless : flux d'auth du mock + rendu de chaque écran.
// préchargées, pour vérifier que la chaîne API → sélecteurs → rendu ne lève pas.
// Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx // 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 { renderToString } from 'react-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { queryKeys } from '@/api/hooks';
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
import { routes } from '@/app/router'; 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',
];
async function prefetch(client: QueryClient) {
const e = EDITION_ID; const e = EDITION_ID;
let failures = 0;
const fail = (label: string, err: unknown) => {
failures += 1;
console.error(`FAIL ${label}`, err);
};
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([ await Promise.all([
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }), client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }), client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }),
@@ -30,70 +91,94 @@ async function prefetch(client: QueryClient) {
]); ]);
} }
async function checkMockApi() { function authValue(account: AccountRead): AuthContextValue {
const e = EDITION_ID; return {
const [volunteers, positions, shifts, assignments, matrix] = await Promise.all([ status: 'authed',
api.volunteers.list(e), account,
api.positions.list(e), loginWithPassword: async () => account,
api.shifts.list(e), requestMagicLink: async () => {},
api.assignments.list(e), verifyMagicLink: async () => account,
api.matrix.get(e), logout: () => {},
]); };
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( async function renderProtected(
JSON.stringify( label: string,
{ account: AccountRead,
volunteers: volunteers.length, node: ReactNode,
positions: positions.length, extra?: (client: QueryClient) => Promise<void>,
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,
),
);
}
let failures = 0;
await checkMockApi().catch((err) => {
failures += 1;
console.error('FAIL checkMockApi', err);
});
for (const path of paths) {
try { try {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); 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 router = createMemoryRouter(routes, { initialEntries: [path] });
const html = renderToString( 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`); console.log(`ok ${path.padEnd(26)} ${html.length} octets`);
} catch (error) { } catch (err) {
failures += 1; fail(path, err);
console.error(`FAIL ${path}`, error);
} }
} }
// --------------------------------------------------------------------------- //
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); process.exit(failures ? 1 : 0);
+20 -3
View File
@@ -34,14 +34,27 @@ export const queryKeys = {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Lectures // // Lectures //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
export function useVolunteers() { export function useVolunteers(options?: { enabled?: boolean }) {
return useQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(edition) }); return useQuery({
queryKey: queryKeys.volunteers,
queryFn: () => api.volunteers.list(edition),
enabled: options?.enabled ?? true,
});
} }
export function usePositions() { export function usePositions() {
return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) }); 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) { export function useShifts(positionId?: string) {
return useQuery({ return useQuery({
queryKey: queryKeys.shifts(positionId), 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({ return useQuery({
queryKey: queryKeys.assignments(params), queryKey: queryKeys.assignments(params),
queryFn: () => api.assignments.list(edition, params), queryFn: () => api.assignments.list(edition, params),
enabled: options?.enabled ?? true,
}); });
} }
+20 -1
View File
@@ -2,21 +2,30 @@ import type {
AssignmentCreate, AssignmentCreate,
AssignmentUpdate, AssignmentUpdate,
AssignmentValidateRequest, AssignmentValidateRequest,
LoginRequest,
MagicLinkRequest,
PositionCreate, PositionCreate,
PositionUpdate, PositionUpdate,
ShiftCreate, ShiftCreate,
ShiftUpdate, ShiftUpdate,
VerifyRequest,
VolunteerCreate, VolunteerCreate,
VolunteerUpdate, VolunteerUpdate,
} from './schema'; } from './schema';
import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types'; import { ApiError, type PlanningApi } from './types';
// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end. // Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end.
export function createHttpApi(baseUrl: string): PlanningApi { export function createHttpApi(baseUrl: string): PlanningApi {
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const res = await fetch(`${baseUrl}${path}`, { const res = await fetch(`${baseUrl}${path}`, {
...init, ...init,
headers: { 'Content-Type': 'application/json', ...init?.headers }, headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
credentials: 'include', credentials: 'include',
}); });
@@ -38,6 +47,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
detail && typeof detail === 'object' && 'violations' in detail detail && typeof detail === 'object' && 'violations' in detail
? ((detail as { violations: [] }).violations ?? []) ? ((detail as { violations: [] }).violations ?? [])
: []; : [];
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
throw new ApiError(res.status, code, violations); throw new ApiError(res.status, code, violations);
} }
return body as T; return body as T;
@@ -109,5 +119,14 @@ export function createHttpApi(baseUrl: string): PlanningApi {
matrix: { matrix: {
get: (editionId) => request(`${base(editionId)}/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`),
},
}; };
} }
+14 -1
View File
@@ -2,7 +2,7 @@ import { ENV } from '@/lib/env';
import { createHttpApi } from './http-client'; import { createHttpApi } from './http-client';
import { MOCK_EDITION_ID } from './mock/dataset'; 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'; import type { PlanningApi } from './types';
// `VITE_API_MODE` : `mock` (défaut, jeu de démonstration en mémoire) ou `http` // `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 = export const api: PlanningApi =
API_MODE === 'http' ? createHttpApi(ENV.apiBaseUrl) : createMockApi(); 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 { ApiError } from './types';
export { setAuthToken, setOnUnauthorized } from './session';
export {
DEMO_COORDINATOR_EMAIL,
DEMO_COORDINATOR_PASSWORD,
} from './mock/dataset';
export type { PlanningApi } from './types'; export type { PlanningApi } from './types';
export * from './schema'; export * from './schema';
+60 -1
View File
@@ -1,6 +1,7 @@
import { hoursBetween } from '@/lib/time'; import { hoursBetween } from '@/lib/time';
import type { import type {
AccountRead,
AssignmentRead, AssignmentRead,
AssignmentStatus, AssignmentStatus,
PositionCategory, PositionCategory,
@@ -14,11 +15,18 @@ import type {
export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001'; 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 { export interface MockState {
volunteers: VolunteerRead[]; volunteers: VolunteerRead[];
positions: PositionRead[]; positions: PositionRead[];
shifts: ShiftRead[]; shifts: ShiftRead[];
assignments: AssignmentRead[]; assignments: AssignmentRead[];
accounts: AccountRead[];
/** accountId -> mot de passe en clair (mock uniquement). */
passwords: Map<string, string>;
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */ /** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
availability: Map<string, Set<string>>; availability: Map<string, Set<string>>;
seq: number; seq: number;
@@ -181,6 +189,7 @@ export function buildMockState(): MockState {
} }
const me = volunteers[0]; const me = volunteers[0];
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
availability.set( availability.set(
me.id, me.id,
new Set( new Set(
@@ -284,7 +293,57 @@ export function buildMockState(): MockState {
seq += 1; seq += 1;
}); });
return { volunteers, positions, shifts, assignments, availability, seq }; // ---- Comptes ----
const accounts: AccountRead[] = [];
const passwords = new Map<string, string>();
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 { function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean {
+73
View File
@@ -8,6 +8,8 @@ import type {
Violation, Violation,
} from '../schema'; } from '../schema';
import { ACTIVE_ASSIGNMENT_STATUSES } 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 { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockState } from './dataset'; import { buildMockState, type MockState } from './dataset';
@@ -16,12 +18,42 @@ import { buildMockState, type MockState } from './dataset';
const LATENCY_MS = 120; const LATENCY_MS = 120;
const EIGHT_HOURS_MIN = 8 * 60; const EIGHT_HOURS_MIN = 8 * 60;
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
let state: MockState = buildMockState(); let state: MockState = buildMockState();
/** token de session -> id de compte. */
const sessions = new Map<string, string>();
/** token magique brut -> compte + expiration. */
const magicTokens = new Map<string, { accountId: string; expiresAt: number; used: boolean }>();
/** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */
const lastMagicLink = new Map<string, string>();
/** Réinitialise le jeu de démonstration (utile en test). */ /** Réinitialise le jeu de démonstration (utile en test). */
export function resetMockState(): void { export function resetMockState(): void {
state = buildMockState(); 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 = <T>(value: T): Promise<T> => const delay = <T>(value: T): Promise<T> =>
@@ -411,6 +443,47 @@ export function createMockApi(): PlanningApi {
matrix: { matrix: {
get: () => delay(buildMatrix()), 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);
},
},
}; };
} }
+36
View File
@@ -16,6 +16,8 @@ export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLE
export type AssignmentSource = 'SOLVER' | 'MANUAL'; export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL'; export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE'; 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. */ /** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */
export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED']; export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED'];
@@ -208,3 +210,37 @@ export interface MatrixResponse {
edition_id: string; edition_id: string;
positions: MatrixPosition[]; 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;
}
+22
View File
@@ -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?.();
}
+12
View File
@@ -1,10 +1,13 @@
import type { import type {
AccountRead,
AssignmentCreate, AssignmentCreate,
AssignmentRead, AssignmentRead,
AssignmentUpdate, AssignmentUpdate,
AssignmentValidateRequest, AssignmentValidateRequest,
AssignmentValidateResult, AssignmentValidateResult,
AssignmentWriteResult, AssignmentWriteResult,
LoginRequest,
MagicLinkRequest,
MatrixResponse, MatrixResponse,
PositionCreate, PositionCreate,
PositionRead, PositionRead,
@@ -12,6 +15,8 @@ import type {
ShiftCreate, ShiftCreate,
ShiftRead, ShiftRead,
ShiftUpdate, ShiftUpdate,
TokenResponse,
VerifyRequest,
Violation, Violation,
VolunteerCreate, VolunteerCreate,
VolunteerRead, VolunteerRead,
@@ -61,6 +66,13 @@ export interface PlanningApi {
matrix: { matrix: {
get(editionId: string): Promise<MatrixResponse>; get(editionId: string): Promise<MatrixResponse>;
}; };
auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
/** 202 sans divulgation : ne dit pas si le compte existe. */
requestMagicLink(editionId: string, body: MagicLinkRequest): Promise<void>;
verifyMagicLink(editionId: string, body: VerifyRequest): Promise<TokenResponse>;
me(): Promise<AccountRead>;
};
} }
/** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */ /** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */
+33 -17
View File
@@ -2,23 +2,23 @@ import { NavLink, Outlet } from 'react-router-dom';
import { API_MODE } from '@/api'; import { API_MODE } from '@/api';
import { useAssignments, useVolunteers } from '@/api/hooks'; 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 { t } from '@/i18n';
import { cx } from '@/lib/cx'; 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() { export function AppShell() {
const volunteers = useVolunteers(); const { account, logout } = useAuth();
const assignments = useAssignments(); 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 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 ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@@ -27,13 +27,19 @@ export function AppShell() {
<div className="font-display text-[14px] font-black tracking-tight"> <div className="font-display text-[14px] font-black tracking-tight">
{t('header.title')} {t('header.title')}
</div> </div>
{isCoordinator ? (
<div className="font-mono text-[9.5px] font-medium opacity-70"> <div className="font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: volunteers.data?.length ?? 0 })} {t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div> </div>
) : (
<div className="font-mono text-[9.5px] font-medium opacity-70">
{account ? t(roleLabelKey(account.role)) : ''}
</div>
)}
</div> </div>
<nav className="flex overflow-x-auto"> <nav className="flex overflow-x-auto">
{TABS.map((tab) => ( {tabs.map((tab) => (
<NavLink <NavLink
key={tab.to} key={tab.to}
to={tab.to} to={tab.to}
@@ -60,18 +66,28 @@ export function AppShell() {
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]"> <div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
{API_MODE === 'mock' ? ( {API_MODE === 'mock' ? (
<div className="border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider"> <div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block">
{t('header.mockMode')} {t('header.mockMode')}
</div> </div>
) : null} ) : null}
{proposedCount > 0 ? ( {isCoordinator && proposedCount > 0 ? (
<div className="flex items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow"> <div className="hidden items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow md:flex">
{t('header.proposed', { count: proposedCount })} {t('header.proposed', { count: proposedCount })}
</div> </div>
) : null} ) : null}
<div className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"> <div
MB className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
title={account?.email}
>
{initials}
</div> </div>
<button
type="button"
onClick={logout}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange"
>
{t('auth.logout')}
</button>
</div> </div>
</header> </header>
+12 -2
View File
@@ -1,6 +1,9 @@
import { Navigate, type RouteObject } from 'react-router-dom'; import { Navigate, type RouteObject } from 'react-router-dom';
import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
import { AppShell } from './AppShell'; import { AppShell } from './AppShell';
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { GrillePage } from '@/pages/GrillePage'; import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage'; import { MobileLeadPage } from '@/pages/MobileLeadPage';
@@ -9,18 +12,25 @@ import { PublicationPage } from '@/pages/PublicationPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage'; import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{ path: '/connexion', element: <ConnexionPage /> },
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
{
element: <RequireAuth />,
children: [
{ {
path: '/', path: '/',
element: <AppShell />, element: <AppShell />,
children: [ children: [
{ index: true, element: <Navigate to="/grille" replace /> }, { index: true, element: <HomeRedirect /> },
{ path: 'grille', element: <GrillePage /> }, { path: 'grille', element: <GrillePage /> },
{ path: 'benevoles', element: <VolunteerViewPage /> }, { path: 'benevoles', element: <VolunteerViewPage /> },
{ path: 'publication', element: <PublicationPage /> }, { path: 'publication', element: <PublicationPage /> },
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> }, { path: 'mobile-benevole', element: <MobileVolunteerPage /> },
{ path: 'mobile-referent', element: <MobileLeadPage /> }, { path: 'mobile-referent', element: <MobileLeadPage /> },
{ path: 'configuration', element: <ConfigPage /> }, { path: 'configuration', element: <ConfigPage /> },
{ path: '*', element: <Navigate to="/grille" replace /> },
], ],
}, },
],
},
{ path: '*', element: <Navigate to="/" replace /> },
]; ];
+31
View File
@@ -0,0 +1,31 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { Loading } from '@/components/state';
import { useAuth } from './auth-context';
import { canAccess, homePath } from './roles';
/**
* Garde d'accès : toutes les pages « métier » passent par ici.
* - non connecté → page de connexion,
* - connecté mais rôle non autorisé pour la route → renvoi vers son accueil.
*/
export function RequireAuth() {
const { status, account } = useAuth();
const location = useLocation();
if (status === 'loading') return <Loading />;
if (status === 'anon' || !account) {
return <Navigate to="/connexion" replace state={{ from: location.pathname }} />;
}
// `/` est géré par <HomeRedirect> ; les autres routes sont contrôlées par rôle.
if (location.pathname !== '/' && !canAccess(location.pathname, account.role)) {
return <Navigate to={homePath(account.role)} replace />;
}
return <Outlet />;
}
/** Redirige `/` vers l'accueil du rôle connecté. */
export function HomeRedirect() {
const { account } = useAuth();
return <Navigate to={account ? homePath(account.role) : '/connexion'} replace />;
}
+137
View File
@@ -0,0 +1,137 @@
/* eslint-disable react-refresh/only-export-components -- contexte + hook + provider colocalisés */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { EDITION_ID, api, setAuthToken, setOnUnauthorized } from '@/api';
import type { AccountRead } from '@/api/schema';
const STORAGE_KEY = 'benevoles.token';
function readStoredToken(): string | null {
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
function writeStoredToken(token: string | null): void {
try {
if (token) window.localStorage.setItem(STORAGE_KEY, token);
else window.localStorage.removeItem(STORAGE_KEY);
} catch {
/* stockage indisponible : la session ne survivra pas au rechargement */
}
}
type AuthStatus = 'loading' | 'anon' | 'authed';
interface AuthContextValue {
status: AuthStatus;
account: AccountRead | null;
loginWithPassword: (email: string, password: string) => Promise<AccountRead>;
requestMagicLink: (email: string) => Promise<void>;
verifyMagicLink: (token: string) => Promise<AccountRead>;
logout: () => void;
}
// Exporté pour les tests / le rendu headless ; l'app passe par <AuthProvider>.
export const AuthContext = createContext<AuthContextValue | null>(null);
export type { AuthContextValue };
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [status, setStatus] = useState<AuthStatus>('loading');
const [account, setAccount] = useState<AccountRead | null>(null);
const applySession = useCallback(
(token: string, nextAccount: AccountRead) => {
writeStoredToken(token);
setAuthToken(token);
setAccount(nextAccount);
setStatus('authed');
queryClient.clear();
},
[queryClient],
);
const logout = useCallback(() => {
writeStoredToken(null);
setAuthToken(null);
setAccount(null);
setStatus('anon');
queryClient.clear();
}, [queryClient]);
// 401 sur une requête protégée ⇒ déconnexion.
const logoutRef = useRef(logout);
logoutRef.current = logout;
useEffect(() => {
setOnUnauthorized(() => logoutRef.current());
return () => setOnUnauthorized(null);
}, []);
// Réhydratation au chargement.
useEffect(() => {
const token = readStoredToken();
if (!token) {
setStatus('anon');
return;
}
setAuthToken(token);
let cancelled = false;
api.auth
.me()
.then((me) => {
if (cancelled) return;
setAccount(me);
setStatus('authed');
})
.catch(() => {
if (cancelled) return;
writeStoredToken(null);
setAuthToken(null);
setStatus('anon');
});
return () => {
cancelled = true;
};
}, []);
const value = useMemo<AuthContextValue>(
() => ({
status,
account,
loginWithPassword: async (email, password) => {
const res = await api.auth.login(EDITION_ID, { email, password });
applySession(res.access_token, res.account);
return res.account;
},
requestMagicLink: (email) => api.auth.requestMagicLink(EDITION_ID, { email }),
verifyMagicLink: async (token) => {
const res = await api.auth.verifyMagicLink(EDITION_ID, { token });
applySession(res.access_token, res.account);
return res.account;
},
logout,
}),
[status, account, applySession, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth doit être utilisé dans <AuthProvider>');
return ctx;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Role } from '@/api/schema';
// Visibilité des onglets par rôle (cf. demande produit) :
// 01 Grille · 02 Vue par bénévole · 03 Confirmations · 06 Configuration → coordination
// 04 Bénévole · mobile → bénévoles
// 05 Référent · mobile → référents
export const ROUTE_ROLES: Record<string, Role[]> = {
'/grille': ['COORDINATOR'],
'/benevoles': ['COORDINATOR'],
'/publication': ['COORDINATOR'],
'/configuration': ['COORDINATOR'],
'/mobile-benevole': ['VOLUNTEER'],
'/mobile-referent': ['LEAD'],
};
export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
{ 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' },
];
export function canAccess(pathname: string, role: Role): boolean {
const allowed = ROUTE_ROLES[pathname];
return allowed ? allowed.includes(role) : false;
}
/** Écran d'accueil d'un rôle après connexion. */
export function homePath(role: Role): string {
if (role === 'VOLUNTEER') return '/mobile-benevole';
if (role === 'LEAD') return '/mobile-referent';
return '/grille';
}
export function roleLabelKey(role: Role): string {
return `role.${role.toLowerCase()}`;
}
+30
View File
@@ -18,6 +18,34 @@
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà", "volunteer.duplicate_email": "Un bénévole avec cet email existe déjà",
"shift.ends_not_after_starts": "La fin doit être après le début", "shift.ends_not_after_starts": "La fin doit être après le début",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
"auth.invalid_credentials": "Email ou mot de passe incorrect",
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
"role.coordinator": "Coordination",
"role.lead": "Référent",
"role.volunteer": "Bénévole",
"auth.logout": "Déconnexion",
"auth.email": "Email",
"auth.password": "Mot de passe",
"auth.signIn": "Se connecter",
"auth.signingIn": "Connexion…",
"auth.admin.title": "Espace coordination",
"auth.demoCredentials": "Démo : {email} · mot de passe « {password} »",
"auth.notAdmin": "Bénévole ou référent ?",
"auth.magicLinkLink": "Recevoir un lien de connexion",
"auth.isAdmin": "Vous êtes de la coordination ?",
"auth.adminLink": "Connexion par mot de passe",
"auth.magic.title": "Connexion bénévole / référent",
"auth.magic.lead": "Entrez votre email : vous recevrez un lien de connexion, sans mot de passe.",
"auth.magic.send": "Envoyer le lien",
"auth.magic.sending": "Envoi…",
"auth.magic.sent": "Si un compte correspond à cet email, un lien de connexion vient d'être envoyé.",
"auth.magic.devHint": "Démo (email stubé) — cliquez ce lien :",
"auth.magic.another": "Utiliser un autre email",
"auth.magic.verifyTitle": "Connexion en cours",
"auth.magic.verifying": "Vérification du lien…",
"auth.magic.retry": "Demander un nouveau lien",
"header.title": "GESTION DES BÉNÉVOLES", "header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS", "header.meta": "BÉNÉVOLAT · {count} INSCRITS",
@@ -124,6 +152,8 @@
"mobileVol.confirm": "JE CONFIRME", "mobileVol.confirm": "JE CONFIRME",
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ", "mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.", "mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
"mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.",
"mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.", "mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.",
"mobileLead.statusTime": "18:07", "mobileLead.statusTime": "18:07",
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { AuthProvider } from '@/auth/auth-context';
import { routes } from '@/app/router'; import { routes } from '@/app/router';
import './index.css'; import './index.css';
@@ -17,7 +18,9 @@ const router = createBrowserRouter(routes);
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
); );
+7 -2
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame'; import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel } from '@/lib/time'; import { hourRangeLabel } from '@/lib/time';
@@ -11,13 +12,17 @@ import { cx } from '@/lib/cx';
type Presence = Record<string, 'present' | 'absent'>; type Presence = Record<string, 'present' | 'absent'>;
export function MobileLeadPage() { export function MobileLeadPage() {
const { account } = useAuth();
const positions = usePositions(); const positions = usePositions();
const shifts = useShifts(); const shifts = useShifts();
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const [presence, setPresence] = useState<Presence>({}); const [presence, setPresence] = useState<Presence>({});
// Poste de démonstration : le premier ; créneau le plus fourni. // Poste du référent connecté (repli : le premier, pour la démo coordination).
const position = (positions.data ?? [])[0]; const myPositions = (positions.data ?? []).filter(
(p) => account && p.lead_account_id === account.id,
);
const position = myPositions[0] ?? (positions.data ?? [])[0];
const positionShifts = useMemo( const positionShifts = useMemo(
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id), () => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
[shifts.data, position?.id], [shifts.data, position?.id],
+64 -26
View File
@@ -1,26 +1,30 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useAssignments, usePositions, useShifts, useUpdateAssignment, useVolunteers } from '@/api/hooks'; import { ApiError } from '@/api';
import {
useAssignments,
usePositions,
useShifts,
useUpdateAssignment,
useVolunteer,
} from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame'; import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time'; import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
import { isActive } from '@/lib/planning'; import { isActive } from '@/lib/planning';
import { ENV } from '@/lib/env';
import { t } from '@/i18n'; import { t } from '@/i18n';
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
export function MobileVolunteerPage() { export function MobileVolunteerPage() {
const volunteers = useVolunteers(); const { account } = useAuth();
const volunteerId = account?.volunteer_id ?? null;
const meVolunteer = useVolunteer(volunteerId);
const assignments = useAssignments(volunteerId ? { volunteerId } : undefined, {
enabled: Boolean(volunteerId),
});
const shifts = useShifts(); const shifts = useShifts();
const positions = usePositions(); const positions = usePositions();
const me = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === DEMO_VOLUNTEER_ID) ?? list[0];
}, [volunteers.data]);
const assignments = useAssignments(me ? { volunteerId: me.id } : undefined);
const updateAssignment = useUpdateAssignment(); const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo( const shiftsById = useMemo(
@@ -32,21 +36,38 @@ export function MobileVolunteerPage() {
[positions.data], [positions.data],
); );
if (volunteers.isLoading || shifts.isLoading) return <Loading />; if (!volunteerId) {
const error = volunteers.error ?? shifts.error ?? assignments.error; return (
if (error) return <ErrorNote error={error} />; <div className="m-6 max-w-[560px] border-[3px] border-dashed border-ink bg-paper p-4 font-display text-[13px] font-bold">
if (!me) return null; {t('mobileVol.noAccount')}
</div>
);
}
if (assignments.isLoading) return <Loading />;
if (assignments.error) return <ErrorNote error={assignments.error} />;
const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? '';
// shifts / positions peuvent être refusés (403) à un bénévole côté back-end réel :
// on affiche alors les créneaux sans détail plutôt que de tomber en erreur.
const detailsUnavailable =
(shifts.error instanceof ApiError && shifts.error.status === 403) ||
(positions.error instanceof ApiError && positions.error.status === 403);
const slots = (assignments.data ?? []) const slots = (assignments.data ?? [])
.filter(isActive) .filter(isActive)
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) })) .map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } => .sort((a, b) => {
Boolean(entry.shift), const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
) const bv = b.shift ? naiveMinutes(b.shift.starts_at) : 0;
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at)); return av - bv;
});
const totalHours = Math.round( const totalHours =
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 0) * 10, Math.round(
slots.reduce(
(sum, s) => sum + (s.shift ? hoursBetween(s.shift.starts_at, s.shift.ends_at) : 0),
0,
) * 10,
) / 10; ) / 10;
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length; const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
@@ -56,15 +77,17 @@ export function MobileVolunteerPage() {
<div className="bg-ink px-4 pb-4 pt-3 text-cream"> <div className="bg-ink px-4 pb-4 pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div> <div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight"> <div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
{t('mobileVol.greeting', { name: me.first_name.toUpperCase() })} {t('mobileVol.greeting', { name: firstName.toUpperCase() })}
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> <div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })} {t('mobileVol.slots', { count: slots.length })}
</div> </div>
{!detailsUnavailable ? (
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> <div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })} {t('mobileVol.hours', { count: totalHours })}
</div> </div>
) : null}
</div> </div>
</div> </div>
@@ -75,10 +98,16 @@ export function MobileVolunteerPage() {
) : null} ) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]"> <div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{detailsUnavailable ? (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
{t('mobileVol.detailsUnavailable')}
</div>
) : null}
{slots.map(({ assignment, shift }) => { {slots.map(({ assignment, shift }) => {
const position = positionsById.get(shift.position_id); const position = shift ? positionsById.get(shift.position_id) : undefined;
return ( return (
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg"> <div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
{shift ? (
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream"> <div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black"> <span className="font-display text-[15px] font-black">
{longDayLabel(shift.starts_at)} {longDayLabel(shift.starts_at)}
@@ -87,10 +116,13 @@ export function MobileVolunteerPage() {
{hourRangeLabel(shift.starts_at, shift.ends_at)} {hourRangeLabel(shift.starts_at, shift.ends_at)}
</span> </span>
</div> </div>
) : null}
<div className="p-[11px]"> <div className="p-[11px]">
{position ? (
<div className="font-display text-[23px] font-black leading-tight tracking-tight"> <div className="font-display text-[23px] font-black leading-tight tracking-tight">
{(position?.name ?? '').toUpperCase()} {position.name.toUpperCase()}
</div> </div>
) : null}
{position?.location ? ( {position?.location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]"> <div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: position.location })} {t('mobileVol.location', { location: position.location })}
@@ -112,6 +144,12 @@ export function MobileVolunteerPage() {
{t('mobileVol.confirm')} {t('mobileVol.confirm')}
</button> </button>
)} )}
{updateAssignment.error instanceof ApiError &&
updateAssignment.variables?.id === assignment.id ? (
<div className="mt-2 border-2 border-ink bg-orange px-2 py-1 font-display text-[10px] font-bold">
{t(updateAssignment.error.code)}
</div>
) : null}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,96 @@
import { useState, type FormEvent } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { API_MODE, ApiError, DEMO_COORDINATOR_EMAIL, DEMO_COORDINATOR_PASSWORD } from '@/api';
import { useAuth } from '@/auth/auth-context';
import { homePath } from '@/auth/roles';
import { FieldLabel } from '@/components/ui';
import { AuthLayout } from './AuthLayout';
import { t } from '@/i18n';
export function AdminLoginPage() {
const { status, account, loginWithPassword } = useAuth();
const navigate = useNavigate();
const location = useLocation() as { state?: { from?: string } };
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
if (status === 'authed' && account) {
return <Navigate to={location.state?.from ?? homePath(account.role)} replace />;
}
async function submit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const me = await loginWithPassword(email.trim(), password);
navigate(location.state?.from ?? homePath(me.role), { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.code : 'error.generic');
} finally {
setBusy(false);
}
}
return (
<AuthLayout title={t('auth.admin.title')}>
<form onSubmit={submit} className="flex flex-col gap-3">
<label className="block">
<FieldLabel>{t('auth.email')}</FieldLabel>
<input
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
/>
</label>
<label className="block">
<FieldLabel>{t('auth.password')}</FieldLabel>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
/>
</label>
{error ? (
<div className="border-2 border-ink bg-orange px-3 py-2 font-display text-[11px] font-bold">
{t(error)}
</div>
) : null}
<button
type="submit"
disabled={busy}
className="mt-1 cursor-pointer border-[3px] border-ink bg-yellow p-3 font-display text-[13px] font-black uppercase tracking-wide shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{busy ? t('auth.signingIn') : t('auth.signIn')}
</button>
</form>
{API_MODE === 'mock' ? (
<div className="mt-4 border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
{t('auth.demoCredentials', {
email: DEMO_COORDINATOR_EMAIL,
password: DEMO_COORDINATOR_PASSWORD,
})}
</div>
) : null}
<div className="mt-4 border-t-2 border-ink pt-3 font-display text-[11px]">
{t('auth.notAdmin')}{' '}
<Link to="/connexion" className="font-extrabold underline">
{t('auth.magicLinkLink')}
</Link>
</div>
</AuthLayout>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import { API_MODE } from '@/api';
import { t } from '@/i18n';
export function AuthLayout({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-cream p-6">
<div className="w-full max-w-[420px]">
<div className="mb-4 flex items-baseline gap-2">
<div className="border-[3px] border-ink bg-yellow px-3 py-2 font-display text-[15px] font-black tracking-tight shadow-brutal-md">
{t('header.title')}
</div>
{API_MODE === 'mock' ? (
<span className="border-2 border-ink bg-blue px-2 py-1 font-display text-[9px] font-extrabold uppercase">
{t('header.mockMode')}
</span>
) : null}
</div>
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-4 py-3 font-display text-[13px] font-black uppercase tracking-widest text-cream">
{title}
</div>
<div className="p-5">{children}</div>
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import { ApiError, devMagicToken } from '@/api';
import { useAuth } from '@/auth/auth-context';
import { homePath } from '@/auth/roles';
import { FieldLabel } from '@/components/ui';
import { AuthLayout } from './AuthLayout';
import { t } from '@/i18n';
export function ConnexionPage() {
const { status, account, requestMagicLink, verifyMagicLink } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const tokenParam = params.get('token');
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [devLink, setDevLink] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const verifyStartedRef = useRef(false);
// Consommation d'un lien magique (?token=…).
useEffect(() => {
if (!tokenParam || verifyStartedRef.current) return;
verifyStartedRef.current = true;
setBusy(true);
verifyMagicLink(tokenParam)
.then((me) => navigate(homePath(me.role), { replace: true }))
.catch((err) => {
setError(err instanceof ApiError ? err.code : 'auth.invalid_token');
setBusy(false);
});
}, [tokenParam, verifyMagicLink, navigate]);
if (status === 'authed' && account && !tokenParam) {
return <Navigate to={homePath(account.role)} replace />;
}
if (tokenParam) {
return (
<AuthLayout title={t('auth.magic.verifyTitle')}>
{error ? (
<div className="flex flex-col gap-3">
<div className="border-2 border-ink bg-orange px-3 py-2 font-display text-[12px] font-bold">
{t(error)}
</div>
<Link
to="/connexion"
className="cursor-pointer border-[3px] border-ink bg-yellow p-3 text-center font-display text-[12px] font-black uppercase tracking-wide shadow-brutal-md hover:bg-green hover:text-white"
>
{t('auth.magic.retry')}
</Link>
</div>
) : (
<div className="flex items-center gap-3 font-mono text-[12px] uppercase tracking-widest opacity-70">
<span className="h-4 w-4 animate-spin border-2 border-ink border-t-transparent" />
{t('auth.magic.verifying')}
</div>
)}
</AuthLayout>
);
}
async function submit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await requestMagicLink(email.trim());
setSent(true);
const raw = devMagicToken(email.trim());
setDevLink(raw ? `/connexion?token=${encodeURIComponent(raw)}` : null);
} catch (err) {
setError(err instanceof ApiError ? err.code : 'error.generic');
} finally {
setBusy(false);
}
}
return (
<AuthLayout title={t('auth.magic.title')}>
{sent ? (
<div className="flex flex-col gap-3">
<div className="border-2 border-ink bg-green px-3 py-3 font-display text-[12px] font-bold text-white">
{t('auth.magic.sent')}
</div>
{devLink ? (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
{t('auth.magic.devHint')}
<Link to={devLink} className="mt-2 block break-all font-bold underline">
{devLink}
</Link>
</div>
) : null}
<button
type="button"
onClick={() => {
setSent(false);
setDevLink(null);
}}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[11px] font-extrabold uppercase hover:bg-yellow"
>
{t('auth.magic.another')}
</button>
</div>
) : (
<form onSubmit={submit} className="flex flex-col gap-3">
<p className="font-display text-[12px] leading-relaxed">{t('auth.magic.lead')}</p>
<label className="block">
<FieldLabel>{t('auth.email')}</FieldLabel>
<input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
/>
</label>
{error ? (
<div className="border-2 border-ink bg-orange px-3 py-2 font-display text-[11px] font-bold">
{t(error)}
</div>
) : null}
<button
type="submit"
disabled={busy}
className="mt-1 cursor-pointer border-[3px] border-ink bg-yellow p-3 font-display text-[13px] font-black uppercase tracking-wide shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{busy ? t('auth.magic.sending') : t('auth.magic.send')}
</button>
</form>
)}
<div className="mt-4 border-t-2 border-ink pt-3 font-display text-[11px]">
{t('auth.isAdmin')}{' '}
<Link to="/connexion/coordination" className="font-extrabold underline">
{t('auth.adminLink')}
</Link>
</div>
</AuthLayout>
);
}