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:
co-authored by
Claude Sonnet 5
parent
464635656a
commit
6e16db4b7e
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
@@ -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? } }`. */
|
||||
|
||||
Reference in New Issue
Block a user