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