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
|
Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par
|
||||||
`src/api/` (client HTTP ou mock) et les hooks React Query de `src/api/hooks.ts`.
|
`src/api/` (client HTTP ou mock) et les hooks React Query de `src/api/hooks.ts`.
|
||||||
|
|
||||||
|
## Authentification & rôles
|
||||||
|
|
||||||
|
Toutes les pages métier sont derrière `<RequireAuth>` (`src/auth/`).
|
||||||
|
|
||||||
|
- **Coordination** → `/connexion/coordination` : email + mot de passe
|
||||||
|
(`POST /editions/{id}/auth/login`).
|
||||||
|
- **Référents / bénévoles** → `/connexion` : lien magique
|
||||||
|
(`POST …/auth/magic-link` puis `…/auth/verify`). L'email étant stubé, le
|
||||||
|
mode `mock` affiche directement le lien de démo à cliquer.
|
||||||
|
- Le jeton JWT est stocké en `localStorage`, envoyé en `Authorization: Bearer`
|
||||||
|
sur chaque requête ; une réponse 401 déconnecte.
|
||||||
|
- Comptes de démo (mode `mock`) : coordination
|
||||||
|
`coordination@lessonsdautomne.fr` / `demo` ; référents
|
||||||
|
`referent1..3@lessonsdautomne.fr` ; chaque bénévole ayant un email.
|
||||||
|
|
||||||
|
Visibilité des onglets par rôle :
|
||||||
|
|
||||||
|
| Onglet | Coordination | Référent | Bénévole |
|
||||||
|
|--------|:---:|:---:|:---:|
|
||||||
|
| 01 Grille · 02 Vue bénévole · 03 Confirmations · 06 Configuration | ✅ | — | — |
|
||||||
|
| 04 Bénévole · mobile | — | — | ✅ |
|
||||||
|
| 05 Référent · mobile | — | ✅ | — |
|
||||||
|
|
||||||
|
L'accueil après connexion dépend du rôle (`homePath`).
|
||||||
|
|
||||||
## Écrans (onglets)
|
## Écrans (onglets)
|
||||||
|
|
||||||
| # | Route | Source de données | Actions |
|
| # | Route | Source de données | Actions |
|
||||||
@@ -26,10 +51,14 @@ Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par
|
|||||||
| 01 | `/grille` | `GET /matrix` | clic cellule → panneau : `GET /assignments?shift_id`, `POST /assignments/validate` par candidat, `POST` / `PATCH` / `DELETE /assignments` |
|
| 01 | `/grille` | `GET /matrix` | clic cellule → panneau : `GET /assignments?shift_id`, `POST /assignments/validate` par candidat, `POST` / `PATCH` / `DELETE /assignments` |
|
||||||
| 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) |
|
| 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) |
|
||||||
| 03 | `/publication` | `assignments` (statut `PROPOSED`) | lecture — publication versionnée & emails **hors périmètre** (CLAUDE.md §4) |
|
| 03 | `/publication` | `assignments` (statut `PROPOSED`) | lecture — publication versionnée & emails **hors périmètre** (CLAUDE.md §4) |
|
||||||
| 04 | `/mobile-benevole` | `assignments?volunteer_id` | `PATCH /assignments/{id}` (confirmer) |
|
| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | `PATCH /assignments/{id}` (confirmer)¹ |
|
||||||
| 05 | `/mobile-referent` | `assignments?shift_id` | pointage **local** (hors périmètre) |
|
| 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) |
|
||||||
| 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. |
|
| 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. |
|
||||||
|
|
||||||
|
¹ En mode `http`, l'API n'expose pas encore aux bénévoles le détail des
|
||||||
|
créneaux ni l'auto-confirmation : l'écran dégrade proprement (créneaux sans
|
||||||
|
détail, message d'info). Fonctionne intégralement en mode `mock`.
|
||||||
|
|
||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
| Action | Commande |
|
| Action | Commande |
|
||||||
@@ -56,14 +85,16 @@ Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par
|
|||||||
src/
|
src/
|
||||||
├── main.tsx
|
├── main.tsx
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── schema.ts # types miroir du back-end
|
│ ├── schema.ts # types miroir du back-end (dont auth)
|
||||||
│ ├── types.ts # interface PlanningApi + ApiError
|
│ ├── types.ts # interface PlanningApi + ApiError
|
||||||
|
│ ├── session.ts # jeton Bearer partagé + callback 401
|
||||||
│ ├── http-client.ts # implémentation HTTP
|
│ ├── http-client.ts # implémentation HTTP
|
||||||
│ ├── mock/ # implémentation mémoire (dataset + client + moteur)
|
│ ├── mock/ # implémentation mémoire (dataset + client + comptes + moteur)
|
||||||
│ ├── index.ts # sélection mock / http, EDITION_ID
|
│ ├── index.ts # sélection mock / http, EDITION_ID
|
||||||
│ └── hooks.ts # hooks React Query (lectures + mutations)
|
│ └── hooks.ts # hooks React Query (lectures + mutations)
|
||||||
├── app/ AppShell (onglets) · router
|
├── auth/ auth-context (AuthProvider/useAuth) · RequireAuth · roles
|
||||||
├── pages/ un fichier par écran (+ grille/, config/, mobile/)
|
├── app/ AppShell (onglets filtrés par rôle) · router
|
||||||
|
├── pages/ un fichier par écran (+ auth/, grille/, config/, mobile/)
|
||||||
├── components/ ui.tsx (primitives) · state.tsx (Loading / ErrorNote)
|
├── components/ ui.tsx (primitives) · state.tsx (Loading / ErrorNote)
|
||||||
├── lib/ time.ts · planning.ts (treillis, heures, surcharge, violationText) · env.ts · cx.ts
|
├── lib/ time.ts · planning.ts (treillis, heures, surcharge, violationText) · env.ts · cx.ts
|
||||||
└── i18n/ t() + locales/fr.json
|
└── i18n/ t() + locales/fr.json
|
||||||
|
|||||||
+156
-71
@@ -1,26 +1,87 @@
|
|||||||
// Rendu headless de chaque écran, avec les données de démonstration
|
// Vérifs headless : flux d'auth du mock + rendu de chaque écran.
|
||||||
// préchargées, pour vérifier que la chaîne API → sélecteurs → rendu ne lève pas.
|
|
||||||
// Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx
|
// Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx
|
||||||
import { createElement } from 'react';
|
import { createElement, type ReactNode } from 'react';
|
||||||
import { renderToString } from 'react-dom/server';
|
import { renderToString } from 'react-dom/server';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { RouterProvider, createMemoryRouter } from 'react-router-dom';
|
import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { EDITION_ID, api } from '@/api';
|
import {
|
||||||
|
ApiError,
|
||||||
|
DEMO_COORDINATOR_EMAIL,
|
||||||
|
DEMO_COORDINATOR_PASSWORD,
|
||||||
|
EDITION_ID,
|
||||||
|
api,
|
||||||
|
devMagicToken,
|
||||||
|
setAuthToken,
|
||||||
|
} from '@/api';
|
||||||
|
import type { AccountRead } from '@/api/schema';
|
||||||
import { queryKeys } from '@/api/hooks';
|
import { queryKeys } from '@/api/hooks';
|
||||||
|
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
|
||||||
import { routes } from '@/app/router';
|
import { routes } from '@/app/router';
|
||||||
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
|
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||||
|
import { PublicationPage } from '@/pages/PublicationPage';
|
||||||
|
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||||
|
|
||||||
const paths = [
|
const e = EDITION_ID;
|
||||||
'/grille',
|
let failures = 0;
|
||||||
'/benevoles',
|
const fail = (label: string, err: unknown) => {
|
||||||
'/publication',
|
failures += 1;
|
||||||
'/mobile-benevole',
|
console.error(`FAIL ${label}`, err);
|
||||||
'/mobile-referent',
|
};
|
||||||
'/configuration',
|
|
||||||
];
|
|
||||||
|
|
||||||
async function prefetch(client: QueryClient) {
|
let coordinator: AccountRead | null = null;
|
||||||
const e = EDITION_ID;
|
let volunteerAccount: AccountRead | null = null;
|
||||||
|
let leadAccount: AccountRead | null = null;
|
||||||
|
let coordinatorToken = '';
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
let badRejected = 'non';
|
||||||
|
try {
|
||||||
|
await api.auth.login(e, { email: DEMO_COORDINATOR_EMAIL, password: 'faux' });
|
||||||
|
} catch (err) {
|
||||||
|
badRejected = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await api.auth.login(e, {
|
||||||
|
email: DEMO_COORDINATOR_EMAIL,
|
||||||
|
password: DEMO_COORDINATOR_PASSWORD,
|
||||||
|
});
|
||||||
|
coordinator = session.account;
|
||||||
|
coordinatorToken = session.access_token;
|
||||||
|
setAuthToken(coordinatorToken);
|
||||||
|
const me = await api.auth.me();
|
||||||
|
|
||||||
|
const vols = await api.volunteers.list(e);
|
||||||
|
const volEmail = vols.find((v) => v.email)?.email ?? '';
|
||||||
|
await api.auth.requestMagicLink(e, { email: volEmail });
|
||||||
|
const raw = devMagicToken(volEmail);
|
||||||
|
volunteerAccount = raw ? (await api.auth.verifyMagicLink(e, { token: raw })).account : null;
|
||||||
|
|
||||||
|
await api.auth.requestMagicLink(e, { email: 'referent1@lessonsdautomne.fr' });
|
||||||
|
const leadRaw = devMagicToken('referent1@lessonsdautomne.fr');
|
||||||
|
leadAccount = leadRaw ? (await api.auth.verifyMagicLink(e, { token: leadRaw })).account : null;
|
||||||
|
|
||||||
|
setAuthToken(coordinatorToken);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
badPasswordRejectedWith: badRejected,
|
||||||
|
loginRole: session.account.role,
|
||||||
|
meRole: me.role,
|
||||||
|
magicVolunteerRole: volunteerAccount?.role,
|
||||||
|
magicVolunteerLinked: Boolean(volunteerAccount?.volunteer_id),
|
||||||
|
magicLeadRole: leadAccount?.role,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefetchCore(client: QueryClient) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
|
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
|
||||||
client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }),
|
client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }),
|
||||||
@@ -30,70 +91,94 @@ async function prefetch(client: QueryClient) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkMockApi() {
|
function authValue(account: AccountRead): AuthContextValue {
|
||||||
const e = EDITION_ID;
|
return {
|
||||||
const [volunteers, positions, shifts, assignments, matrix] = await Promise.all([
|
status: 'authed',
|
||||||
api.volunteers.list(e),
|
account,
|
||||||
api.positions.list(e),
|
loginWithPassword: async () => account,
|
||||||
api.shifts.list(e),
|
requestMagicLink: async () => {},
|
||||||
api.assignments.list(e),
|
verifyMagicLink: async () => account,
|
||||||
api.matrix.get(e),
|
logout: () => {},
|
||||||
]);
|
};
|
||||||
|
|
||||||
const openShift = matrix.positions.flatMap((p) => p.shifts).find((s) => s.confirmed + s.proposed < s.min_staff);
|
|
||||||
const candidate = volunteers.find(
|
|
||||||
(v) => !assignments.some((a) => a.shift_id === openShift?.shift_id && a.volunteer_id === v.id),
|
|
||||||
);
|
|
||||||
const validation = openShift && candidate
|
|
||||||
? await api.assignments.validate(e, { volunteer_id: candidate.id, shift_id: openShift.shift_id })
|
|
||||||
: { blocking: false, violations: [] };
|
|
||||||
|
|
||||||
let dupError = 'none';
|
|
||||||
const existing = assignments[0];
|
|
||||||
try {
|
|
||||||
await api.assignments.create(e, { volunteer_id: existing.volunteer_id, shift_id: existing.shift_id });
|
|
||||||
} catch (err) {
|
|
||||||
dupError = err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let failures = 0;
|
async function renderProtected(
|
||||||
|
label: string,
|
||||||
await checkMockApi().catch((err) => {
|
account: AccountRead,
|
||||||
failures += 1;
|
node: ReactNode,
|
||||||
console.error('FAIL checkMockApi', err);
|
extra?: (client: QueryClient) => Promise<void>,
|
||||||
});
|
) {
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
try {
|
try {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
await prefetch(client);
|
await prefetchCore(client);
|
||||||
|
if (extra) await extra(client);
|
||||||
|
const html = renderToString(
|
||||||
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client },
|
||||||
|
createElement(
|
||||||
|
AuthContext.Provider,
|
||||||
|
{ value: authValue(account) },
|
||||||
|
createElement(MemoryRouter, null, node),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(`ok ${label.padEnd(26)} ${html.length} octets`);
|
||||||
|
} catch (err) {
|
||||||
|
fail(label, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPublic(path: string) {
|
||||||
|
try {
|
||||||
|
const client = new QueryClient();
|
||||||
const router = createMemoryRouter(routes, { initialEntries: [path] });
|
const router = createMemoryRouter(routes, { initialEntries: [path] });
|
||||||
const html = renderToString(
|
const html = renderToString(
|
||||||
createElement(QueryClientProvider, { client }, createElement(RouterProvider, { router })),
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client },
|
||||||
|
createElement(AuthProvider, null, createElement(RouterProvider, { router })),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
console.log(`ok ${path.padEnd(20)} ${html.length} octets`);
|
console.log(`ok ${path.padEnd(26)} ${html.length} octets`);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
failures += 1;
|
fail(path, err);
|
||||||
console.error(`FAIL ${path}`, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
await checkAuth().catch((err) => fail('checkAuth', err));
|
||||||
|
|
||||||
|
renderPublic('/connexion');
|
||||||
|
renderPublic('/connexion/coordination');
|
||||||
|
|
||||||
|
if (coordinator) {
|
||||||
|
setAuthToken(coordinatorToken);
|
||||||
|
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||||
|
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||||
|
await renderProtected('/publication', coordinator, createElement(PublicationPage));
|
||||||
|
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||||
|
}
|
||||||
|
if (volunteerAccount) {
|
||||||
|
const vid = volunteerAccount.volunteer_id as string;
|
||||||
|
await renderProtected(
|
||||||
|
'/mobile-benevole',
|
||||||
|
volunteerAccount,
|
||||||
|
createElement(MobileVolunteerPage),
|
||||||
|
async (client) => {
|
||||||
|
await client.prefetchQuery({
|
||||||
|
queryKey: queryKeys.assignments({ volunteerId: vid }),
|
||||||
|
queryFn: () => api.assignments.list(e, { volunteerId: vid }),
|
||||||
|
});
|
||||||
|
await client.prefetchQuery({
|
||||||
|
queryKey: ['volunteer', e, vid],
|
||||||
|
queryFn: () => api.volunteers.get(e, vid),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (leadAccount) {
|
||||||
|
await renderProtected('/mobile-referent', leadAccount, createElement(MobileLeadPage));
|
||||||
|
}
|
||||||
|
|
||||||
process.exit(failures ? 1 : 0);
|
process.exit(failures ? 1 : 0);
|
||||||
|
|||||||
@@ -34,14 +34,27 @@ export const queryKeys = {
|
|||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
// Lectures //
|
// Lectures //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
export function useVolunteers() {
|
export function useVolunteers(options?: { enabled?: boolean }) {
|
||||||
return useQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(edition) });
|
return useQuery({
|
||||||
|
queryKey: queryKeys.volunteers,
|
||||||
|
queryFn: () => api.volunteers.list(edition),
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePositions() {
|
export function usePositions() {
|
||||||
return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) });
|
return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fiche d'un bénévole (un bénévole connecté peut lire la sienne). */
|
||||||
|
export function useVolunteer(id: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['volunteer', edition, id ?? null],
|
||||||
|
queryFn: () => api.volunteers.get(edition, id as string),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useShifts(positionId?: string) {
|
export function useShifts(positionId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.shifts(positionId),
|
queryKey: queryKeys.shifts(positionId),
|
||||||
@@ -49,10 +62,14 @@ export function useShifts(positionId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAssignments(params?: { volunteerId?: string; shiftId?: string }) {
|
export function useAssignments(
|
||||||
|
params?: { volunteerId?: string; shiftId?: string },
|
||||||
|
options?: { enabled?: boolean },
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.assignments(params),
|
queryKey: queryKeys.assignments(params),
|
||||||
queryFn: () => api.assignments.list(edition, params),
|
queryFn: () => api.assignments.list(edition, params),
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,30 @@ import type {
|
|||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
|
LoginRequest,
|
||||||
|
MagicLinkRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
|
VerifyRequest,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerUpdate,
|
VolunteerUpdate,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
import { getAuthToken, notifyUnauthorized } from './session';
|
||||||
import { ApiError, type PlanningApi } from './types';
|
import { ApiError, type PlanningApi } from './types';
|
||||||
|
|
||||||
// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end.
|
// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end.
|
||||||
export function createHttpApi(baseUrl: string): PlanningApi {
|
export function createHttpApi(baseUrl: string): PlanningApi {
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const token = getAuthToken();
|
||||||
const res = await fetch(`${baseUrl}${path}`, {
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +47,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
detail && typeof detail === 'object' && 'violations' in detail
|
detail && typeof detail === 'object' && 'violations' in detail
|
||||||
? ((detail as { violations: [] }).violations ?? [])
|
? ((detail as { violations: [] }).violations ?? [])
|
||||||
: [];
|
: [];
|
||||||
|
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
|
||||||
throw new ApiError(res.status, code, violations);
|
throw new ApiError(res.status, code, violations);
|
||||||
}
|
}
|
||||||
return body as T;
|
return body as T;
|
||||||
@@ -109,5 +119,14 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get: (editionId) => request(`${base(editionId)}/matrix`),
|
get: (editionId) => request(`${base(editionId)}/matrix`),
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
login: (editionId, body: LoginRequest) =>
|
||||||
|
request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
requestMagicLink: (editionId, body: MagicLinkRequest) =>
|
||||||
|
request(`${base(editionId)}/auth/magic-link`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
verifyMagicLink: (editionId, body: VerifyRequest) =>
|
||||||
|
request(`${base(editionId)}/auth/verify`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
me: () => request(`/auth/me`),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ENV } from '@/lib/env';
|
|||||||
|
|
||||||
import { createHttpApi } from './http-client';
|
import { createHttpApi } from './http-client';
|
||||||
import { MOCK_EDITION_ID } from './mock/dataset';
|
import { MOCK_EDITION_ID } from './mock/dataset';
|
||||||
import { createMockApi } from './mock/mock-client';
|
import { createMockApi, peekMockMagicToken } from './mock/mock-client';
|
||||||
import type { PlanningApi } from './types';
|
import type { PlanningApi } from './types';
|
||||||
|
|
||||||
// `VITE_API_MODE` : `mock` (défaut, jeu de démonstration en mémoire) ou `http`
|
// `VITE_API_MODE` : `mock` (défaut, jeu de démonstration en mémoire) ou `http`
|
||||||
@@ -14,6 +14,19 @@ export const EDITION_ID: string = ENV.editionId ?? MOCK_EDITION_ID;
|
|||||||
export const api: PlanningApi =
|
export const api: PlanningApi =
|
||||||
API_MODE === 'http' ? createHttpApi(ENV.apiBaseUrl) : createMockApi();
|
API_MODE === 'http' ? createHttpApi(ENV.apiBaseUrl) : createMockApi();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode mock uniquement : dernier lien magique émis pour un email (l'envoi
|
||||||
|
* d'email est stubé). Renvoie `undefined` en mode HTTP.
|
||||||
|
*/
|
||||||
|
export function devMagicToken(email: string): string | undefined {
|
||||||
|
return API_MODE === 'mock' ? peekMockMagicToken(email) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export { ApiError } from './types';
|
export { ApiError } from './types';
|
||||||
|
export { setAuthToken, setOnUnauthorized } from './session';
|
||||||
|
export {
|
||||||
|
DEMO_COORDINATOR_EMAIL,
|
||||||
|
DEMO_COORDINATOR_PASSWORD,
|
||||||
|
} from './mock/dataset';
|
||||||
export type { PlanningApi } from './types';
|
export type { PlanningApi } from './types';
|
||||||
export * from './schema';
|
export * from './schema';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { hoursBetween } from '@/lib/time';
|
import { hoursBetween } from '@/lib/time';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AccountRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentStatus,
|
AssignmentStatus,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -14,11 +15,18 @@ import type {
|
|||||||
|
|
||||||
export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001';
|
export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
/** Identifiants de démonstration (mode mock uniquement). */
|
||||||
|
export const DEMO_COORDINATOR_EMAIL = 'coordination@lessonsdautomne.fr';
|
||||||
|
export const DEMO_COORDINATOR_PASSWORD = 'demo';
|
||||||
|
|
||||||
export interface MockState {
|
export interface MockState {
|
||||||
volunteers: VolunteerRead[];
|
volunteers: VolunteerRead[];
|
||||||
positions: PositionRead[];
|
positions: PositionRead[];
|
||||||
shifts: ShiftRead[];
|
shifts: ShiftRead[];
|
||||||
assignments: AssignmentRead[];
|
assignments: AssignmentRead[];
|
||||||
|
accounts: AccountRead[];
|
||||||
|
/** accountId -> mot de passe en clair (mock uniquement). */
|
||||||
|
passwords: Map<string, string>;
|
||||||
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
|
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
|
||||||
availability: Map<string, Set<string>>;
|
availability: Map<string, Set<string>>;
|
||||||
seq: number;
|
seq: number;
|
||||||
@@ -181,6 +189,7 @@ export function buildMockState(): MockState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const me = volunteers[0];
|
const me = volunteers[0];
|
||||||
|
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
|
||||||
availability.set(
|
availability.set(
|
||||||
me.id,
|
me.id,
|
||||||
new Set(
|
new Set(
|
||||||
@@ -284,7 +293,57 @@ export function buildMockState(): MockState {
|
|||||||
seq += 1;
|
seq += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { volunteers, positions, shifts, assignments, availability, seq };
|
// ---- Comptes ----
|
||||||
|
const accounts: AccountRead[] = [];
|
||||||
|
const passwords = new Map<string, string>();
|
||||||
|
|
||||||
|
const coordinator: AccountRead = {
|
||||||
|
id: `acc${seq++}`,
|
||||||
|
edition_id,
|
||||||
|
email: DEMO_COORDINATOR_EMAIL,
|
||||||
|
role: 'COORDINATOR',
|
||||||
|
volunteer_id: null,
|
||||||
|
auth_method: 'password',
|
||||||
|
created_at: NOW,
|
||||||
|
updated_at: NOW,
|
||||||
|
};
|
||||||
|
accounts.push(coordinator);
|
||||||
|
passwords.set(coordinator.id, DEMO_COORDINATOR_PASSWORD);
|
||||||
|
|
||||||
|
// Trois référents, rattachés à trois postes (lien magique, sans mot de passe).
|
||||||
|
['MONS', 'MONI', 'REG'].forEach((positionId, i) => {
|
||||||
|
const account: AccountRead = {
|
||||||
|
id: `acc${seq++}`,
|
||||||
|
edition_id,
|
||||||
|
email: `referent${i + 1}@lessonsdautomne.fr`,
|
||||||
|
role: 'LEAD',
|
||||||
|
volunteer_id: null,
|
||||||
|
auth_method: 'magic_link',
|
||||||
|
created_at: NOW,
|
||||||
|
updated_at: NOW,
|
||||||
|
};
|
||||||
|
accounts.push(account);
|
||||||
|
const position = positions.find((p) => p.id === positionId);
|
||||||
|
if (position) position.lead_account_id = account.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bénévoles connectés : tous ceux qui ont une adresse email.
|
||||||
|
volunteers
|
||||||
|
.filter((v) => v.email)
|
||||||
|
.forEach((v) => {
|
||||||
|
accounts.push({
|
||||||
|
id: `acc${seq++}`,
|
||||||
|
edition_id,
|
||||||
|
email: v.email as string,
|
||||||
|
role: 'VOLUNTEER',
|
||||||
|
volunteer_id: v.id,
|
||||||
|
auth_method: 'magic_link',
|
||||||
|
created_at: NOW,
|
||||||
|
updated_at: NOW,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { volunteers, positions, shifts, assignments, accounts, passwords, availability, seq };
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean {
|
function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
Violation,
|
Violation,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema';
|
import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema';
|
||||||
|
import type { AccountRead, TokenResponse } from '../schema';
|
||||||
|
import { getAuthToken } from '../session';
|
||||||
import { ApiError, type PlanningApi } from '../types';
|
import { ApiError, type PlanningApi } from '../types';
|
||||||
import { buildMockState, type MockState } from './dataset';
|
import { buildMockState, type MockState } from './dataset';
|
||||||
|
|
||||||
@@ -16,12 +18,42 @@ import { buildMockState, type MockState } from './dataset';
|
|||||||
|
|
||||||
const LATENCY_MS = 120;
|
const LATENCY_MS = 120;
|
||||||
const EIGHT_HOURS_MIN = 8 * 60;
|
const EIGHT_HOURS_MIN = 8 * 60;
|
||||||
|
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
let state: MockState = buildMockState();
|
let state: MockState = buildMockState();
|
||||||
|
|
||||||
|
/** token de session -> id de compte. */
|
||||||
|
const sessions = new Map<string, string>();
|
||||||
|
/** token magique brut -> compte + expiration. */
|
||||||
|
const magicTokens = new Map<string, { accountId: string; expiresAt: number; used: boolean }>();
|
||||||
|
/** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */
|
||||||
|
const lastMagicLink = new Map<string, string>();
|
||||||
|
|
||||||
/** Réinitialise le jeu de démonstration (utile en test). */
|
/** Réinitialise le jeu de démonstration (utile en test). */
|
||||||
export function resetMockState(): void {
|
export function resetMockState(): void {
|
||||||
state = buildMockState();
|
state = buildMockState();
|
||||||
|
sessions.clear();
|
||||||
|
magicTokens.clear();
|
||||||
|
lastMagicLink.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode mock uniquement : récupère le dernier lien magique émis pour un email.
|
||||||
|
* En mode HTTP le back-end l'envoie par mail (ici stubé dans ses logs).
|
||||||
|
*/
|
||||||
|
export function peekMockMagicToken(email: string): string | undefined {
|
||||||
|
return lastMagicLink.get(email.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountByEmail(email: string): AccountRead | undefined {
|
||||||
|
const target = email.trim().toLowerCase();
|
||||||
|
return state.accounts.find((a) => a.email.toLowerCase() === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issue(account: AccountRead): TokenResponse {
|
||||||
|
const token = `mock.${account.id}.${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
sessions.set(token, account.id);
|
||||||
|
return { access_token: token, token_type: 'bearer', account };
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = <T>(value: T): Promise<T> =>
|
const delay = <T>(value: T): Promise<T> =>
|
||||||
@@ -411,6 +443,47 @@ export function createMockApi(): PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get: () => delay(buildMatrix()),
|
get: () => delay(buildMatrix()),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
login: (_edition, { email, password }) => {
|
||||||
|
const account = accountByEmail(email);
|
||||||
|
if (!account || account.auth_method !== 'password' || state.passwords.get(account.id) !== password) {
|
||||||
|
throw new ApiError(401, 'auth.invalid_credentials');
|
||||||
|
}
|
||||||
|
return delay(issue(account));
|
||||||
|
},
|
||||||
|
requestMagicLink: (_edition, { email }) => {
|
||||||
|
const account = accountByEmail(email);
|
||||||
|
if (account && account.auth_method === 'magic_link') {
|
||||||
|
const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||||
|
magicTokens.set(raw, {
|
||||||
|
accountId: account.id,
|
||||||
|
expiresAt: Date.now() + MAGIC_LINK_TTL_MS,
|
||||||
|
used: false,
|
||||||
|
});
|
||||||
|
lastMagicLink.set(account.email.toLowerCase(), raw);
|
||||||
|
}
|
||||||
|
// Réponse identique dans tous les cas (pas de divulgation).
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
verifyMagicLink: (_edition, { token }) => {
|
||||||
|
const entry = magicTokens.get(token.trim());
|
||||||
|
if (!entry || entry.used || entry.expiresAt < Date.now()) {
|
||||||
|
throw new ApiError(401, 'auth.invalid_token');
|
||||||
|
}
|
||||||
|
entry.used = true;
|
||||||
|
const account = state.accounts.find((a) => a.id === entry.accountId);
|
||||||
|
if (!account) throw new ApiError(401, 'auth.invalid_token');
|
||||||
|
return delay(issue(account));
|
||||||
|
},
|
||||||
|
me: () => {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const accountId = token ? sessions.get(token) : undefined;
|
||||||
|
const account = accountId ? state.accounts.find((a) => a.id === accountId) : undefined;
|
||||||
|
if (!account) throw new ApiError(401, 'error.unauthorized');
|
||||||
|
return delay(account);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLE
|
|||||||
export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
||||||
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
|
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
|
||||||
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
||||||
|
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
||||||
|
export type AuthMethod = 'password' | 'magic_link';
|
||||||
|
|
||||||
/** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */
|
/** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */
|
||||||
export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED'];
|
export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED'];
|
||||||
@@ -208,3 +210,37 @@ export interface MatrixResponse {
|
|||||||
edition_id: string;
|
edition_id: string;
|
||||||
positions: MatrixPosition[];
|
positions: MatrixPosition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Authentification //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
export interface AccountRead {
|
||||||
|
id: string;
|
||||||
|
edition_id: string;
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
volunteer_id: string | null;
|
||||||
|
/** "password" (coordination) ou "magic_link" (référents, bénévoles). */
|
||||||
|
auth_method: AuthMethod;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
account: AccountRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MagicLinkRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyRequest {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
import type {
|
||||||
|
AccountRead,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
|
LoginRequest,
|
||||||
|
MagicLinkRequest,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionRead,
|
PositionRead,
|
||||||
@@ -12,6 +15,8 @@ import type {
|
|||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
|
TokenResponse,
|
||||||
|
VerifyRequest,
|
||||||
Violation,
|
Violation,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerRead,
|
VolunteerRead,
|
||||||
@@ -61,6 +66,13 @@ export interface PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get(editionId: string): Promise<MatrixResponse>;
|
get(editionId: string): Promise<MatrixResponse>;
|
||||||
};
|
};
|
||||||
|
auth: {
|
||||||
|
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
|
||||||
|
/** 202 sans divulgation : ne dit pas si le compte existe. */
|
||||||
|
requestMagicLink(editionId: string, body: MagicLinkRequest): Promise<void>;
|
||||||
|
verifyMagicLink(editionId: string, body: VerifyRequest): Promise<TokenResponse>;
|
||||||
|
me(): Promise<AccountRead>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */
|
/** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ import { NavLink, Outlet } from 'react-router-dom';
|
|||||||
|
|
||||||
import { API_MODE } from '@/api';
|
import { API_MODE } from '@/api';
|
||||||
import { useAssignments, useVolunteers } from '@/api/hooks';
|
import { useAssignments, useVolunteers } from '@/api/hooks';
|
||||||
|
import { useAuth } from '@/auth/auth-context';
|
||||||
|
import { NAV_TABS, canAccess, roleLabelKey } from '@/auth/roles';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ to: '/grille', num: '01', labelKey: 'tab.grille' },
|
|
||||||
{ to: '/benevoles', num: '02', labelKey: 'tab.volunteers' },
|
|
||||||
{ to: '/publication', num: '03', labelKey: 'tab.publication' },
|
|
||||||
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
|
||||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
|
||||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const volunteers = useVolunteers();
|
const { account, logout } = useAuth();
|
||||||
const assignments = useAssignments();
|
const isCoordinator = account?.role === 'COORDINATOR';
|
||||||
|
|
||||||
|
const volunteers = useVolunteers({ enabled: isCoordinator });
|
||||||
|
const assignments = useAssignments(undefined, { enabled: isCoordinator });
|
||||||
|
|
||||||
const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length;
|
const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length;
|
||||||
|
const tabs = account ? NAV_TABS.filter((tab) => canAccess(tab.to, account.role)) : [];
|
||||||
|
const initials = account
|
||||||
|
? account.email.slice(0, 2).toUpperCase()
|
||||||
|
: '··';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
@@ -27,13 +27,19 @@ export function AppShell() {
|
|||||||
<div className="font-display text-[14px] font-black tracking-tight">
|
<div className="font-display text-[14px] font-black tracking-tight">
|
||||||
{t('header.title')}
|
{t('header.title')}
|
||||||
</div>
|
</div>
|
||||||
|
{isCoordinator ? (
|
||||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||||
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||||
|
{account ? t(roleLabelKey(account.role)) : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex overflow-x-auto">
|
<nav className="flex overflow-x-auto">
|
||||||
{TABS.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
to={tab.to}
|
to={tab.to}
|
||||||
@@ -60,18 +66,28 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
|
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
|
||||||
{API_MODE === 'mock' ? (
|
{API_MODE === 'mock' ? (
|
||||||
<div className="border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider">
|
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block">
|
||||||
{t('header.mockMode')}
|
{t('header.mockMode')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{proposedCount > 0 ? (
|
{isCoordinator && proposedCount > 0 ? (
|
||||||
<div className="flex items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow">
|
<div className="hidden items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow md:flex">
|
||||||
{t('header.proposed', { count: proposedCount })}
|
{t('header.proposed', { count: proposedCount })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold">
|
<div
|
||||||
MB
|
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
|
||||||
|
title={account?.email}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange"
|
||||||
|
>
|
||||||
|
{t('auth.logout')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Navigate, type RouteObject } from 'react-router-dom';
|
import { Navigate, type RouteObject } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
|
||||||
import { AppShell } from './AppShell';
|
import { AppShell } from './AppShell';
|
||||||
|
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
||||||
|
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
@@ -9,18 +12,25 @@ import { PublicationPage } from '@/pages/PublicationPage';
|
|||||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
|
{ path: '/connexion', element: <ConnexionPage /> },
|
||||||
|
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
|
||||||
|
{
|
||||||
|
element: <RequireAuth />,
|
||||||
|
children: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/grille" replace /> },
|
{ index: true, element: <HomeRedirect /> },
|
||||||
{ path: 'grille', element: <GrillePage /> },
|
{ path: 'grille', element: <GrillePage /> },
|
||||||
{ path: 'benevoles', element: <VolunteerViewPage /> },
|
{ path: 'benevoles', element: <VolunteerViewPage /> },
|
||||||
{ path: 'publication', element: <PublicationPage /> },
|
{ path: 'publication', element: <PublicationPage /> },
|
||||||
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||||
{ path: 'configuration', element: <ConfigPage /> },
|
{ path: 'configuration', element: <ConfigPage /> },
|
||||||
{ path: '*', element: <Navigate to="/grille" replace /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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à",
|
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà",
|
||||||
"shift.ends_not_after_starts": "La fin doit être après le début",
|
"shift.ends_not_after_starts": "La fin doit être après le début",
|
||||||
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
|
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
|
||||||
|
"auth.invalid_credentials": "Email ou mot de passe incorrect",
|
||||||
|
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
|
||||||
|
|
||||||
|
"role.coordinator": "Coordination",
|
||||||
|
"role.lead": "Référent",
|
||||||
|
"role.volunteer": "Bénévole",
|
||||||
|
|
||||||
|
"auth.logout": "Déconnexion",
|
||||||
|
"auth.email": "Email",
|
||||||
|
"auth.password": "Mot de passe",
|
||||||
|
"auth.signIn": "Se connecter",
|
||||||
|
"auth.signingIn": "Connexion…",
|
||||||
|
"auth.admin.title": "Espace coordination",
|
||||||
|
"auth.demoCredentials": "Démo : {email} · mot de passe « {password} »",
|
||||||
|
"auth.notAdmin": "Bénévole ou référent ?",
|
||||||
|
"auth.magicLinkLink": "Recevoir un lien de connexion",
|
||||||
|
"auth.isAdmin": "Vous êtes de la coordination ?",
|
||||||
|
"auth.adminLink": "Connexion par mot de passe",
|
||||||
|
"auth.magic.title": "Connexion bénévole / référent",
|
||||||
|
"auth.magic.lead": "Entrez votre email : vous recevrez un lien de connexion, sans mot de passe.",
|
||||||
|
"auth.magic.send": "Envoyer le lien",
|
||||||
|
"auth.magic.sending": "Envoi…",
|
||||||
|
"auth.magic.sent": "Si un compte correspond à cet email, un lien de connexion vient d'être envoyé.",
|
||||||
|
"auth.magic.devHint": "Démo (email stubé) — cliquez ce lien :",
|
||||||
|
"auth.magic.another": "Utiliser un autre email",
|
||||||
|
"auth.magic.verifyTitle": "Connexion en cours",
|
||||||
|
"auth.magic.verifying": "Vérification du lien…",
|
||||||
|
"auth.magic.retry": "Demander un nouveau lien",
|
||||||
|
|
||||||
"header.title": "GESTION DES BÉNÉVOLES",
|
"header.title": "GESTION DES BÉNÉVOLES",
|
||||||
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||||
@@ -124,6 +152,8 @@
|
|||||||
"mobileVol.confirm": "JE CONFIRME",
|
"mobileVol.confirm": "JE CONFIRME",
|
||||||
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
|
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
|
||||||
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
|
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
|
||||||
|
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
|
||||||
|
"mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.",
|
||||||
"mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.",
|
"mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.",
|
||||||
|
|
||||||
"mobileLead.statusTime": "18:07",
|
"mobileLead.statusTime": "18:07",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AuthProvider } from '@/auth/auth-context';
|
||||||
import { routes } from '@/app/router';
|
import { routes } from '@/app/router';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -17,7 +18,9 @@ const router = createBrowserRouter(routes);
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||||
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||||
import { hourRangeLabel } from '@/lib/time';
|
import { hourRangeLabel } from '@/lib/time';
|
||||||
@@ -11,13 +12,17 @@ import { cx } from '@/lib/cx';
|
|||||||
type Presence = Record<string, 'present' | 'absent'>;
|
type Presence = Record<string, 'present' | 'absent'>;
|
||||||
|
|
||||||
export function MobileLeadPage() {
|
export function MobileLeadPage() {
|
||||||
|
const { account } = useAuth();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
const [presence, setPresence] = useState<Presence>({});
|
const [presence, setPresence] = useState<Presence>({});
|
||||||
|
|
||||||
// Poste de démonstration : le premier ; créneau le plus fourni.
|
// Poste du référent connecté (repli : le premier, pour la démo coordination).
|
||||||
const position = (positions.data ?? [])[0];
|
const myPositions = (positions.data ?? []).filter(
|
||||||
|
(p) => account && p.lead_account_id === account.id,
|
||||||
|
);
|
||||||
|
const position = myPositions[0] ?? (positions.data ?? [])[0];
|
||||||
const positionShifts = useMemo(
|
const positionShifts = useMemo(
|
||||||
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
|
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
|
||||||
[shifts.data, position?.id],
|
[shifts.data, position?.id],
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useAssignments, usePositions, useShifts, useUpdateAssignment, useVolunteers } from '@/api/hooks';
|
import { ApiError } from '@/api';
|
||||||
|
import {
|
||||||
|
useAssignments,
|
||||||
|
usePositions,
|
||||||
|
useShifts,
|
||||||
|
useUpdateAssignment,
|
||||||
|
useVolunteer,
|
||||||
|
} from '@/api/hooks';
|
||||||
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||||
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
|
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||||
import { isActive } from '@/lib/planning';
|
import { isActive } from '@/lib/planning';
|
||||||
import { ENV } from '@/lib/env';
|
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
|
|
||||||
|
|
||||||
export function MobileVolunteerPage() {
|
export function MobileVolunteerPage() {
|
||||||
const volunteers = useVolunteers();
|
const { account } = useAuth();
|
||||||
|
const volunteerId = account?.volunteer_id ?? null;
|
||||||
|
|
||||||
|
const meVolunteer = useVolunteer(volunteerId);
|
||||||
|
const assignments = useAssignments(volunteerId ? { volunteerId } : undefined, {
|
||||||
|
enabled: Boolean(volunteerId),
|
||||||
|
});
|
||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
|
|
||||||
const me = useMemo(() => {
|
|
||||||
const list = volunteers.data ?? [];
|
|
||||||
return list.find((v) => v.id === DEMO_VOLUNTEER_ID) ?? list[0];
|
|
||||||
}, [volunteers.data]);
|
|
||||||
|
|
||||||
const assignments = useAssignments(me ? { volunteerId: me.id } : undefined);
|
|
||||||
const updateAssignment = useUpdateAssignment();
|
const updateAssignment = useUpdateAssignment();
|
||||||
|
|
||||||
const shiftsById = useMemo(
|
const shiftsById = useMemo(
|
||||||
@@ -32,21 +36,38 @@ export function MobileVolunteerPage() {
|
|||||||
[positions.data],
|
[positions.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (volunteers.isLoading || shifts.isLoading) return <Loading />;
|
if (!volunteerId) {
|
||||||
const error = volunteers.error ?? shifts.error ?? assignments.error;
|
return (
|
||||||
if (error) return <ErrorNote error={error} />;
|
<div className="m-6 max-w-[560px] border-[3px] border-dashed border-ink bg-paper p-4 font-display text-[13px] font-bold">
|
||||||
if (!me) return null;
|
{t('mobileVol.noAccount')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (assignments.isLoading) return <Loading />;
|
||||||
|
if (assignments.error) return <ErrorNote error={assignments.error} />;
|
||||||
|
|
||||||
|
const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? '';
|
||||||
|
// shifts / positions peuvent être refusés (403) à un bénévole côté back-end réel :
|
||||||
|
// on affiche alors les créneaux sans détail plutôt que de tomber en erreur.
|
||||||
|
const detailsUnavailable =
|
||||||
|
(shifts.error instanceof ApiError && shifts.error.status === 403) ||
|
||||||
|
(positions.error instanceof ApiError && positions.error.status === 403);
|
||||||
|
|
||||||
const slots = (assignments.data ?? [])
|
const slots = (assignments.data ?? [])
|
||||||
.filter(isActive)
|
.filter(isActive)
|
||||||
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) }))
|
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
|
||||||
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } =>
|
.sort((a, b) => {
|
||||||
Boolean(entry.shift),
|
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
|
||||||
)
|
const bv = b.shift ? naiveMinutes(b.shift.starts_at) : 0;
|
||||||
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at));
|
return av - bv;
|
||||||
|
});
|
||||||
|
|
||||||
const totalHours = Math.round(
|
const totalHours =
|
||||||
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 0) * 10,
|
Math.round(
|
||||||
|
slots.reduce(
|
||||||
|
(sum, s) => sum + (s.shift ? hoursBetween(s.shift.starts_at, s.shift.ends_at) : 0),
|
||||||
|
0,
|
||||||
|
) * 10,
|
||||||
) / 10;
|
) / 10;
|
||||||
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
|
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
|
||||||
|
|
||||||
@@ -56,15 +77,17 @@ export function MobileVolunteerPage() {
|
|||||||
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
|
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
|
||||||
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||||
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
|
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
|
||||||
{t('mobileVol.greeting', { name: me.first_name.toUpperCase() })}
|
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||||
{t('mobileVol.slots', { count: slots.length })}
|
{t('mobileVol.slots', { count: slots.length })}
|
||||||
</div>
|
</div>
|
||||||
|
{!detailsUnavailable ? (
|
||||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||||
{t('mobileVol.hours', { count: totalHours })}
|
{t('mobileVol.hours', { count: totalHours })}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,10 +98,16 @@ export function MobileVolunteerPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
|
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
|
||||||
|
{detailsUnavailable ? (
|
||||||
|
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
|
||||||
|
{t('mobileVol.detailsUnavailable')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{slots.map(({ assignment, shift }) => {
|
{slots.map(({ assignment, shift }) => {
|
||||||
const position = positionsById.get(shift.position_id);
|
const position = shift ? positionsById.get(shift.position_id) : undefined;
|
||||||
return (
|
return (
|
||||||
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
{shift ? (
|
||||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||||
<span className="font-display text-[15px] font-black">
|
<span className="font-display text-[15px] font-black">
|
||||||
{longDayLabel(shift.starts_at)}
|
{longDayLabel(shift.starts_at)}
|
||||||
@@ -87,10 +116,13 @@ export function MobileVolunteerPage() {
|
|||||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="p-[11px]">
|
<div className="p-[11px]">
|
||||||
|
{position ? (
|
||||||
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
||||||
{(position?.name ?? '').toUpperCase()}
|
{position.name.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
{position?.location ? (
|
{position?.location ? (
|
||||||
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
||||||
{t('mobileVol.location', { location: position.location })}
|
{t('mobileVol.location', { location: position.location })}
|
||||||
@@ -112,6 +144,12 @@ export function MobileVolunteerPage() {
|
|||||||
{t('mobileVol.confirm')}
|
{t('mobileVol.confirm')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{updateAssignment.error instanceof ApiError &&
|
||||||
|
updateAssignment.variables?.id === assignment.id ? (
|
||||||
|
<div className="mt-2 border-2 border-ink bg-orange px-2 py-1 font-display text-[10px] font-bold">
|
||||||
|
{t(updateAssignment.error.code)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { API_MODE, ApiError, DEMO_COORDINATOR_EMAIL, DEMO_COORDINATOR_PASSWORD } from '@/api';
|
||||||
|
import { useAuth } from '@/auth/auth-context';
|
||||||
|
import { homePath } from '@/auth/roles';
|
||||||
|
import { FieldLabel } from '@/components/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export function AdminLoginPage() {
|
||||||
|
const { status, account, loginWithPassword } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation() as { state?: { from?: string } };
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
if (status === 'authed' && account) {
|
||||||
|
return <Navigate to={location.state?.from ?? homePath(account.role)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const me = await loginWithPassword(email.trim(), password);
|
||||||
|
navigate(location.state?.from ?? homePath(me.role), { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.code : 'error.generic');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout title={t('auth.admin.title')}>
|
||||||
|
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('auth.email')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('auth.password')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="border-2 border-ink bg-orange px-3 py-2 font-display text-[11px] font-bold">
|
||||||
|
{t(error)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="mt-1 cursor-pointer border-[3px] border-ink bg-yellow p-3 font-display text-[13px] font-black uppercase tracking-wide shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? t('auth.signingIn') : t('auth.signIn')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{API_MODE === 'mock' ? (
|
||||||
|
<div className="mt-4 border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
|
||||||
|
{t('auth.demoCredentials', {
|
||||||
|
email: DEMO_COORDINATOR_EMAIL,
|
||||||
|
password: DEMO_COORDINATOR_PASSWORD,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4 border-t-2 border-ink pt-3 font-display text-[11px]">
|
||||||
|
{t('auth.notAdmin')}{' '}
|
||||||
|
<Link to="/connexion" className="font-extrabold underline">
|
||||||
|
{t('auth.magicLinkLink')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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