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

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>