feat: Activation de compte utilisateur avec validation token

L'inscription Classeo se fait via invitation : un admin crée un compte,
l'utilisateur reçoit un lien d'activation par email pour définir son
mot de passe. Ce flow sécurisé évite les inscriptions non autorisées
et garantit que seuls les utilisateurs légitimes accèdent au système.

Points clés de l'implémentation :
- Tokens d'activation à usage unique stockés en cache (Redis/filesystem)
- Validation du consentement parental pour les mineurs < 15 ans (RGPD)
- L'échec d'activation ne consume pas le token (retry possible)
- Users dans un cache séparé sans TTL (pas d'expiration)
- Hot reload en dev (FrankenPHP sans mode worker)

Story: 1.3 - Inscription et activation de compte
This commit is contained in:
2026-01-31 18:00:43 +01:00
parent 1fd256346a
commit c5e6c1d810
69 changed files with 5173 additions and 13 deletions

2
frontend/.gitignore vendored
View File

@@ -35,3 +35,5 @@ dev-dist/
# =============================================================================
*.local
*.tsbuildinfo
# Generated test token for E2E tests
e2e/.test-token

View File

@@ -0,0 +1,187 @@
import { test, expect } from '@playwright/test';
import { getTestToken } from './test-utils';
test.describe('Account Activation Flow', () => {
test.describe('Token Validation', () => {
test('displays error for invalid token', async ({ page }) => {
await page.goto('/activate/invalid-token-uuid-format');
// Wait for the error state
await expect(page.getByRole('heading', { name: /lien invalide/i })).toBeVisible();
await expect(page.getByText(/contacter votre établissement/i)).toBeVisible();
});
test('displays error for non-existent token', async ({ page }) => {
// Use a valid UUID format but non-existent token
await page.goto('/activate/00000000-0000-0000-0000-000000000000');
// Shows error because token doesn't exist
const heading = page.getByRole('heading', { name: /lien invalide/i });
await expect(heading).toBeVisible();
});
});
test.describe('Password Form', () => {
test('validates password requirements in real-time', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
// Wait for form to be visible (token must be valid)
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
// Test minimum length requirement - should NOT be valid yet
await passwordInput.fill('Abc1');
const minLengthItem = page.locator('.password-requirements li').filter({ hasText: /8 caractères/ });
await expect(minLengthItem).not.toHaveClass(/valid/);
// Test uppercase requirement - missing
await passwordInput.fill('abcd1234');
const uppercaseItem = page.locator('.password-requirements li').filter({ hasText: /majuscule/ });
await expect(uppercaseItem).not.toHaveClass(/valid/);
// Test digit requirement - missing
await passwordInput.fill('Abcdefgh');
const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ });
await expect(digitItem).not.toHaveClass(/valid/);
// Valid password should show all checkmarks
await passwordInput.fill('Abcdefgh1');
const validItems = page.locator('.password-requirements li.valid');
await expect(validItems).toHaveCount(3);
});
test('requires password confirmation to match', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
const confirmInput = page.locator('#passwordConfirmation');
await passwordInput.fill('SecurePass123');
await confirmInput.fill('DifferentPass123');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible();
// Fix confirmation
await confirmInput.fill('SecurePass123');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
});
test('submit button is disabled until form is valid', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const submitButton = page.getByRole('button', { name: /activer mon compte/i });
// Initially disabled
await expect(submitButton).toBeDisabled();
// Fill valid password
await page.locator('#password').fill('SecurePass123');
await page.locator('#passwordConfirmation').fill('SecurePass123');
// Should now be enabled
await expect(submitButton).toBeEnabled();
});
});
test.describe('Establishment Info Display', () => {
test('shows establishment name and role when token is valid', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
// School info should be visible
await expect(page.locator('.school-info')).toBeVisible();
await expect(page.locator('.school-name')).toBeVisible();
await expect(page.locator('.account-type')).toBeVisible();
});
});
test.describe('Password Visibility Toggle', () => {
test('toggles password visibility', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
const passwordInput = page.locator('#password');
const toggleButton = page.locator('.toggle-password');
// Initially password type
await expect(passwordInput).toHaveAttribute('type', 'password');
// Click toggle
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'text');
// Click again to hide
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'password');
});
});
test.describe('Full Activation Flow', () => {
test('activates account and redirects to login', async ({ page }) => {
const token = getTestToken();
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
// Fill valid password
await page.locator('#password').fill('SecurePass123');
await page.locator('#passwordConfirmation').fill('SecurePass123');
// Submit
await page.getByRole('button', { name: /activer mon compte/i }).click();
// Should redirect to login with success message
await expect(page).toHaveURL(/\/login\?activated=true/);
await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible();
});
});
});
test.describe('Login Page After Activation', () => {
test('shows success message when redirected after activation', async ({ page }) => {
await page.goto('/login?activated=true');
await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible();
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
});
test('does not show success message without query param', async ({ page }) => {
await page.goto('/login');
await expect(page.getByText(/compte a été activé avec succès/i)).not.toBeVisible();
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
});
});
test.describe('Parental Consent Flow (Minor User)', () => {
// These tests would require seeded data for a minor user
test.skip('shows consent required message for minor without consent', async () => {
// Would navigate to activation page for a minor user token
// and verify the consent required message is displayed
});
test.skip('allows activation after parental consent is given', async () => {
// Would verify the full flow:
// 1. Minor receives activation link
// 2. Parent gives consent
// 3. Minor can then activate their account
});
});

View File

@@ -0,0 +1,52 @@
import { execSync } from 'child_process';
import { writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Global setup for E2E tests.
* Seeds a test activation token before tests run.
*/
async function globalSetup() {
console.warn('🌱 Seeding test activation token...');
try {
// Call the backend command to create a test token
// Project root is 2 levels up from frontend/e2e/
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
const result = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-activation-token --email=e2e-test@example.com 2>&1`,
{
encoding: 'utf-8'
}
);
// Extract the token from the output
// Output format: "Token f9174245-9766-4ef1-b6e9-a6795aa2da04"
const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i);
if (!tokenMatch) {
console.error('❌ Could not extract token from output:', result);
throw new Error('Failed to extract token from command output');
}
const token = tokenMatch[1];
console.warn(`✅ Test token created: ${token}`);
// Write the token to a file for tests to use
const tokenFile = join(__dirname, '.test-token');
writeFileSync(tokenFile, token);
console.warn('✅ Token saved to .test-token file');
} catch (error) {
console.error('❌ Failed to seed test token:', error);
// Don't throw - tests can still run with skipped token-dependent tests
console.warn('⚠️ Tests requiring valid tokens will be skipped');
}
}
export default globalSetup;

View File

@@ -0,0 +1,22 @@
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Get the seeded test token.
* The token is created by global-setup.ts before tests run via Docker.
*/
export function getTestToken(): string {
const tokenFile = join(__dirname, '.test-token');
if (existsSync(tokenFile)) {
return readFileSync(tokenFile, 'utf-8').trim();
}
throw new Error(
'No .test-token file found. Make sure Docker is running and global-setup.ts executed successfully.'
);
}

View File

@@ -73,7 +73,10 @@ export default tseslint.config(
process: 'readonly',
Promise: 'readonly',
Set: 'readonly',
Map: 'readonly'
Map: 'readonly',
Event: 'readonly',
SubmitEvent: 'readonly',
fetch: 'readonly'
}
},
plugins: {

View File

@@ -1,15 +1,23 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const useExternalServer = !!process.env.PLAYWRIGHT_BASE_URL;
const config: PlaywrightTestConfig = {
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 4173,
reuseExistingServer: !process.env.CI
},
// Always run globalSetup to seed test tokens
// If backend is not running, tests requiring tokens will be skipped gracefully
globalSetup: './e2e/global-setup.ts',
webServer: useExternalServer
? undefined
: {
command: 'pnpm run build && pnpm run preview',
port: 4173,
reuseExistingServer: !process.env.CI
},
testDir: 'e2e',
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
use: {
baseURL: 'http://localhost:4173',
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'

View File

@@ -0,0 +1,41 @@
/**
* Types for account activation flow.
*/
export interface ActivationTokenInfo {
tokenValue: string;
email: string;
role: string;
schoolName: string;
isExpired: boolean;
expiresAt: string;
}
export interface ActivateAccountInput {
tokenValue: string;
password: string;
}
export interface ActivateAccountOutput {
userId: string;
email: string;
role: string;
message: string;
}
export type ActivationError =
| 'TOKEN_NOT_FOUND'
| 'TOKEN_EXPIRED'
| 'TOKEN_ALREADY_USED'
| 'VALIDATION_ERROR'
| 'NETWORK_ERROR';
export interface ActivationErrorResponse {
'@type': string;
title: string;
detail: string;
violations?: Array<{
propertyPath: string;
message: string;
}>;
}

View File

@@ -0,0 +1,649 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { createQuery, createMutation } from '@tanstack/svelte-query';
import { getApiBaseUrl } from '$lib/api/config';
import type {
ActivationTokenInfo,
ActivateAccountInput,
ActivationErrorResponse
} from '$lib/types/activation';
const token = $derived($page.params.token ?? '');
const apiBaseUrl = getApiBaseUrl();
// État du formulaire
let password = $state('');
let passwordConfirmation = $state('');
let showPassword = $state(false);
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
// Critères de validation du mot de passe
const hasMinLength = $derived(password.length >= 8);
const hasUppercase = $derived(/[A-Z]/.test(password));
const hasDigit = $derived(/[0-9]/.test(password));
const passwordsMatch = $derived(password === passwordConfirmation && password.length > 0);
const isPasswordValid = $derived(hasMinLength && hasUppercase && hasDigit && passwordsMatch);
// Query pour récupérer les infos du token
// svelte-ignore state_referenced_locally
// The token comes from URL params and won't change during component lifecycle
const tokenInfoQuery = createQuery<ActivationTokenInfo>({
queryKey: ['activationToken', token] as const,
queryFn: async () => {
const currentToken = $page.params.token ?? '';
const response = await globalThis.fetch(`${apiBaseUrl}/activation-tokens/${currentToken}`, {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
const errorData = (await response.json()) as ActivationErrorResponse;
throw new Error(errorData.detail || 'Token invalide');
}
return response.json() as Promise<ActivationTokenInfo>;
},
retry: false
});
// Mutation pour activer le compte
const activateMutation = createMutation({
mutationFn: async (data: ActivateAccountInput) => {
const response = await globalThis.fetch(`${apiBaseUrl}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
const errorData = (await response.json()) as ActivationErrorResponse;
throw errorData;
}
return response.json();
},
onSuccess: () => {
goto('/login?activated=true');
},
onError: (error: ActivationErrorResponse) => {
formError = '';
fieldErrors = {};
if (error.violations) {
for (const violation of error.violations) {
fieldErrors[violation.propertyPath] = violation.message;
}
} else {
formError = error.detail || "Une erreur est survenue lors de l'activation.";
}
}
});
function handleSubmit(event: Event) {
event.preventDefault();
if (!isPasswordValid) {
formError = 'Veuillez corriger les erreurs avant de continuer.';
return;
}
formError = '';
fieldErrors = {};
$activateMutation.mutate({
tokenValue: token,
password: password
});
}
</script>
<svelte:head>
<title>Activation de compte | Classeo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<div class="activation-page">
<div class="activation-container">
<!-- Logo -->
<div class="logo">
<span class="logo-icon">📚</span>
<span class="logo-text">Classeo</span>
</div>
{#if $tokenInfoQuery.isPending}
<!-- Loading state -->
<div class="card">
<div class="loading-state">
<div class="spinner"></div>
<p>Vérification du lien d'activation...</p>
</div>
</div>
{:else if $tokenInfoQuery.isError}
<!-- Error state -->
<div class="card">
<div class="error-state">
<div class="error-icon"></div>
<h2>Lien invalide</h2>
<p>Ce lien d'activation est invalide ou a expiré.</p>
<p class="hint">Veuillez contacter votre établissement pour obtenir un nouveau lien.</p>
</div>
</div>
{:else if $tokenInfoQuery.data}
{@const tokenInfo = $tokenInfoQuery.data}
{#if tokenInfo.isExpired}
<!-- Token expired -->
<div class="card">
<div class="error-state warning">
<div class="error-icon"></div>
<h2>Lien expiré</h2>
<p>Votre lien d'activation a expiré (validité : 7 jours).</p>
<p class="hint">
Veuillez contacter <strong>{tokenInfo.schoolName}</strong> pour obtenir un nouveau lien.
</p>
</div>
</div>
{:else}
<!-- Activation form -->
<div class="card">
<h1>Activation de votre compte</h1>
<!-- Info établissement -->
<div class="school-info">
<div class="school-icon">🏫</div>
<div class="school-details">
<span class="school-name">{tokenInfo.schoolName}</span>
<span class="account-type">{tokenInfo.role}</span>
</div>
</div>
<div class="email-badge">
{tokenInfo.email}
</div>
<form onsubmit={handleSubmit}>
{#if formError}
<div class="form-error">
<span class="error-badge">!</span>
{formError}
</div>
{/if}
<!-- Mot de passe -->
<div class="form-group">
<label for="password">Créer votre mot de passe</label>
<div class="input-wrapper">
<input
id="password"
type={showPassword ? 'text' : 'password'}
bind:value={password}
required
placeholder="Entrez votre mot de passe"
class:has-error={fieldErrors['password']}
/>
<button type="button" class="toggle-password" onclick={() => (showPassword = !showPassword)}>
{showPassword ? '🙈' : '👁'}
</button>
</div>
{#if fieldErrors['password']}
<span class="field-error">{fieldErrors['password']}</span>
{/if}
</div>
<!-- Critères de validation -->
<div class="password-requirements">
<span class="requirements-title">Votre mot de passe doit contenir :</span>
<ul>
<li class:valid={hasMinLength}>
<span class="check">{hasMinLength ? '✓' : '○'}</span>
Au moins 8 caractères
</li>
<li class:valid={hasUppercase}>
<span class="check">{hasUppercase ? '✓' : '○'}</span>
Au moins 1 majuscule
</li>
<li class:valid={hasDigit}>
<span class="check">{hasDigit ? '✓' : '○'}</span>
Au moins 1 chiffre
</li>
</ul>
</div>
<!-- Confirmation mot de passe -->
<div class="form-group">
<label for="passwordConfirmation">Confirmer le mot de passe</label>
<div class="input-wrapper">
<input
id="passwordConfirmation"
type={showPassword ? 'text' : 'password'}
bind:value={passwordConfirmation}
required
placeholder="Confirmez votre mot de passe"
class:has-error={passwordConfirmation.length > 0 && !passwordsMatch}
/>
</div>
{#if passwordConfirmation.length > 0 && !passwordsMatch}
<span class="field-error">Les mots de passe ne correspondent pas.</span>
{/if}
</div>
<!-- Bouton submit -->
<button type="submit" class="submit-button" disabled={!isPasswordValid || $activateMutation.isPending}>
{#if $activateMutation.isPending}
<span class="button-spinner"></span>
Activation en cours...
{:else}
Activer mon compte
{/if}
</button>
</form>
</div>
{/if}
{/if}
<!-- Footer -->
<p class="footer">Un problème ? Contactez votre établissement.</p>
</div>
</div>
<style>
/* Design Tokens - Calm Productivity */
:root {
--color-calm: hsl(142, 76%, 36%);
--color-attention: hsl(38, 92%, 50%);
--color-alert: hsl(0, 72%, 51%);
--surface-primary: hsl(210, 20%, 98%);
--surface-elevated: hsl(0, 0%, 100%);
--text-primary: hsl(222, 47%, 11%);
--text-secondary: hsl(215, 16%, 47%);
--text-muted: hsl(215, 13%, 65%);
--accent-primary: hsl(199, 89%, 48%);
--border-subtle: hsl(214, 32%, 91%);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
}
.activation-page {
min-height: 100vh;
background: var(--surface-primary);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
.activation-container {
width: 100%;
max-width: 420px;
}
/* Logo */
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 32px;
}
.logo-icon {
font-size: 32px;
}
.logo-text {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
/* Card */
.card {
background: var(--surface-elevated);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-elevated);
padding: 32px;
}
.card h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 24px;
text-align: center;
}
/* School Info */
.school-info {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: linear-gradient(135deg, hsl(199, 89%, 96%) 0%, hsl(199, 89%, 98%) 100%);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.school-icon {
font-size: 32px;
}
.school-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.school-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.account-type {
font-size: 14px;
color: var(--accent-primary);
font-weight: 500;
}
.email-badge {
display: inline-block;
width: 100%;
text-align: center;
padding: 10px 16px;
background: var(--surface-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
/* Form */
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.input-wrapper {
position: relative;
}
.input-wrapper input {
width: 100%;
padding: 12px 16px;
padding-right: 48px;
font-size: 15px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-wrapper input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px hsla(199, 89%, 48%, 0.15);
}
.input-wrapper input.has-error {
border-color: var(--color-alert);
}
.input-wrapper input::placeholder {
color: var(--text-muted);
}
.toggle-password {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 4px;
opacity: 0.6;
transition: opacity 0.2s;
}
.toggle-password:hover {
opacity: 1;
}
.field-error {
font-size: 13px;
color: var(--color-alert);
}
/* Password Requirements */
.password-requirements {
padding: 16px;
background: var(--surface-primary);
border-radius: var(--radius-sm);
}
.requirements-title {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
display: block;
margin-bottom: 12px;
}
.password-requirements ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.password-requirements li {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--text-muted);
transition: color 0.2s;
}
.password-requirements li.valid {
color: var(--color-calm);
}
.password-requirements li .check {
font-size: 14px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--border-subtle);
color: var(--text-muted);
font-weight: 600;
transition: background 0.2s, color 0.2s;
}
.password-requirements li.valid .check {
background: var(--color-calm);
color: white;
}
/* Submit Button */
.submit-button {
width: 100%;
padding: 14px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background: var(--accent-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.submit-button:hover:not(:disabled) {
background: hsl(199, 89%, 42%);
box-shadow: var(--shadow-card);
}
.submit-button:active:not(:disabled) {
transform: scale(0.98);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-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;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px;
}
.loading-state p {
color: var(--text-secondary);
font-size: 15px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-subtle);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error State */
.error-state {
text-align: center;
padding: 16px;
}
.error-state .error-icon {
width: 56px;
height: 56px;
margin: 0 auto 20px;
background: hsl(0, 72%, 95%);
color: var(--color-alert);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.error-state.warning .error-icon {
background: hsl(38, 92%, 95%);
color: var(--color-attention);
}
.error-state h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.error-state p {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.error-state .hint {
font-size: 14px;
color: var(--text-muted);
}
/* Form Error */
.form-error {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: hsl(0, 72%, 97%);
border: 1px solid hsl(0, 72%, 90%);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--color-alert);
}
.error-badge {
width: 20px;
height: 20px;
background: var(--color-alert);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
/* Footer */
.footer {
text-align: center;
font-size: 14px;
color: var(--text-muted);
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,257 @@
<script lang="ts">
import { page } from '$app/stores';
const justActivated = $derived($page.url.searchParams.get('activated') === 'true');
</script>
<svelte:head>
<title>Connexion | Classeo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<div class="login-page">
<div class="login-container">
<!-- Logo -->
<div class="logo">
<span class="logo-icon">📚</span>
<span class="logo-text">Classeo</span>
</div>
<div class="card">
{#if justActivated}
<div class="success-banner">
<span class="success-icon"></span>
<span>Votre compte a été activé avec succès !</span>
</div>
{/if}
<h1>Connexion</h1>
<form>
<div class="form-group">
<label for="email">Adresse email</label>
<div class="input-wrapper">
<input
id="email"
type="email"
required
placeholder="votre@email.com"
/>
</div>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<div class="input-wrapper">
<input
id="password"
type="password"
required
placeholder="Votre mot de passe"
/>
</div>
</div>
<button type="submit" class="submit-button">
Se connecter
</button>
</form>
<p class="help-text">
La connexion sera disponible prochainement.
</p>
</div>
<p class="footer">Un problème ? Contactez votre établissement.</p>
</div>
</div>
<style>
/* Design Tokens - Calm Productivity */
:root {
--color-calm: hsl(142, 76%, 36%);
--color-attention: hsl(38, 92%, 50%);
--color-alert: hsl(0, 72%, 51%);
--surface-primary: hsl(210, 20%, 98%);
--surface-elevated: hsl(0, 0%, 100%);
--text-primary: hsl(222, 47%, 11%);
--text-secondary: hsl(215, 16%, 47%);
--text-muted: hsl(215, 13%, 65%);
--accent-primary: hsl(199, 89%, 48%);
--border-subtle: hsl(214, 32%, 91%);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
}
.login-page {
min-height: 100vh;
background: var(--surface-primary);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
.login-container {
width: 100%;
max-width: 420px;
}
/* Logo */
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 32px;
}
.logo-icon {
font-size: 32px;
}
.logo-text {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
/* Card */
.card {
background: var(--surface-elevated);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-elevated);
padding: 32px;
}
.card h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 24px;
text-align: center;
}
/* Success Banner */
.success-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: linear-gradient(135deg, hsl(142, 76%, 95%) 0%, hsl(142, 76%, 97%) 100%);
border: 1px solid hsl(142, 76%, 85%);
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 14px;
font-weight: 500;
color: var(--color-calm);
}
.success-icon {
width: 24px;
height: 24px;
background: var(--color-calm);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
/* Form */
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.input-wrapper {
position: relative;
}
.input-wrapper input {
width: 100%;
padding: 12px 16px;
font-size: 15px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-wrapper input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px hsla(199, 89%, 48%, 0.15);
}
.input-wrapper input::placeholder {
color: var(--text-muted);
}
/* Submit Button */
.submit-button {
width: 100%;
padding: 14px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background: var(--accent-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.submit-button:hover {
background: hsl(199, 89%, 42%);
box-shadow: var(--shadow-card);
}
.submit-button:active {
transform: scale(0.98);
}
/* Help Text */
.help-text {
text-align: center;
font-size: 13px;
color: var(--text-muted);
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-subtle);
}
/* Footer */
.footer {
text-align: center;
font-size: 14px;
color: var(--text-muted);
margin-top: 24px;
}
</style>