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:
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -35,3 +35,5 @@ dev-dist/
|
||||
# =============================================================================
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
# Generated test token for E2E tests
|
||||
e2e/.test-token
|
||||
|
||||
187
frontend/e2e/activation.spec.ts
Normal file
187
frontend/e2e/activation.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
52
frontend/e2e/global-setup.ts
Normal file
52
frontend/e2e/global-setup.ts
Normal 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;
|
||||
22
frontend/e2e/test-utils.ts
Normal file
22
frontend/e2e/test-utils.ts
Normal 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.'
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
41
frontend/src/lib/types/activation.ts
Normal file
41
frontend/src/lib/types/activation.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
649
frontend/src/routes/activate/[token]/+page.svelte
Normal file
649
frontend/src/routes/activate/[token]/+page.svelte
Normal 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>
|
||||
257
frontend/src/routes/login/+page.svelte
Normal file
257
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user