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:
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