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).
This commit is contained in:
2026-02-28 00:08:56 +01:00
parent de5880e25e
commit be1b0b60a6
68 changed files with 8787 additions and 1 deletions

View File

@@ -31,6 +31,11 @@
<span class="action-label">Gérer les utilisateurs</span>
<span class="action-hint">Inviter et gérer</span>
</a>
<a class="action-card" href="/admin/parent-invitations">
<span class="action-icon">✉️</span>
<span class="action-label">Invitations parents</span>
<span class="action-hint">Codes d'invitation</span>
</a>
<a class="action-card" href="/admin/classes">
<span class="action-icon">🏫</span>
<span class="action-label">Configurer les classes</span>

View File

@@ -0,0 +1,104 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
// === Types ===
export interface AnalyzeResult {
columns: string[];
rows: Record<string, string>[];
totalRows: number;
filename: string;
suggestedMapping: Record<string, string>;
}
export interface ValidatedRow {
studentName: string;
email1: string;
email2: string;
studentId: string | null;
studentMatch: string | null;
error: string | null;
}
export interface ValidateResult {
validatedRows: ValidatedRow[];
validCount: number;
errorCount: number;
}
export interface BulkResult {
created: number;
errors: { line: number; email?: string; error: string }[];
total: number;
}
// === API Functions ===
/**
* Upload et analyse un fichier CSV ou XLSX pour l'import d'invitations parents.
*/
export async function analyzeFile(file: File): Promise<AnalyzeResult> {
const apiUrl = getApiBaseUrl();
const formData = new FormData();
formData.append('file', file);
const response = await authenticatedFetch(`${apiUrl}/import/parents/analyze`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? "Erreur lors de l'analyse du fichier"
);
}
return await response.json();
}
/**
* Valide les lignes mappées contre les élèves existants.
*/
export async function validateRows(
rows: { studentName: string; email1: string; email2: string }[]
): Promise<ValidateResult> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/import/parents/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows })
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors de la validation'
);
}
return await response.json();
}
/**
* Envoie les invitations en masse via l'endpoint bulk existant.
*/
export async function sendBulkInvitations(
invitations: { studentId: string; parentEmail: string }[]
): Promise<BulkResult> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/parent-invitations/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitations })
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? "Erreur lors de l'envoi"
);
}
return await response.json();
}

View File

@@ -25,6 +25,7 @@
const navLinks = [
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
{ href: '/admin/parent-invitations', label: 'Invitations parents', isActive: () => isParentInvitationsActive },
{ href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive },
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
@@ -82,6 +83,7 @@
// Determine which admin section is active
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
const isParentInvitationsActive = $derived(page.url.pathname.startsWith('/admin/parent-invitations'));
const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students'));
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,586 @@
<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>