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
+37
-6
@@ -19,6 +19,31 @@ assignments/validate · matrix`).
|
||||
Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par
|
||||
`src/api/` (client HTTP ou mock) et les hooks React Query de `src/api/hooks.ts`.
|
||||
|
||||
## Authentification & rôles
|
||||
|
||||
Toutes les pages métier sont derrière `<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)
|
||||
|
||||
| # | Route | Source de données | Actions |
|
||||
@@ -26,10 +51,14 @@ Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par
|
||||
| 01 | `/grille` | `GET /matrix` | clic cellule → panneau : `GET /assignments?shift_id`, `POST /assignments/validate` par candidat, `POST` / `PATCH` / `DELETE /assignments` |
|
||||
| 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) |
|
||||
| 03 | `/publication` | `assignments` (statut `PROPOSED`) | lecture — publication versionnée & emails **hors périmètre** (CLAUDE.md §4) |
|
||||
| 04 | `/mobile-benevole` | `assignments?volunteer_id` | `PATCH /assignments/{id}` (confirmer) |
|
||||
| 05 | `/mobile-referent` | `assignments?shift_id` | pointage **local** (hors périmètre) |
|
||||
| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | `PATCH /assignments/{id}` (confirmer)¹ |
|
||||
| 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) |
|
||||
| 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. |
|
||||
|
||||
¹ En mode `http`, l'API n'expose pas encore aux bénévoles le détail des
|
||||
créneaux ni l'auto-confirmation : l'écran dégrade proprement (créneaux sans
|
||||
détail, message d'info). Fonctionne intégralement en mode `mock`.
|
||||
|
||||
## Commandes
|
||||
|
||||
| Action | Commande |
|
||||
@@ -56,14 +85,16 @@ Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par
|
||||
src/
|
||||
├── main.tsx
|
||||
├── api/
|
||||
│ ├── schema.ts # types miroir du back-end
|
||||
│ ├── schema.ts # types miroir du back-end (dont auth)
|
||||
│ ├── types.ts # interface PlanningApi + ApiError
|
||||
│ ├── session.ts # jeton Bearer partagé + callback 401
|
||||
│ ├── http-client.ts # implémentation HTTP
|
||||
│ ├── mock/ # implémentation mémoire (dataset + client + moteur)
|
||||
│ ├── mock/ # implémentation mémoire (dataset + client + comptes + moteur)
|
||||
│ ├── index.ts # sélection mock / http, EDITION_ID
|
||||
│ └── hooks.ts # hooks React Query (lectures + mutations)
|
||||
├── app/ AppShell (onglets) · router
|
||||
├── pages/ un fichier par écran (+ grille/, config/, mobile/)
|
||||
├── auth/ auth-context (AuthProvider/useAuth) · RequireAuth · roles
|
||||
├── app/ AppShell (onglets filtrés par rôle) · router
|
||||
├── pages/ un fichier par écran (+ auth/, grille/, config/, mobile/)
|
||||
├── components/ ui.tsx (primitives) · state.tsx (Loading / ErrorNote)
|
||||
├── lib/ time.ts · planning.ts (treillis, heures, surcharge, violationText) · env.ts · cx.ts
|
||||
└── i18n/ t() + locales/fr.json
|
||||
|
||||
+156
-71
@@ -1,26 +1,87 @@
|
||||
// Rendu headless de chaque écran, avec les données de démonstration
|
||||
// préchargées, pour vérifier que la chaîne API → sélecteurs → rendu ne lève pas.
|
||||
// Vérifs headless : flux d'auth du mock + rendu de chaque écran.
|
||||
// Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx
|
||||
import { createElement } from 'react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RouterProvider, createMemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { EDITION_ID, api } from '@/api';
|
||||
import {
|
||||
ApiError,
|
||||
DEMO_COORDINATOR_EMAIL,
|
||||
DEMO_COORDINATOR_PASSWORD,
|
||||
EDITION_ID,
|
||||
api,
|
||||
devMagicToken,
|
||||
setAuthToken,
|
||||
} from '@/api';
|
||||
import type { AccountRead } from '@/api/schema';
|
||||
import { queryKeys } from '@/api/hooks';
|
||||
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
|
||||
import { routes } from '@/app/router';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||
import { PublicationPage } from '@/pages/PublicationPage';
|
||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||
|
||||
const paths = [
|
||||
'/grille',
|
||||
'/benevoles',
|
||||
'/publication',
|
||||
'/mobile-benevole',
|
||||
'/mobile-referent',
|
||||
'/configuration',
|
||||
];
|
||||
const e = EDITION_ID;
|
||||
let failures = 0;
|
||||
const fail = (label: string, err: unknown) => {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${label}`, err);
|
||||
};
|
||||
|
||||
async function prefetch(client: QueryClient) {
|
||||
const e = EDITION_ID;
|
||||
let coordinator: AccountRead | null = null;
|
||||
let volunteerAccount: AccountRead | null = null;
|
||||
let leadAccount: AccountRead | null = null;
|
||||
let coordinatorToken = '';
|
||||
|
||||
async function checkAuth() {
|
||||
let badRejected = 'non';
|
||||
try {
|
||||
await api.auth.login(e, { email: DEMO_COORDINATOR_EMAIL, password: 'faux' });
|
||||
} catch (err) {
|
||||
badRejected = err instanceof ApiError ? err.code : String(err);
|
||||
}
|
||||
|
||||
const session = await api.auth.login(e, {
|
||||
email: DEMO_COORDINATOR_EMAIL,
|
||||
password: DEMO_COORDINATOR_PASSWORD,
|
||||
});
|
||||
coordinator = session.account;
|
||||
coordinatorToken = session.access_token;
|
||||
setAuthToken(coordinatorToken);
|
||||
const me = await api.auth.me();
|
||||
|
||||
const vols = await api.volunteers.list(e);
|
||||
const volEmail = vols.find((v) => v.email)?.email ?? '';
|
||||
await api.auth.requestMagicLink(e, { email: volEmail });
|
||||
const raw = devMagicToken(volEmail);
|
||||
volunteerAccount = raw ? (await api.auth.verifyMagicLink(e, { token: raw })).account : null;
|
||||
|
||||
await api.auth.requestMagicLink(e, { email: 'referent1@lessonsdautomne.fr' });
|
||||
const leadRaw = devMagicToken('referent1@lessonsdautomne.fr');
|
||||
leadAccount = leadRaw ? (await api.auth.verifyMagicLink(e, { token: leadRaw })).account : null;
|
||||
|
||||
setAuthToken(coordinatorToken);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
badPasswordRejectedWith: badRejected,
|
||||
loginRole: session.account.role,
|
||||
meRole: me.role,
|
||||
magicVolunteerRole: volunteerAccount?.role,
|
||||
magicVolunteerLinked: Boolean(volunteerAccount?.volunteer_id),
|
||||
magicLeadRole: leadAccount?.role,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function prefetchCore(client: QueryClient) {
|
||||
await Promise.all([
|
||||
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
|
||||
client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }),
|
||||
@@ -30,70 +91,94 @@ async function prefetch(client: QueryClient) {
|
||||
]);
|
||||
}
|
||||
|
||||
async function checkMockApi() {
|
||||
const e = EDITION_ID;
|
||||
const [volunteers, positions, shifts, assignments, matrix] = await Promise.all([
|
||||
api.volunteers.list(e),
|
||||
api.positions.list(e),
|
||||
api.shifts.list(e),
|
||||
api.assignments.list(e),
|
||||
api.matrix.get(e),
|
||||
]);
|
||||
|
||||
const openShift = matrix.positions.flatMap((p) => p.shifts).find((s) => s.confirmed + s.proposed < s.min_staff);
|
||||
const candidate = volunteers.find(
|
||||
(v) => !assignments.some((a) => a.shift_id === openShift?.shift_id && a.volunteer_id === v.id),
|
||||
);
|
||||
const validation = openShift && candidate
|
||||
? await api.assignments.validate(e, { volunteer_id: candidate.id, shift_id: openShift.shift_id })
|
||||
: { blocking: false, violations: [] };
|
||||
|
||||
let dupError = 'none';
|
||||
const existing = assignments[0];
|
||||
try {
|
||||
await api.assignments.create(e, { volunteer_id: existing.volunteer_id, shift_id: existing.shift_id });
|
||||
} catch (err) {
|
||||
dupError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
volunteers: volunteers.length,
|
||||
positions: positions.length,
|
||||
shifts: shifts.length,
|
||||
assignments: assignments.length,
|
||||
matrixPositions: matrix.positions.length,
|
||||
matrixShifts: matrix.positions.reduce((n, p) => n + p.shifts.length, 0),
|
||||
sampleValidation: validation.violations.map((v) => v.code),
|
||||
duplicateCreateRejectedWith: dupError,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
function authValue(account: AccountRead): AuthContextValue {
|
||||
return {
|
||||
status: 'authed',
|
||||
account,
|
||||
loginWithPassword: async () => account,
|
||||
requestMagicLink: async () => {},
|
||||
verifyMagicLink: async () => account,
|
||||
logout: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
await checkMockApi().catch((err) => {
|
||||
failures += 1;
|
||||
console.error('FAIL checkMockApi', err);
|
||||
});
|
||||
|
||||
for (const path of paths) {
|
||||
async function renderProtected(
|
||||
label: string,
|
||||
account: AccountRead,
|
||||
node: ReactNode,
|
||||
extra?: (client: QueryClient) => Promise<void>,
|
||||
) {
|
||||
try {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await prefetch(client);
|
||||
await prefetchCore(client);
|
||||
if (extra) await extra(client);
|
||||
const html = renderToString(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(
|
||||
AuthContext.Provider,
|
||||
{ value: authValue(account) },
|
||||
createElement(MemoryRouter, null, node),
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(`ok ${label.padEnd(26)} ${html.length} octets`);
|
||||
} catch (err) {
|
||||
fail(label, err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPublic(path: string) {
|
||||
try {
|
||||
const client = new QueryClient();
|
||||
const router = createMemoryRouter(routes, { initialEntries: [path] });
|
||||
const html = renderToString(
|
||||
createElement(QueryClientProvider, { client }, createElement(RouterProvider, { router })),
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(AuthProvider, null, createElement(RouterProvider, { router })),
|
||||
),
|
||||
);
|
||||
console.log(`ok ${path.padEnd(20)} ${html.length} octets`);
|
||||
} catch (error) {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${path}`, error);
|
||||
console.log(`ok ${path.padEnd(26)} ${html.length} octets`);
|
||||
} catch (err) {
|
||||
fail(path, err);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
await checkAuth().catch((err) => fail('checkAuth', err));
|
||||
|
||||
renderPublic('/connexion');
|
||||
renderPublic('/connexion/coordination');
|
||||
|
||||
if (coordinator) {
|
||||
setAuthToken(coordinatorToken);
|
||||
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||
await renderProtected('/publication', coordinator, createElement(PublicationPage));
|
||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||
}
|
||||
if (volunteerAccount) {
|
||||
const vid = volunteerAccount.volunteer_id as string;
|
||||
await renderProtected(
|
||||
'/mobile-benevole',
|
||||
volunteerAccount,
|
||||
createElement(MobileVolunteerPage),
|
||||
async (client) => {
|
||||
await client.prefetchQuery({
|
||||
queryKey: queryKeys.assignments({ volunteerId: vid }),
|
||||
queryFn: () => api.assignments.list(e, { volunteerId: vid }),
|
||||
});
|
||||
await client.prefetchQuery({
|
||||
queryKey: ['volunteer', e, vid],
|
||||
queryFn: () => api.volunteers.get(e, vid),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
if (leadAccount) {
|
||||
await renderProtected('/mobile-referent', leadAccount, createElement(MobileLeadPage));
|
||||
}
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
|
||||
@@ -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? } }`. */
|
||||
|
||||
@@ -2,23 +2,23 @@ import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
import { API_MODE } from '@/api';
|
||||
import { useAssignments, useVolunteers } from '@/api/hooks';
|
||||
import { useAuth } from '@/auth/auth-context';
|
||||
import { NAV_TABS, canAccess, roleLabelKey } from '@/auth/roles';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/grille', num: '01', labelKey: 'tab.grille' },
|
||||
{ to: '/benevoles', num: '02', labelKey: 'tab.volunteers' },
|
||||
{ to: '/publication', num: '03', labelKey: 'tab.publication' },
|
||||
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||
] as const;
|
||||
|
||||
export function AppShell() {
|
||||
const volunteers = useVolunteers();
|
||||
const assignments = useAssignments();
|
||||
const { account, logout } = useAuth();
|
||||
const isCoordinator = account?.role === 'COORDINATOR';
|
||||
|
||||
const volunteers = useVolunteers({ enabled: isCoordinator });
|
||||
const assignments = useAssignments(undefined, { enabled: isCoordinator });
|
||||
|
||||
const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length;
|
||||
const tabs = account ? NAV_TABS.filter((tab) => canAccess(tab.to, account.role)) : [];
|
||||
const initials = account
|
||||
? account.email.slice(0, 2).toUpperCase()
|
||||
: '··';
|
||||
|
||||
return (
|
||||
<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">
|
||||
{t('header.title')}
|
||||
</div>
|
||||
{isCoordinator ? (
|
||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||
{account ? t(roleLabelKey(account.role)) : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex overflow-x-auto">
|
||||
{TABS.map((tab) => (
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={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]">
|
||||
{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')}
|
||||
</div>
|
||||
) : null}
|
||||
{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">
|
||||
{isCoordinator && proposedCount > 0 ? (
|
||||
<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 })}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold">
|
||||
MB
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Navigate, type RouteObject } from 'react-router-dom';
|
||||
|
||||
import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
|
||||
import { AppShell } from './AppShell';
|
||||
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
||||
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
@@ -9,18 +12,25 @@ import { PublicationPage } from '@/pages/PublicationPage';
|
||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{ path: '/connexion', element: <ConnexionPage /> },
|
||||
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/grille" replace /> },
|
||||
{ index: true, element: <HomeRedirect /> },
|
||||
{ path: 'grille', element: <GrillePage /> },
|
||||
{ path: 'benevoles', element: <VolunteerViewPage /> },
|
||||
{ path: 'publication', element: <PublicationPage /> },
|
||||
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||
{ path: 'configuration', element: <ConfigPage /> },
|
||||
{ path: '*', element: <Navigate to="/grille" replace /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
];
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -18,6 +18,34 @@
|
||||
"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.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.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||
@@ -124,6 +152,8 @@
|
||||
"mobileVol.confirm": "JE CONFIRME",
|
||||
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
|
||||
"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.",
|
||||
|
||||
"mobileLead.statusTime": "18:07",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
|
||||
import { AuthProvider } from '@/auth/auth-context';
|
||||
import { routes } from '@/app/router';
|
||||
import './index.css';
|
||||
|
||||
@@ -17,7 +18,9 @@ const router = createBrowserRouter(routes);
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import { useAuth } from '@/auth/auth-context';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||
import { hourRangeLabel } from '@/lib/time';
|
||||
@@ -11,13 +12,17 @@ import { cx } from '@/lib/cx';
|
||||
type Presence = Record<string, 'present' | 'absent'>;
|
||||
|
||||
export function MobileLeadPage() {
|
||||
const { account } = useAuth();
|
||||
const positions = usePositions();
|
||||
const shifts = useShifts();
|
||||
const volunteers = useVolunteers();
|
||||
const [presence, setPresence] = useState<Presence>({});
|
||||
|
||||
// Poste de démonstration : le premier ; créneau le plus fourni.
|
||||
const position = (positions.data ?? [])[0];
|
||||
// Poste du référent connecté (repli : le premier, pour la démo coordination).
|
||||
const myPositions = (positions.data ?? []).filter(
|
||||
(p) => account && p.lead_account_id === account.id,
|
||||
);
|
||||
const position = myPositions[0] ?? (positions.data ?? [])[0];
|
||||
const positionShifts = useMemo(
|
||||
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
|
||||
[shifts.data, position?.id],
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
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 { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { isActive } from '@/lib/planning';
|
||||
import { ENV } from '@/lib/env';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
|
||||
|
||||
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 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 shiftsById = useMemo(
|
||||
@@ -32,21 +36,38 @@ export function MobileVolunteerPage() {
|
||||
[positions.data],
|
||||
);
|
||||
|
||||
if (volunteers.isLoading || shifts.isLoading) return <Loading />;
|
||||
const error = volunteers.error ?? shifts.error ?? assignments.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
if (!me) return null;
|
||||
if (!volunteerId) {
|
||||
return (
|
||||
<div className="m-6 max-w-[560px] border-[3px] border-dashed border-ink bg-paper p-4 font-display text-[13px] font-bold">
|
||||
{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 ?? [])
|
||||
.filter(isActive)
|
||||
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) }))
|
||||
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } =>
|
||||
Boolean(entry.shift),
|
||||
)
|
||||
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at));
|
||||
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
|
||||
.sort((a, b) => {
|
||||
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
|
||||
const bv = b.shift ? naiveMinutes(b.shift.starts_at) : 0;
|
||||
return av - bv;
|
||||
});
|
||||
|
||||
const totalHours = Math.round(
|
||||
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 0) * 10,
|
||||
const totalHours =
|
||||
Math.round(
|
||||
slots.reduce(
|
||||
(sum, s) => sum + (s.shift ? hoursBetween(s.shift.starts_at, s.shift.ends_at) : 0),
|
||||
0,
|
||||
) * 10,
|
||||
) / 10;
|
||||
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="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||
<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 className="mt-3 flex gap-2">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
{t('mobileVol.slots', { count: slots.length })}
|
||||
</div>
|
||||
{!detailsUnavailable ? (
|
||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||
{t('mobileVol.hours', { count: totalHours })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,10 +98,16 @@ export function MobileVolunteerPage() {
|
||||
) : null}
|
||||
|
||||
<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 }) => {
|
||||
const position = positionsById.get(shift.position_id);
|
||||
const position = shift ? positionsById.get(shift.position_id) : undefined;
|
||||
return (
|
||||
<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">
|
||||
<span className="font-display text-[15px] font-black">
|
||||
{longDayLabel(shift.starts_at)}
|
||||
@@ -87,10 +116,13 @@ export function MobileVolunteerPage() {
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-[11px]">
|
||||
{position ? (
|
||||
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
||||
{(position?.name ?? '').toUpperCase()}
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
) : null}
|
||||
{position?.location ? (
|
||||
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
||||
{t('mobileVol.location', { location: position.location })}
|
||||
@@ -112,6 +144,12 @@ export function MobileVolunteerPage() {
|
||||
{t('mobileVol.confirm')}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user