feat: Connexion utilisateur avec sécurité renforcée
Implémente la Story 1.4 du système d'authentification avec plusieurs couches de protection contre les attaques par force brute. Sécurité backend : - Authentification JWT avec access token (15min) + refresh token (7j) - Rotation automatique des refresh tokens avec détection de replay - Rate limiting progressif par IP (délai Fibonacci après échecs) - Intégration Cloudflare Turnstile CAPTCHA après 5 tentatives - Alerte email à l'utilisateur après blocage temporaire - Isolation multi-tenant (un utilisateur ne peut se connecter que sur son établissement) Frontend : - Page de connexion avec feedback visuel des délais et erreurs - Composant TurnstileCaptcha réutilisable - Gestion d'état auth avec stockage sécurisé des tokens - Tests E2E Playwright pour login, tenant isolation, et activation Infrastructure : - Configuration Symfony Security avec json_login + jwt - Cache pools séparés (filesystem en test, Redis en prod) - NullLoginRateLimiter pour environnement de test (évite blocage CI) - Génération des clés JWT en CI après démarrage du backend
This commit is contained in:
273
frontend/src/lib/auth/auth.svelte.ts
Normal file
273
frontend/src/lib/auth/auth.svelte.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
/**
|
||||
* Service d'authentification côté client.
|
||||
*
|
||||
* Sécurité :
|
||||
* - Access token stocké en mémoire uniquement (pas localStorage - vulnérable XSS)
|
||||
* - Refresh token en cookie HttpOnly (géré côté serveur)
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
|
||||
/** Délai entre les tentatives de refresh lors de race conditions multi-onglets (ms) */
|
||||
const REFRESH_RACE_RETRY_DELAY_MS = 100;
|
||||
|
||||
// État réactif de l'authentification
|
||||
let accessToken = $state<string | null>(null);
|
||||
let isRefreshing = $state(false);
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
captcha_token?: string | undefined;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
success: boolean;
|
||||
error?: {
|
||||
type: 'invalid_credentials' | 'rate_limited' | 'captcha_required' | 'captcha_invalid' | 'unknown';
|
||||
message: string;
|
||||
retryAfter?: number | undefined;
|
||||
delay?: number | undefined;
|
||||
attempts?: number | undefined;
|
||||
captchaRequired?: boolean | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
retryAfter?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une tentative de connexion.
|
||||
*/
|
||||
export async function login(credentials: LoginCredentials): Promise<LoginResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
credentials: 'include', // Important pour recevoir le cookie refresh_token
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
accessToken = data.token;
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Erreur
|
||||
const error = await response.json() as ApiError & {
|
||||
attempts?: number;
|
||||
captchaRequired?: boolean;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
// IP bloquée (429)
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'rate_limited',
|
||||
message: error.detail,
|
||||
retryAfter: error.retryAfter,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// CAPTCHA requis (428)
|
||||
if (response.status === 428) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'captcha_required',
|
||||
message: error.detail,
|
||||
attempts: error.attempts,
|
||||
captchaRequired: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// CAPTCHA invalide (400)
|
||||
if (response.status === 400 && error.type === '/errors/captcha-invalid') {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'captcha_invalid',
|
||||
message: error.detail,
|
||||
captchaRequired: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Authentification échouée (401)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'invalid_credentials',
|
||||
message: error.detail || 'Identifiants incorrects',
|
||||
attempts: error.attempts,
|
||||
delay: error.delay,
|
||||
captchaRequired: error.captchaRequired,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[auth] Login error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'unknown',
|
||||
message: 'Erreur de connexion. Veuillez réessayer.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit le token JWT via le refresh token (cookie).
|
||||
*
|
||||
* Gère le cas de race condition multi-onglets (409 Conflict) :
|
||||
* Si deux onglets tentent de rafraîchir simultanément, le second recevra
|
||||
* un 409 car le token a déjà été rotaté. Dans ce cas, on attend un court
|
||||
* instant et on réessaie car le cookie contient maintenant le nouveau token.
|
||||
*/
|
||||
export async function refreshToken(retryCount = 0): Promise<boolean> {
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
// Déjà en cours de refresh, attendre
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isRefreshing) {
|
||||
clearInterval(interval);
|
||||
resolve(accessToken !== null);
|
||||
}
|
||||
}, REFRESH_RACE_RETRY_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
if (retryCount === 0) {
|
||||
isRefreshing = true;
|
||||
}
|
||||
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/token/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
accessToken = data.token;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 409 Conflict = token déjà rotaté (race condition multi-onglets)
|
||||
// Attendre un court instant et réessayer avec le nouveau cookie
|
||||
if (response.status === 409 && retryCount < 2) {
|
||||
await new Promise((resolve) => setTimeout(resolve, REFRESH_RACE_RETRY_DELAY_MS));
|
||||
return refreshToken(retryCount + 1);
|
||||
}
|
||||
|
||||
// Refresh échoué - token expiré ou replay détecté
|
||||
accessToken = null;
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[auth] Refresh token error:', error);
|
||||
accessToken = null;
|
||||
return false;
|
||||
} finally {
|
||||
if (retryCount === 0) {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête authentifiée.
|
||||
* Rafraîchit automatiquement le token si nécessaire.
|
||||
*/
|
||||
export async function authenticatedFetch(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
// Si pas de token, essayer de rafraîchir
|
||||
if (!accessToken) {
|
||||
const refreshed = await refreshToken();
|
||||
if (!refreshed) {
|
||||
// Rediriger vers login
|
||||
goto('/login');
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter le token à la requête
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Si 401, essayer de rafraîchir et rejouer
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
return fetch(url, { ...options, headers, credentials: 'include' });
|
||||
}
|
||||
|
||||
// Refresh échoué, rediriger vers login
|
||||
goto('/login');
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Déconnexion.
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
try {
|
||||
await fetch(`${apiUrl}/token/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (error) {
|
||||
// Log mais ne pas bloquer la déconnexion locale
|
||||
console.warn('[auth] Logout API error (continuing with local logout):', error);
|
||||
}
|
||||
|
||||
accessToken = null;
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur est authentifié.
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return accessToken !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le token actuel (pour debug uniquement).
|
||||
*/
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
10
frontend/src/lib/auth/index.ts
Normal file
10
frontend/src/lib/auth/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
authenticatedFetch,
|
||||
isAuthenticated,
|
||||
getAccessToken,
|
||||
type LoginCredentials,
|
||||
type LoginResult,
|
||||
} from './auth.svelte';
|
||||
151
frontend/src/lib/components/TurnstileCaptcha.svelte
Normal file
151
frontend/src/lib/components/TurnstileCaptcha.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile CAPTCHA Component
|
||||
*
|
||||
* @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
|
||||
* @see Story 1.4 - T8: CAPTCHA anti-bot
|
||||
*/
|
||||
|
||||
// Site Key from environment (PUBLIC_TURNSTILE_SITE_KEY)
|
||||
// Fallback to Cloudflare's official "always passes" test key for development
|
||||
// @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
const TURNSTILE_TEST_KEY = '1x00000000000000000000AA';
|
||||
const SITE_KEY = env['PUBLIC_TURNSTILE_SITE_KEY'] || TURNSTILE_TEST_KEY;
|
||||
|
||||
// Warn in console if using test key (helps catch missing config)
|
||||
if (SITE_KEY === TURNSTILE_TEST_KEY) {
|
||||
console.warn(
|
||||
'[TurnstileCaptcha] Using Cloudflare test key. Set PUBLIC_TURNSTILE_SITE_KEY for production.'
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSuccess?: (token: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
onExpired?: () => void;
|
||||
}
|
||||
|
||||
let { onSuccess, onError, onExpired }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let widgetId: string | null = $state(null);
|
||||
let isLoaded = $state(false);
|
||||
|
||||
// Load the Turnstile script
|
||||
function loadScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.turnstile) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load Turnstile script'));
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Render the widget
|
||||
async function renderWidget() {
|
||||
try {
|
||||
await loadScript();
|
||||
|
||||
if (!container || widgetId !== null) return;
|
||||
|
||||
widgetId = window.turnstile.render(container, {
|
||||
sitekey: SITE_KEY,
|
||||
callback: (token: string) => {
|
||||
onSuccess?.(token);
|
||||
},
|
||||
'error-callback': (error: string) => {
|
||||
onError?.(error || 'Vérification échouée');
|
||||
},
|
||||
'expired-callback': () => {
|
||||
onExpired?.();
|
||||
},
|
||||
theme: 'light',
|
||||
language: 'fr'
|
||||
});
|
||||
|
||||
isLoaded = true;
|
||||
} catch (error) {
|
||||
console.error('Turnstile render error:', error);
|
||||
onError?.('Impossible de charger la vérification de sécurité');
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the widget (for retry)
|
||||
export function reset() {
|
||||
if (widgetId !== null && window.turnstile) {
|
||||
window.turnstile.reset(widgetId);
|
||||
}
|
||||
}
|
||||
|
||||
// Mount effect
|
||||
$effect(() => {
|
||||
if (container) {
|
||||
renderWidget();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (widgetId !== null && window.turnstile) {
|
||||
window.turnstile.remove(widgetId);
|
||||
widgetId = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="turnstile-container">
|
||||
<div bind:this={container} class="turnstile-widget"></div>
|
||||
{#if !isLoaded}
|
||||
<div class="turnstile-loading">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Chargement de la vérification...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.turnstile-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.turnstile-widget {
|
||||
min-height: 65px;
|
||||
}
|
||||
|
||||
.turnstile-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
frontend/src/lib/types/turnstile.d.ts
vendored
Normal file
40
frontend/src/lib/types/turnstile.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Cloudflare Turnstile type declarations
|
||||
*
|
||||
* @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
|
||||
*/
|
||||
|
||||
interface TurnstileRenderOptions {
|
||||
sitekey: string;
|
||||
callback?: (token: string) => void;
|
||||
'error-callback'?: (error: string) => void;
|
||||
'expired-callback'?: () => void;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
language?: string;
|
||||
action?: string;
|
||||
cData?: string;
|
||||
tabindex?: number;
|
||||
'response-field'?: boolean;
|
||||
'response-field-name'?: string;
|
||||
size?: 'normal' | 'compact';
|
||||
retry?: 'auto' | 'never';
|
||||
'retry-interval'?: number;
|
||||
'refresh-expired'?: 'auto' | 'manual' | 'never';
|
||||
appearance?: 'always' | 'execute' | 'interaction-only';
|
||||
}
|
||||
|
||||
interface Turnstile {
|
||||
render: (container: HTMLElement | string, options: TurnstileRenderOptions) => string;
|
||||
reset: (widgetId: string) => void;
|
||||
remove: (widgetId: string) => void;
|
||||
getResponse: (widgetId: string) => string | undefined;
|
||||
isExpired: (widgetId: string) => boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile: Turnstile;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,7 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { login, type LoginResult } from '$lib/auth';
|
||||
import TurnstileCaptcha from '$lib/components/TurnstileCaptcha.svelte';
|
||||
|
||||
const justActivated = $derived($page.url.searchParams.get('activated') === 'true');
|
||||
|
||||
// Form state
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let captchaToken = $state<string | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state<{ type: string; message: string } | null>(null);
|
||||
|
||||
// Rate limit / delay state
|
||||
let isRateLimited = $state(false);
|
||||
let isDelayed = $state(false);
|
||||
let retryAfterSeconds = $state(0);
|
||||
let delaySeconds = $state(0);
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// CAPTCHA state
|
||||
let showCaptcha = $state(false);
|
||||
let captchaComponent: TurnstileCaptcha | undefined = $state();
|
||||
|
||||
function startCountdown(seconds: number, isBlock: boolean = true) {
|
||||
if (isBlock) {
|
||||
retryAfterSeconds = seconds;
|
||||
isRateLimited = true;
|
||||
} else {
|
||||
delaySeconds = seconds;
|
||||
isDelayed = true;
|
||||
}
|
||||
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
if (isBlock) {
|
||||
retryAfterSeconds--;
|
||||
if (retryAfterSeconds <= 0) {
|
||||
isRateLimited = false;
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delaySeconds--;
|
||||
if (delaySeconds <= 0) {
|
||||
isDelayed = false;
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function handleCaptchaSuccess(token: string) {
|
||||
captchaToken = token;
|
||||
}
|
||||
|
||||
function handleCaptchaError(errorMsg: string) {
|
||||
error = {
|
||||
type: 'captcha_error',
|
||||
message: errorMsg
|
||||
};
|
||||
}
|
||||
|
||||
function handleCaptchaExpired() {
|
||||
captchaToken = null;
|
||||
error = {
|
||||
type: 'captcha_expired',
|
||||
message: 'La vérification a expiré. Veuillez réessayer.'
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isRateLimited || isDelayed || isSubmitting) return;
|
||||
|
||||
// Si CAPTCHA requis mais pas encore résolu
|
||||
if (showCaptcha && !captchaToken) {
|
||||
error = {
|
||||
type: 'captcha_required',
|
||||
message: 'Veuillez compléter la vérification de sécurité.'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
error = null;
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result: LoginResult = await login({
|
||||
email,
|
||||
password,
|
||||
captcha_token: captchaToken ?? undefined
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Rediriger vers le dashboard
|
||||
goto('/');
|
||||
} else if (result.error) {
|
||||
// Gérer les différents types d'erreur
|
||||
switch (result.error.type) {
|
||||
case 'rate_limited':
|
||||
if (result.error.retryAfter) {
|
||||
startCountdown(result.error.retryAfter);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'captcha_required':
|
||||
showCaptcha = true;
|
||||
break;
|
||||
|
||||
case 'captcha_invalid':
|
||||
// Réinitialiser le CAPTCHA pour réessayer
|
||||
captchaToken = null;
|
||||
captchaComponent?.reset();
|
||||
break;
|
||||
|
||||
case 'invalid_credentials':
|
||||
// Afficher CAPTCHA si requis pour la prochaine tentative
|
||||
if (result.error.captchaRequired) {
|
||||
showCaptcha = true;
|
||||
}
|
||||
// Réinitialiser le token si CAPTCHA déjà affiché
|
||||
if (showCaptcha) {
|
||||
captchaToken = null;
|
||||
captchaComponent?.reset();
|
||||
}
|
||||
// Appliquer le délai Fibonacci si présent
|
||||
if (result.error.delay && result.error.delay > 0) {
|
||||
startCountdown(result.error.delay, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
error = {
|
||||
type: result.error.type,
|
||||
message: result.error.message
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -32,7 +187,24 @@
|
||||
|
||||
<h1>Connexion</h1>
|
||||
|
||||
<form>
|
||||
{#if error}
|
||||
<div class="error-banner" class:rate-limited={isRateLimited}>
|
||||
{#if isRateLimited}
|
||||
<span class="error-icon">🔒</span>
|
||||
<div class="error-content">
|
||||
<span class="error-message">{error.message}</span>
|
||||
<span class="countdown">
|
||||
Réessayez dans <strong>{formatCountdown(retryAfterSeconds)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="error-icon">⚠</span>
|
||||
<span class="error-message">{error.message}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse email</label>
|
||||
<div class="input-wrapper">
|
||||
@@ -41,6 +213,9 @@
|
||||
type="email"
|
||||
required
|
||||
placeholder="votre@email.com"
|
||||
bind:value={email}
|
||||
disabled={isSubmitting || isRateLimited || isDelayed}
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,18 +228,46 @@
|
||||
type="password"
|
||||
required
|
||||
placeholder="Votre mot de passe"
|
||||
bind:value={password}
|
||||
disabled={isSubmitting || isRateLimited || isDelayed}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button">
|
||||
Se connecter
|
||||
{#if showCaptcha}
|
||||
<div class="captcha-section">
|
||||
<p class="captcha-label">Vérification de sécurité</p>
|
||||
<TurnstileCaptcha
|
||||
bind:this={captchaComponent}
|
||||
onSuccess={handleCaptchaSuccess}
|
||||
onError={handleCaptchaError}
|
||||
onExpired={handleCaptchaExpired}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
disabled={isSubmitting || isRateLimited || isDelayed || !email || !password || (showCaptcha && !captchaToken)}
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<span class="spinner"></span>
|
||||
Connexion en cours...
|
||||
{:else if isRateLimited}
|
||||
Compte bloqué ({formatCountdown(retryAfterSeconds)})
|
||||
{:else if isDelayed}
|
||||
Patientez {delaySeconds}s...
|
||||
{:else}
|
||||
Se connecter
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="help-text">
|
||||
La connexion sera disponible prochainement.
|
||||
</p>
|
||||
<div class="links">
|
||||
<a href="/mot-de-passe-oublie" class="forgot-password">Mot de passe oublié ?</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footer">Un problème ? Contactez votre établissement.</p>
|
||||
@@ -170,6 +373,50 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, hsl(0, 76%, 95%) 0%, hsl(0, 76%, 97%) 100%);
|
||||
border: 1px solid hsl(0, 76%, 85%);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--color-alert);
|
||||
}
|
||||
|
||||
.error-banner.rate-limited {
|
||||
background: linear-gradient(135deg, hsl(38, 92%, 95%) 0%, hsl(38, 92%, 97%) 100%);
|
||||
border-color: hsl(38, 92%, 75%);
|
||||
color: hsl(38, 92%, 30%);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.countdown strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
form {
|
||||
display: flex;
|
||||
@@ -201,7 +448,9 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
@@ -214,6 +463,30 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-wrapper input:disabled {
|
||||
background: var(--surface-primary);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* CAPTCHA Section */
|
||||
.captcha-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: var(--surface-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.captcha-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Submit Button */
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
@@ -225,28 +498,64 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.1s,
|
||||
box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: hsl(199, 89%, 42%);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.submit-button:active {
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Help Text */
|
||||
.help-text {
|
||||
.submit-button:disabled {
|
||||
background: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.links {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
font-size: 14px;
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user