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
+20 -3
View File
@@ -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,
});
}
+20 -1
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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`),
},
};
}
+14 -1
View File
@@ -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';
+60 -1
View File
@@ -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<string, string>;
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
availability: Map<string, Set<string>>;
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<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 {
+73
View File
@@ -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<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). */
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 = <T>(value: T): Promise<T> =>
@@ -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);
},
},
};
}
+36
View File
@@ -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;
}
+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 {
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<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? } }`. */