Files
Classeo/frontend/src/routes/parent-activate/[code]/+page.svelte
Mathias STRASSER be1b0b60a6 feat: Permettre la génération et l'envoi de codes d'invitation aux parents
Les administrateurs ont besoin d'un moyen simple pour inviter les parents
à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes
d'invitation uniques (8 caractères alphanumériques) avec une validité de
48h, de les envoyer par email, et de les activer via une page publique
dédiée qui crée automatiquement le compte parent.

L'interface d'administration offre l'envoi unitaire et en masse, le renvoi,
le filtrage par statut, ainsi que la visualisation de l'état de chaque
invitation (en attente, activée, expirée).
2026-02-28 16:37:10 +01:00

587 lines
13 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { getApiBaseUrl } from '$lib/api/config';
const code = $derived(page.params.code ?? '');
const apiBaseUrl = getApiBaseUrl();
// Form state
let firstName = $state('');
let lastName = $state('');
let password = $state('');
let passwordConfirmation = $state('');
let showPassword = $state(false);
let formError = $state('');
let isSubmitting = $state(false);
let isActivated = $state(false);
// Password validation
const hasMinLength = $derived(password.length >= 8);
const hasUppercase = $derived(/[A-Z]/.test(password));
const hasLowercase = $derived(/[a-z]/.test(password));
const hasDigit = $derived(/[0-9]/.test(password));
const hasSpecial = $derived(/[^A-Za-z0-9]/.test(password));
const passwordsMatch = $derived(password === passwordConfirmation && password.length > 0);
const isPasswordValid = $derived(
hasMinLength && hasUppercase && hasLowercase && hasDigit && hasSpecial && passwordsMatch
);
const isFormValid = $derived(
firstName.trim().length >= 2 && lastName.trim().length >= 2 && isPasswordValid
);
async function handleSubmit(event: Event) {
event.preventDefault();
if (!isFormValid) {
formError = 'Veuillez corriger les erreurs avant de continuer.';
return;
}
formError = '';
isSubmitting = true;
try {
const response = await globalThis.fetch(`${apiBaseUrl}/parent-invitations/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
code,
firstName: firstName.trim(),
lastName: lastName.trim(),
password
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const detail =
errorData['hydra:description'] ??
errorData.detail ??
errorData.message;
if (response.status === 404) {
formError = 'Ce lien d\'activation est invalide.';
} else if (response.status === 410) {
formError = 'Ce lien d\'activation a expiré. Contactez votre établissement.';
} else if (response.status === 409) {
formError = 'Cette invitation a déjà été activée.';
} else {
formError = detail ?? 'Une erreur est survenue lors de l\'activation.';
}
return;
}
isActivated = true;
} catch {
formError = 'Erreur de connexion. Veuillez réessayer.';
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Activation compte parent | Classeo</title>
</svelte:head>
<div class="activation-page">
<div class="activation-container">
<!-- Logo -->
<div class="logo">
<span class="logo-icon">&#x1F4DA;</span>
<span class="logo-text">Classeo</span>
</div>
{#if isActivated}
<!-- Success state -->
<div class="card">
<div class="success-state">
<div class="success-icon">&#x2713;</div>
<h2>Compte activé !</h2>
<p>Votre compte parent a été créé avec succès.</p>
<p class="hint">Vous pouvez maintenant vous connecter pour acceder aux informations de votre enfant.</p>
<button class="submit-button" onclick={() => goto('/login')}>
Se connecter
</button>
</div>
</div>
{:else}
<!-- Activation form -->
<div class="card">
<h1>Activation de votre compte parent</h1>
<p class="intro-text">
Vous avez été invité à rejoindre Classeo pour suivre la scolarité de votre enfant.
Complétez les informations ci-dessous pour créer votre compte.
</p>
<form onsubmit={handleSubmit}>
{#if formError}
<div class="form-error">
<span class="error-badge">!</span>
{formError}
</div>
{/if}
<!-- Name fields -->
<div class="form-row">
<div class="form-group">
<label for="firstName">Prénom *</label>
<input
id="firstName"
type="text"
bind:value={firstName}
required
minlength="2"
maxlength="100"
placeholder="Votre prénom"
/>
</div>
<div class="form-group">
<label for="lastName">Nom *</label>
<input
id="lastName"
type="text"
bind:value={lastName}
required
minlength="2"
maxlength="100"
placeholder="Votre nom"
/>
</div>
</div>
<!-- Password -->
<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"
/>
<button
type="button"
class="toggle-password"
onclick={() => (showPassword = !showPassword)}
>
{showPassword ? '🙈' : '👁'}
</button>
</div>
</div>
<!-- Password requirements -->
<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>
Une majuscule
</li>
<li class:valid={hasLowercase}>
<span class="check">{hasLowercase ? '✓' : '○'}</span>
Une minuscule
</li>
<li class:valid={hasDigit}>
<span class="check">{hasDigit ? '✓' : '○'}</span>
Un chiffre
</li>
<li class:valid={hasSpecial}>
<span class="check">{hasSpecial ? '✓' : '○'}</span>
Un caractère spécial
</li>
</ul>
</div>
<!-- Confirm password -->
<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>
<!-- Submit -->
<button type="submit" class="submit-button" disabled={!isFormValid || isSubmitting}>
{#if isSubmitting}
<span class="button-spinner"></span>
Activation en cours...
{:else}
Activer mon compte
{/if}
</button>
</form>
</div>
{/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: ui-sans-serif, system-ui, sans-serif;
}
.activation-container {
width: 100%;
max-width: 480px;
}
/* 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: 16px;
text-align: center;
}
.intro-text {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
margin-bottom: 24px;
line-height: 1.5;
}
/* Form */
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.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,
.form-group 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 {
padding-right: 48px;
}
.input-wrapper input:focus,
.form-group 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,
.form-group 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;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Success state */
.success-state {
text-align: center;
padding: 16px;
}
.success-icon {
width: 56px;
height: 56px;
margin: 0 auto 20px;
background: hsl(142, 76%, 95%);
color: var(--color-calm);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.success-state h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.success-state p {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.success-state .hint {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 24px;
}
/* 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;
}
@media (max-width: 480px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>