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
+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);
|
||||
|
||||
Reference in New Issue
Block a user