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
+20 -3
View File
@@ -34,14 +34,27 @@ export const queryKeys = {
// --------------------------------------------------------------------------- //
// Lectures //
// --------------------------------------------------------------------------- //
export function useVolunteers() {
return useQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(edition) });
export function useVolunteers(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.volunteers,
queryFn: () => api.volunteers.list(edition),
enabled: options?.enabled ?? true,
});
}
export function usePositions() {
return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) });
}
/** Fiche d'un bénévole (un bénévole connecté peut lire la sienne). */
export function useVolunteer(id: string | null | undefined) {
return useQuery({
queryKey: ['volunteer', edition, id ?? null],
queryFn: () => api.volunteers.get(edition, id as string),
enabled: Boolean(id),
});
}
export function useShifts(positionId?: string) {
return useQuery({
queryKey: queryKeys.shifts(positionId),
@@ -49,10 +62,14 @@ export function useShifts(positionId?: string) {
});
}
export function useAssignments(params?: { volunteerId?: string; shiftId?: string }) {
export function useAssignments(
params?: { volunteerId?: string; shiftId?: string },
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: queryKeys.assignments(params),
queryFn: () => api.assignments.list(edition, params),
enabled: options?.enabled ?? true,
});
}
+20 -1
View File
@@ -2,21 +2,30 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
LoginRequest,
MagicLinkRequest,
PositionCreate,
PositionUpdate,
ShiftCreate,
ShiftUpdate,
VerifyRequest,
VolunteerCreate,
VolunteerUpdate,
} from './schema';
import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types';
// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end.
export function createHttpApi(baseUrl: string): PlanningApi {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
credentials: 'include',
});
@@ -38,6 +47,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
detail && typeof detail === 'object' && 'violations' in detail
? ((detail as { violations: [] }).violations ?? [])
: [];
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
throw new ApiError(res.status, code, violations);
}
return body as T;
@@ -109,5 +119,14 @@ export function createHttpApi(baseUrl: string): PlanningApi {
matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`),
},
auth: {
login: (editionId, body: LoginRequest) =>
request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }),
requestMagicLink: (editionId, body: MagicLinkRequest) =>
request(`${base(editionId)}/auth/magic-link`, { method: 'POST', body: JSON.stringify(body) }),
verifyMagicLink: (editionId, body: VerifyRequest) =>
request(`${base(editionId)}/auth/verify`, { method: 'POST', body: JSON.stringify(body) }),
me: () => request(`/auth/me`),
},
};
}
+14 -1
View File
@@ -2,7 +2,7 @@ import { ENV } from '@/lib/env';
import { createHttpApi } from './http-client';
import { MOCK_EDITION_ID } from './mock/dataset';
import { createMockApi } from './mock/mock-client';
import { createMockApi, peekMockMagicToken } from './mock/mock-client';
import type { PlanningApi } from './types';
// `VITE_API_MODE` : `mock` (défaut, jeu de démonstration en mémoire) ou `http`
@@ -14,6 +14,19 @@ export const EDITION_ID: string = ENV.editionId ?? MOCK_EDITION_ID;
export const api: PlanningApi =
API_MODE === 'http' ? createHttpApi(ENV.apiBaseUrl) : createMockApi();
/**
* Mode mock uniquement : dernier lien magique émis pour un email (l'envoi
* d'email est stubé). Renvoie `undefined` en mode HTTP.
*/
export function devMagicToken(email: string): string | undefined {
return API_MODE === 'mock' ? peekMockMagicToken(email) : undefined;
}
export { ApiError } from './types';
export { setAuthToken, setOnUnauthorized } from './session';
export {
DEMO_COORDINATOR_EMAIL,
DEMO_COORDINATOR_PASSWORD,
} from './mock/dataset';
export type { PlanningApi } from './types';
export * from './schema';
+60 -1
View File
@@ -1,6 +1,7 @@
import { hoursBetween } from '@/lib/time';
import type {
AccountRead,
AssignmentRead,
AssignmentStatus,
PositionCategory,
@@ -14,11 +15,18 @@ import type {
export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001';
/** Identifiants de démonstration (mode mock uniquement). */
export const DEMO_COORDINATOR_EMAIL = 'coordination@lessonsdautomne.fr';
export const DEMO_COORDINATOR_PASSWORD = 'demo';
export interface MockState {
volunteers: VolunteerRead[];
positions: PositionRead[];
shifts: ShiftRead[];
assignments: AssignmentRead[];
accounts: AccountRead[];
/** accountId -> mot de passe en clair (mock uniquement). */
passwords: Map<string, string>;
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
availability: Map<string, Set<string>>;
seq: number;
@@ -181,6 +189,7 @@ export function buildMockState(): MockState {
}
const me = volunteers[0];
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
availability.set(
me.id,
new Set(
@@ -284,7 +293,57 @@ export function buildMockState(): MockState {
seq += 1;
});
return { volunteers, positions, shifts, assignments, availability, seq };
// ---- Comptes ----
const accounts: AccountRead[] = [];
const passwords = new Map<string, string>();
const coordinator: AccountRead = {
id: `acc${seq++}`,
edition_id,
email: DEMO_COORDINATOR_EMAIL,
role: 'COORDINATOR',
volunteer_id: null,
auth_method: 'password',
created_at: NOW,
updated_at: NOW,
};
accounts.push(coordinator);
passwords.set(coordinator.id, DEMO_COORDINATOR_PASSWORD);
// Trois référents, rattachés à trois postes (lien magique, sans mot de passe).
['MONS', 'MONI', 'REG'].forEach((positionId, i) => {
const account: AccountRead = {
id: `acc${seq++}`,
edition_id,
email: `referent${i + 1}@lessonsdautomne.fr`,
role: 'LEAD',
volunteer_id: null,
auth_method: 'magic_link',
created_at: NOW,
updated_at: NOW,
};
accounts.push(account);
const position = positions.find((p) => p.id === positionId);
if (position) position.lead_account_id = account.id;
});
// Bénévoles connectés : tous ceux qui ont une adresse email.
volunteers
.filter((v) => v.email)
.forEach((v) => {
accounts.push({
id: `acc${seq++}`,
edition_id,
email: v.email as string,
role: 'VOLUNTEER',
volunteer_id: v.id,
auth_method: 'magic_link',
created_at: NOW,
updated_at: NOW,
});
});
return { volunteers, positions, shifts, assignments, accounts, passwords, availability, seq };
}
function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean {
+73
View File
@@ -8,6 +8,8 @@ import type {
Violation,
} from '../schema';
import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema';
import type { AccountRead, TokenResponse } from '../schema';
import { getAuthToken } from '../session';
import { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockState } from './dataset';
@@ -16,12 +18,42 @@ import { buildMockState, type MockState } from './dataset';
const LATENCY_MS = 120;
const EIGHT_HOURS_MIN = 8 * 60;
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
let state: MockState = buildMockState();
/** token de session -> id de compte. */
const sessions = new Map<string, string>();
/** token magique brut -> compte + expiration. */
const magicTokens = new Map<string, { accountId: string; expiresAt: number; used: boolean }>();
/** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */
const lastMagicLink = new Map<string, string>();
/** Réinitialise le jeu de démonstration (utile en test). */
export function resetMockState(): void {
state = buildMockState();
sessions.clear();
magicTokens.clear();
lastMagicLink.clear();
}
/**
* Mode mock uniquement : récupère le dernier lien magique émis pour un email.
* En mode HTTP le back-end l'envoie par mail (ici stubé dans ses logs).
*/
export function peekMockMagicToken(email: string): string | undefined {
return lastMagicLink.get(email.trim().toLowerCase());
}
function accountByEmail(email: string): AccountRead | undefined {
const target = email.trim().toLowerCase();
return state.accounts.find((a) => a.email.toLowerCase() === target);
}
function issue(account: AccountRead): TokenResponse {
const token = `mock.${account.id}.${Math.random().toString(36).slice(2, 10)}`;
sessions.set(token, account.id);
return { access_token: token, token_type: 'bearer', account };
}
const delay = <T>(value: T): Promise<T> =>
@@ -411,6 +443,47 @@ export function createMockApi(): PlanningApi {
matrix: {
get: () => delay(buildMatrix()),
},
auth: {
login: (_edition, { email, password }) => {
const account = accountByEmail(email);
if (!account || account.auth_method !== 'password' || state.passwords.get(account.id) !== password) {
throw new ApiError(401, 'auth.invalid_credentials');
}
return delay(issue(account));
},
requestMagicLink: (_edition, { email }) => {
const account = accountByEmail(email);
if (account && account.auth_method === 'magic_link') {
const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
magicTokens.set(raw, {
accountId: account.id,
expiresAt: Date.now() + MAGIC_LINK_TTL_MS,
used: false,
});
lastMagicLink.set(account.email.toLowerCase(), raw);
}
// Réponse identique dans tous les cas (pas de divulgation).
return delay(undefined);
},
verifyMagicLink: (_edition, { token }) => {
const entry = magicTokens.get(token.trim());
if (!entry || entry.used || entry.expiresAt < Date.now()) {
throw new ApiError(401, 'auth.invalid_token');
}
entry.used = true;
const account = state.accounts.find((a) => a.id === entry.accountId);
if (!account) throw new ApiError(401, 'auth.invalid_token');
return delay(issue(account));
},
me: () => {
const token = getAuthToken();
const accountId = token ? sessions.get(token) : undefined;
const account = accountId ? state.accounts.find((a) => a.id === accountId) : undefined;
if (!account) throw new ApiError(401, 'error.unauthorized');
return delay(account);
},
},
};
}
+36
View File
@@ -16,6 +16,8 @@ export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLE
export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
export type AuthMethod = 'password' | 'magic_link';
/** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */
export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED'];
@@ -208,3 +210,37 @@ export interface MatrixResponse {
edition_id: string;
positions: MatrixPosition[];
}
// --------------------------------------------------------------------------- //
// Authentification //
// --------------------------------------------------------------------------- //
export interface AccountRead {
id: string;
edition_id: string;
email: string;
role: Role;
volunteer_id: string | null;
/** "password" (coordination) ou "magic_link" (référents, bénévoles). */
auth_method: AuthMethod;
created_at: string;
updated_at: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
account: AccountRead;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface MagicLinkRequest {
email: string;
}
export interface VerifyRequest {
token: string;
}
+22
View File
@@ -0,0 +1,22 @@
// Jeton d'accès partagé par les deux implémentations de l'API (HTTP et mock).
// Posé par le provider d'auth ; lu à chaque requête.
let token: string | null = null;
let onUnauthorized: (() => void) | null = null;
export function getAuthToken(): string | null {
return token;
}
export function setAuthToken(value: string | null): void {
token = value;
}
/** Enregistre le callback appelé sur une réponse 401 (déconnexion). */
export function setOnUnauthorized(callback: (() => void) | null): void {
onUnauthorized = callback;
}
export function notifyUnauthorized(): void {
onUnauthorized?.();
}
+12
View File
@@ -1,10 +1,13 @@
import type {
AccountRead,
AssignmentCreate,
AssignmentRead,
AssignmentUpdate,
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
LoginRequest,
MagicLinkRequest,
MatrixResponse,
PositionCreate,
PositionRead,
@@ -12,6 +15,8 @@ import type {
ShiftCreate,
ShiftRead,
ShiftUpdate,
TokenResponse,
VerifyRequest,
Violation,
VolunteerCreate,
VolunteerRead,
@@ -61,6 +66,13 @@ export interface PlanningApi {
matrix: {
get(editionId: string): Promise<MatrixResponse>;
};
auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
/** 202 sans divulgation : ne dit pas si le compte existe. */
requestMagicLink(editionId: string, body: MagicLinkRequest): Promise<void>;
verifyMagicLink(editionId: string, body: VerifyRequest): Promise<TokenResponse>;
me(): Promise<AccountRead>;
};
}
/** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */
+36 -20
View File
@@ -2,23 +2,23 @@ import { NavLink, Outlet } from 'react-router-dom';
import { API_MODE } from '@/api';
import { useAssignments, useVolunteers } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { NAV_TABS, canAccess, roleLabelKey } from '@/auth/roles';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const TABS = [
{ 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' },
] as const;
export function AppShell() {
const volunteers = useVolunteers();
const assignments = useAssignments();
const { account, logout } = useAuth();
const isCoordinator = account?.role === 'COORDINATOR';
const volunteers = useVolunteers({ enabled: isCoordinator });
const assignments = useAssignments(undefined, { enabled: isCoordinator });
const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length;
const tabs = account ? NAV_TABS.filter((tab) => canAccess(tab.to, account.role)) : [];
const initials = account
? account.email.slice(0, 2).toUpperCase()
: '··';
return (
<div className="flex min-h-screen flex-col">
@@ -27,13 +27,19 @@ export function AppShell() {
<div className="font-display text-[14px] font-black tracking-tight">
{t('header.title')}
</div>
<div className="font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div>
{isCoordinator ? (
<div className="font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div>
) : (
<div className="font-mono text-[9.5px] font-medium opacity-70">
{account ? t(roleLabelKey(account.role)) : ''}
</div>
)}
</div>
<nav className="flex overflow-x-auto">
{TABS.map((tab) => (
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
@@ -60,18 +66,28 @@ export function AppShell() {
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
{API_MODE === 'mock' ? (
<div className="border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider">
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block">
{t('header.mockMode')}
</div>
) : null}
{proposedCount > 0 ? (
<div className="flex items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow">
{isCoordinator && proposedCount > 0 ? (
<div className="hidden items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow md:flex">
{t('header.proposed', { count: proposedCount })}
</div>
) : null}
<div className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold">
MB
<div
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
title={account?.email}
>
{initials}
</div>
<button
type="button"
onClick={logout}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange"
>
{t('auth.logout')}
</button>
</div>
</header>
+20 -10
View File
@@ -1,6 +1,9 @@
import { Navigate, type RouteObject } from 'react-router-dom';
import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
import { AppShell } from './AppShell';
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
import { ConfigPage } from '@/pages/ConfigPage';
import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage';
@@ -9,18 +12,25 @@ import { PublicationPage } from '@/pages/PublicationPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
export const routes: RouteObject[] = [
{ path: '/connexion', element: <ConnexionPage /> },
{ path: '/connexion/coordination', element: <AdminLoginPage /> },
{
path: '/',
element: <AppShell />,
element: <RequireAuth />,
children: [
{ index: true, element: <Navigate to="/grille" replace /> },
{ path: 'grille', element: <GrillePage /> },
{ path: 'benevoles', element: <VolunteerViewPage /> },
{ path: 'publication', element: <PublicationPage /> },
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
{ path: 'mobile-referent', element: <MobileLeadPage /> },
{ path: 'configuration', element: <ConfigPage /> },
{ path: '*', element: <Navigate to="/grille" replace /> },
{
path: '/',
element: <AppShell />,
children: [
{ index: true, element: <HomeRedirect /> },
{ path: 'grille', element: <GrillePage /> },
{ path: 'benevoles', element: <VolunteerViewPage /> },
{ path: 'publication', element: <PublicationPage /> },
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
{ path: 'mobile-referent', element: <MobileLeadPage /> },
{ path: 'configuration', element: <ConfigPage /> },
],
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
];
+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()}`;
}
+30
View File
@@ -18,6 +18,34 @@
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà",
"shift.ends_not_after_starts": "La fin doit être après le début",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
"auth.invalid_credentials": "Email ou mot de passe incorrect",
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
"role.coordinator": "Coordination",
"role.lead": "Référent",
"role.volunteer": "Bénévole",
"auth.logout": "Déconnexion",
"auth.email": "Email",
"auth.password": "Mot de passe",
"auth.signIn": "Se connecter",
"auth.signingIn": "Connexion…",
"auth.admin.title": "Espace coordination",
"auth.demoCredentials": "Démo : {email} · mot de passe « {password} »",
"auth.notAdmin": "Bénévole ou référent ?",
"auth.magicLinkLink": "Recevoir un lien de connexion",
"auth.isAdmin": "Vous êtes de la coordination ?",
"auth.adminLink": "Connexion par mot de passe",
"auth.magic.title": "Connexion bénévole / référent",
"auth.magic.lead": "Entrez votre email : vous recevrez un lien de connexion, sans mot de passe.",
"auth.magic.send": "Envoyer le lien",
"auth.magic.sending": "Envoi…",
"auth.magic.sent": "Si un compte correspond à cet email, un lien de connexion vient d'être envoyé.",
"auth.magic.devHint": "Démo (email stubé) — cliquez ce lien :",
"auth.magic.another": "Utiliser un autre email",
"auth.magic.verifyTitle": "Connexion en cours",
"auth.magic.verifying": "Vérification du lien…",
"auth.magic.retry": "Demander un nouveau lien",
"header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
@@ -124,6 +152,8 @@
"mobileVol.confirm": "JE CONFIRME",
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
"mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.",
"mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.",
"mobileLead.statusTime": "18:07",
+4 -1
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { AuthProvider } from '@/auth/auth-context';
import { routes } from '@/app/router';
import './index.css';
@@ -17,7 +18,9 @@ const router = createBrowserRouter(routes);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</StrictMode>,
);
+7 -2
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel } from '@/lib/time';
@@ -11,13 +12,17 @@ import { cx } from '@/lib/cx';
type Presence = Record<string, 'present' | 'absent'>;
export function MobileLeadPage() {
const { account } = useAuth();
const positions = usePositions();
const shifts = useShifts();
const volunteers = useVolunteers();
const [presence, setPresence] = useState<Presence>({});
// Poste de démonstration : le premier ; créneau le plus fourni.
const position = (positions.data ?? [])[0];
// Poste du référent connecté (repli : le premier, pour la démo coordination).
const myPositions = (positions.data ?? []).filter(
(p) => account && p.lead_account_id === account.id,
);
const position = myPositions[0] ?? (positions.data ?? [])[0];
const positionShifts = useMemo(
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
[shifts.data, position?.id],
+78 -40
View File
@@ -1,26 +1,30 @@
import { useMemo } from 'react';
import { useAssignments, usePositions, useShifts, useUpdateAssignment, useVolunteers } from '@/api/hooks';
import { ApiError } from '@/api';
import {
useAssignments,
usePositions,
useShifts,
useUpdateAssignment,
useVolunteer,
} from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
import { isActive } from '@/lib/planning';
import { ENV } from '@/lib/env';
import { t } from '@/i18n';
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
export function MobileVolunteerPage() {
const volunteers = useVolunteers();
const { account } = useAuth();
const volunteerId = account?.volunteer_id ?? null;
const meVolunteer = useVolunteer(volunteerId);
const assignments = useAssignments(volunteerId ? { volunteerId } : undefined, {
enabled: Boolean(volunteerId),
});
const shifts = useShifts();
const positions = usePositions();
const me = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === DEMO_VOLUNTEER_ID) ?? list[0];
}, [volunteers.data]);
const assignments = useAssignments(me ? { volunteerId: me.id } : undefined);
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
@@ -32,22 +36,39 @@ export function MobileVolunteerPage() {
[positions.data],
);
if (volunteers.isLoading || shifts.isLoading) return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error;
if (error) return <ErrorNote error={error} />;
if (!me) return null;
if (!volunteerId) {
return (
<div className="m-6 max-w-[560px] border-[3px] border-dashed border-ink bg-paper p-4 font-display text-[13px] font-bold">
{t('mobileVol.noAccount')}
</div>
);
}
if (assignments.isLoading) return <Loading />;
if (assignments.error) return <ErrorNote error={assignments.error} />;
const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? '';
// shifts / positions peuvent être refusés (403) à un bénévole côté back-end réel :
// on affiche alors les créneaux sans détail plutôt que de tomber en erreur.
const detailsUnavailable =
(shifts.error instanceof ApiError && shifts.error.status === 403) ||
(positions.error instanceof ApiError && positions.error.status === 403);
const slots = (assignments.data ?? [])
.filter(isActive)
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) }))
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } =>
Boolean(entry.shift),
)
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at));
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
.sort((a, b) => {
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
const bv = b.shift ? naiveMinutes(b.shift.starts_at) : 0;
return av - bv;
});
const totalHours = Math.round(
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 0) * 10,
) / 10;
const totalHours =
Math.round(
slots.reduce(
(sum, s) => sum + (s.shift ? hoursBetween(s.shift.starts_at, s.shift.ends_at) : 0),
0,
) * 10,
) / 10;
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
return (
@@ -56,15 +77,17 @@ export function MobileVolunteerPage() {
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
{t('mobileVol.greeting', { name: me.first_name.toUpperCase() })}
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
</div>
<div className="mt-3 flex gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })}
</div>
{!detailsUnavailable ? (
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })}
</div>
) : null}
</div>
</div>
@@ -75,22 +98,31 @@ export function MobileVolunteerPage() {
) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{detailsUnavailable ? (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
{t('mobileVol.detailsUnavailable')}
</div>
) : null}
{slots.map(({ assignment, shift }) => {
const position = positionsById.get(shift.position_id);
const position = shift ? positionsById.get(shift.position_id) : undefined;
return (
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">
{longDayLabel(shift.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
</div>
<div className="p-[11px]">
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{(position?.name ?? '').toUpperCase()}
{shift ? (
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">
{longDayLabel(shift.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
</div>
) : null}
<div className="p-[11px]">
{position ? (
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
) : null}
{position?.location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: position.location })}
@@ -112,6 +144,12 @@ export function MobileVolunteerPage() {
{t('mobileVol.confirm')}
</button>
)}
{updateAssignment.error instanceof ApiError &&
updateAssignment.variables?.id === assignment.id ? (
<div className="mt-2 border-2 border-ink bg-orange px-2 py-1 font-display text-[10px] font-bold">
{t(updateAssignment.error.code)}
</div>
) : null}
</div>
</div>
);
@@ -0,0 +1,96 @@
import { useState, type FormEvent } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { API_MODE, ApiError, DEMO_COORDINATOR_EMAIL, DEMO_COORDINATOR_PASSWORD } from '@/api';
import { useAuth } from '@/auth/auth-context';
import { homePath } from '@/auth/roles';
import { FieldLabel } from '@/components/ui';
import { AuthLayout } from './AuthLayout';
import { t } from '@/i18n';
export function AdminLoginPage() {
const { status, account, loginWithPassword } = useAuth();
const navigate = useNavigate();
const location = useLocation() as { state?: { from?: string } };
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
if (status === 'authed' && account) {
return <Navigate to={location.state?.from ?? homePath(account.role)} replace />;
}
async function submit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const me = await loginWithPassword(email.trim(), password);
navigate(location.state?.from ?? homePath(me.role), { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.code : 'error.generic');
} finally {
setBusy(false);
}
}
return (
<AuthLayout title={t('auth.admin.title')}>
<form onSubmit={submit} className="flex flex-col gap-3">
<label className="block">
<FieldLabel>{t('auth.email')}</FieldLabel>
<input
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
/>
</label>
<label className="block">
<FieldLabel>{t('auth.password')}</FieldLabel>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
/>
</label>
{error ? (
<div className="border-2 border-ink bg-orange px-3 py-2 font-display text-[11px] font-bold">
{t(error)}
</div>
) : null}
<button
type="submit"
disabled={busy}
className="mt-1 cursor-pointer border-[3px] border-ink bg-yellow p-3 font-display text-[13px] font-black uppercase tracking-wide shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{busy ? t('auth.signingIn') : t('auth.signIn')}
</button>
</form>
{API_MODE === 'mock' ? (
<div className="mt-4 border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
{t('auth.demoCredentials', {
email: DEMO_COORDINATOR_EMAIL,
password: DEMO_COORDINATOR_PASSWORD,
})}
</div>
) : null}
<div className="mt-4 border-t-2 border-ink pt-3 font-display text-[11px]">
{t('auth.notAdmin')}{' '}
<Link to="/connexion" className="font-extrabold underline">
{t('auth.magicLinkLink')}
</Link>
</div>
</AuthLayout>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import { API_MODE } from '@/api';
import { t } from '@/i18n';
export function AuthLayout({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-cream p-6">
<div className="w-full max-w-[420px]">
<div className="mb-4 flex items-baseline gap-2">
<div className="border-[3px] border-ink bg-yellow px-3 py-2 font-display text-[15px] font-black tracking-tight shadow-brutal-md">
{t('header.title')}
</div>
{API_MODE === 'mock' ? (
<span className="border-2 border-ink bg-blue px-2 py-1 font-display text-[9px] font-extrabold uppercase">
{t('header.mockMode')}
</span>
) : null}
</div>
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-4 py-3 font-display text-[13px] font-black uppercase tracking-widest text-cream">
{title}
</div>
<div className="p-5">{children}</div>
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import { ApiError, devMagicToken } from '@/api';
import { useAuth } from '@/auth/auth-context';
import { homePath } from '@/auth/roles';
import { FieldLabel } from '@/components/ui';
import { AuthLayout } from './AuthLayout';
import { t } from '@/i18n';
export function ConnexionPage() {
const { status, account, requestMagicLink, verifyMagicLink } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const tokenParam = params.get('token');
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [devLink, setDevLink] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const verifyStartedRef = useRef(false);
// Consommation d'un lien magique (?token=…).
useEffect(() => {
if (!tokenParam || verifyStartedRef.current) return;
verifyStartedRef.current = true;
setBusy(true);
verifyMagicLink(tokenParam)
.then((me) => navigate(homePath(me.role), { replace: true }))
.catch((err) => {
setError(err instanceof ApiError ? err.code : 'auth.invalid_token');
setBusy(false);
});
}, [tokenParam, verifyMagicLink, navigate]);
if (status === 'authed' && account && !tokenParam) {
return <Navigate to={homePath(account.role)} replace />;
}
if (tokenParam) {
return (
<AuthLayout title={t('auth.magic.verifyTitle')}>
{error ? (
<div className="flex flex-col gap-3">
<div className="border-2 border-ink bg-orange px-3 py-2 font-display text-[12px] font-bold">
{t(error)}
</div>
<Link
to="/connexion"
className="cursor-pointer border-[3px] border-ink bg-yellow p-3 text-center font-display text-[12px] font-black uppercase tracking-wide shadow-brutal-md hover:bg-green hover:text-white"
>
{t('auth.magic.retry')}
</Link>
</div>
) : (
<div className="flex items-center gap-3 font-mono text-[12px] uppercase tracking-widest opacity-70">
<span className="h-4 w-4 animate-spin border-2 border-ink border-t-transparent" />
{t('auth.magic.verifying')}
</div>
)}
</AuthLayout>
);
}
async function submit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await requestMagicLink(email.trim());
setSent(true);
const raw = devMagicToken(email.trim());
setDevLink(raw ? `/connexion?token=${encodeURIComponent(raw)}` : null);
} catch (err) {
setError(err instanceof ApiError ? err.code : 'error.generic');
} finally {
setBusy(false);
}
}
return (
<AuthLayout title={t('auth.magic.title')}>
{sent ? (
<div className="flex flex-col gap-3">
<div className="border-2 border-ink bg-green px-3 py-3 font-display text-[12px] font-bold text-white">
{t('auth.magic.sent')}
</div>
{devLink ? (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
{t('auth.magic.devHint')}
<Link to={devLink} className="mt-2 block break-all font-bold underline">
{devLink}
</Link>
</div>
) : null}
<button
type="button"
onClick={() => {
setSent(false);
setDevLink(null);
}}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[11px] font-extrabold uppercase hover:bg-yellow"
>
{t('auth.magic.another')}
</button>
</div>
) : (
<form onSubmit={submit} className="flex flex-col gap-3">
<p className="font-display text-[12px] leading-relaxed">{t('auth.magic.lead')}</p>
<label className="block">
<FieldLabel>{t('auth.email')}</FieldLabel>
<input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border-2 border-ink bg-cream p-[10px] font-mono text-[13px]"
/>
</label>
{error ? (
<div className="border-2 border-ink bg-orange px-3 py-2 font-display text-[11px] font-bold">
{t(error)}
</div>
) : null}
<button
type="submit"
disabled={busy}
className="mt-1 cursor-pointer border-[3px] border-ink bg-yellow p-3 font-display text-[13px] font-black uppercase tracking-wide shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{busy ? t('auth.magic.sending') : t('auth.magic.send')}
</button>
</form>
)}
<div className="mt-4 border-t-2 border-ink pt-3 font-display text-[11px]">
{t('auth.isAdmin')}{' '}
<Link to="/connexion/coordination" className="font-extrabold underline">
{t('auth.adminLink')}
</Link>
</div>
</AuthLayout>
);
}