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:
AdamThomas
2026-09-10 07:43:00 +02:00
co-authored by Claude Sonnet 5
parent 464635656a
commit 6e16db4b7e
22 changed files with 1102 additions and 156 deletions
+31
View File
@@ -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 />;
}
+137
View File
@@ -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;
}
+39
View File
@@ -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()}`;
}