Files
benevoles/frontend/src/auth/auth-context.tsx
T
AdamThomasandClaude Sonnet 5 6e16db4b7e 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
2026-09-10 07:43:00 +02:00

138 lines
3.7 KiB
TypeScript

/* 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;
}