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:
@@ -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'));
|
||||
|
||||
1469
frontend/src/routes/admin/import/parents/+page.svelte
Normal file
1469
frontend/src/routes/admin/import/parents/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
1147
frontend/src/routes/admin/parent-invitations/+page.svelte
Normal file
1147
frontend/src/routes/admin/parent-invitations/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
586
frontend/src/routes/parent-activate/[code]/+page.svelte
Normal file
586
frontend/src/routes/parent-activate/[code]/+page.svelte
Normal 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">📚</span>
|
||||
<span class="logo-text">Classeo</span>
|
||||
</div>
|
||||
|
||||
{#if isActivated}
|
||||
<!-- Success state -->
|
||||
<div class="card">
|
||||
<div class="success-state">
|
||||
<div class="success-icon">✓</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>
|
||||
Reference in New Issue
Block a user