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
138 lines
3.7 KiB
TypeScript
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;
|
|
}
|