feat: Réinitialisation de mot de passe avec tokens sécurisés
Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5): Backend: - Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique - Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP) - Endpoint POST /api/password/reset avec validation token - Templates email (demande + confirmation) - Repository Redis avec TTL 2h pour distinguer expiré/invalide Frontend: - Page /mot-de-passe-oublie avec message générique (anti-énumération) - Page /reset-password/[token] avec validation temps réel des critères - Gestion erreurs: token invalide, expiré, déjà utilisé Tests: - 14 tests unitaires PasswordResetToken - 7 tests unitaires RequestPasswordResetHandler - 7 tests unitaires ResetPasswordHandler - Tests E2E Playwright pour le flux complet
This commit is contained in:
@@ -90,10 +90,12 @@ test.describe('Account Activation Flow', () => {
|
||||
const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ });
|
||||
await expect(digitItem).not.toHaveClass(/valid/);
|
||||
|
||||
// Valid password should show all checkmarks
|
||||
// Valid password (without special char) should show 4/5 checkmarks
|
||||
// Requirements: minLength, uppercase, lowercase, digit, specialChar
|
||||
// "Abcdefgh1" satisfies: minLength(9>=8), uppercase(A), lowercase(bcdefgh), digit(1)
|
||||
await passwordInput.fill('Abcdefgh1');
|
||||
const validItems = page.locator('.password-requirements li.valid');
|
||||
await expect(validItems).toHaveCount(3);
|
||||
await expect(validItems).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('requires password confirmation to match', async ({ page }) => {
|
||||
@@ -106,13 +108,13 @@ test.describe('Account Activation Flow', () => {
|
||||
const passwordInput = page.locator('#password');
|
||||
const confirmInput = page.locator('#passwordConfirmation');
|
||||
|
||||
await passwordInput.fill('SecurePass123');
|
||||
await confirmInput.fill('DifferentPass123');
|
||||
await passwordInput.fill('SecurePass123!');
|
||||
await confirmInput.fill('DifferentPass123!');
|
||||
|
||||
await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible();
|
||||
|
||||
// Fix confirmation
|
||||
await confirmInput.fill('SecurePass123');
|
||||
await confirmInput.fill('SecurePass123!');
|
||||
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -128,9 +130,9 @@ test.describe('Account Activation Flow', () => {
|
||||
// Initially disabled
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill valid password
|
||||
await page.locator('#password').fill('SecurePass123');
|
||||
await page.locator('#passwordConfirmation').fill('SecurePass123');
|
||||
// Fill valid password (must include special char)
|
||||
await page.locator('#password').fill('SecurePass123!');
|
||||
await page.locator('#passwordConfirmation').fill('SecurePass123!');
|
||||
|
||||
// Should now be enabled
|
||||
await expect(submitButton).toBeEnabled();
|
||||
@@ -194,9 +196,9 @@ test.describe('Account Activation Flow', () => {
|
||||
// Button should be disabled initially (no password yet)
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill valid password
|
||||
await page.locator('#password').fill('SecurePass123');
|
||||
await page.locator('#passwordConfirmation').fill('SecurePass123');
|
||||
// Fill valid password (must include special char)
|
||||
await page.locator('#password').fill('SecurePass123!');
|
||||
await page.locator('#passwordConfirmation').fill('SecurePass123!');
|
||||
|
||||
// Wait for validation to complete - button should now be enabled
|
||||
await expect(submitButton).toBeEnabled({ timeout: 2000 });
|
||||
|
||||
271
frontend/e2e/password-reset.spec.ts
Normal file
271
frontend/e2e/password-reset.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Helper to create a password reset token via CLI command
|
||||
*/
|
||||
function createResetToken(options: { email: string; expired?: boolean }): string | null {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
try {
|
||||
const expiredFlag = options.expired ? ' --expired' : '';
|
||||
// Use APP_ENV=test to ensure Redis cache is used (same as the web server in CI)
|
||||
const result = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T -e APP_ENV=test php php bin/console app:dev:create-test-password-reset-token --email=${options.email}${expiredFlag} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i);
|
||||
if (tokenMatch) {
|
||||
return tokenMatch[1];
|
||||
}
|
||||
console.error('Could not extract token from output:', result);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to create reset token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Password Reset Flow', () => {
|
||||
test.describe('Forgot Password Page (/mot-de-passe-oublie)', () => {
|
||||
test('displays the forgot password form', async ({ page }) => {
|
||||
await page.goto('/mot-de-passe-oublie');
|
||||
|
||||
// Page should have the heading and form elements
|
||||
await expect(page.getByRole('heading', { name: /mot de passe oublié/i })).toBeVisible();
|
||||
await expect(page.locator('input#email')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /envoyer le lien de réinitialisation/i })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows success message after submitting email', async ({ page }) => {
|
||||
await page.goto('/mot-de-passe-oublie');
|
||||
|
||||
const emailInput = page.locator('input#email');
|
||||
await emailInput.fill('test-forgot@example.com');
|
||||
|
||||
await page.getByRole('button', { name: /envoyer le lien de réinitialisation/i }).click();
|
||||
|
||||
// Should show success message (always, to prevent enumeration)
|
||||
await expect(page.getByText(/vérifiez votre boîte mail/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('has link to login page', async ({ page }) => {
|
||||
await page.goto('/mot-de-passe-oublie');
|
||||
|
||||
const loginLink = page.getByRole('link', { name: /retour à la connexion/i });
|
||||
await expect(loginLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Reset Password Page (/reset-password/[token])', () => {
|
||||
test.describe('Password Form Display', () => {
|
||||
test('displays password form', async ({ page }, testInfo) => {
|
||||
// Create a fresh token for this test
|
||||
const email = `e2e-form-display-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
// Form should be visible
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('#password')).toBeVisible();
|
||||
await expect(page.locator('#confirmPassword')).toBeVisible();
|
||||
});
|
||||
|
||||
test('validates password requirements in real-time', async ({ page }, testInfo) => {
|
||||
const email = `e2e-validation-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const passwordInput = page.locator('#password');
|
||||
|
||||
// Test short password - should not pass length requirement
|
||||
await passwordInput.fill('Abc1!');
|
||||
const minLengthItem = page
|
||||
.locator('.requirements li')
|
||||
.filter({ hasText: /8 caractères/ });
|
||||
await expect(minLengthItem).not.toHaveClass(/valid/);
|
||||
|
||||
// Test missing uppercase
|
||||
await passwordInput.fill('abcdefgh1!');
|
||||
const uppercaseItem = page
|
||||
.locator('.requirements li')
|
||||
.filter({ hasText: /majuscule/ });
|
||||
await expect(uppercaseItem).not.toHaveClass(/valid/);
|
||||
|
||||
// Valid password should show all checkmarks (5 requirements)
|
||||
await passwordInput.fill('Abcdefgh1!');
|
||||
const validItems = page.locator('.requirements li.valid');
|
||||
await expect(validItems).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('validates password confirmation matches', async ({ page }, testInfo) => {
|
||||
const email = `e2e-confirm-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const passwordInput = page.locator('#password');
|
||||
const confirmInput = page.locator('#confirmPassword');
|
||||
|
||||
await passwordInput.fill('SecurePass123!');
|
||||
await confirmInput.fill('DifferentPass123!');
|
||||
|
||||
// Should show mismatch error
|
||||
await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible();
|
||||
|
||||
// Fix confirmation
|
||||
await confirmInput.fill('SecurePass123!');
|
||||
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('submit button is disabled until form is valid', async ({ page }, testInfo) => {
|
||||
const email = `e2e-button-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /réinitialiser/i });
|
||||
|
||||
// Initially disabled
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill valid password
|
||||
await page.locator('#password').fill('NewSecurePass123!');
|
||||
await page.locator('#confirmPassword').fill('NewSecurePass123!');
|
||||
|
||||
// Should now be enabled
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Token Validation', () => {
|
||||
test('shows error for invalid token after form submission', async ({ page }) => {
|
||||
// Use an invalid token format
|
||||
await page.goto('/reset-password/00000000-0000-0000-0000-000000000000');
|
||||
|
||||
// Form should still be visible (validation happens on submit)
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill valid password and submit
|
||||
await page.locator('#password').fill('ValidPassword123!');
|
||||
await page.locator('#confirmPassword').fill('ValidPassword123!');
|
||||
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Should show error after submission
|
||||
await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
|
||||
test('shows expiration message for expired token after form submission', async ({
|
||||
page
|
||||
}, testInfo) => {
|
||||
const email = `e2e-expired-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email, expired: true });
|
||||
test.skip(!token, 'Could not create expired test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill valid password and submit
|
||||
await page.locator('#password').fill('ValidPassword123!');
|
||||
await page.locator('#confirmPassword').fill('ValidPassword123!');
|
||||
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Should show expired/used error (shows "Lien invalide" heading with expiry message)
|
||||
await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Complete Reset Flow', () => {
|
||||
test('successfully resets password and redirects to login', async ({ page }, testInfo) => {
|
||||
const email = `e2e-success-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill form
|
||||
await page.locator('#password').fill('NewSecurePass123!');
|
||||
await page.locator('#confirmPassword').fill('NewSecurePass123!');
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /réinitialiser/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
// Submit
|
||||
await submitButton.click();
|
||||
|
||||
// Should show success message (heading "Mot de passe modifié")
|
||||
await expect(page.getByRole('heading', { name: 'Mot de passe modifié' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
|
||||
test('token cannot be reused after successful reset', async ({ page }, testInfo) => {
|
||||
const email = `e2e-reuse-${testInfo.project.name}-${Date.now()}@example.com`;
|
||||
const token = createResetToken({ email });
|
||||
test.skip(!token, 'Could not create test token');
|
||||
|
||||
// First reset
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.locator('#password').fill('FirstPassword123!');
|
||||
await page.locator('#confirmPassword').fill('FirstPassword123!');
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Wait for success (heading "Mot de passe modifié")
|
||||
await expect(page.getByRole('heading', { name: 'Mot de passe modifié' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// Try to use same token again
|
||||
await page.goto(`/reset-password/${token}`);
|
||||
await expect(page.locator('form')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.locator('#password').fill('SecondPassword123!');
|
||||
await page.locator('#confirmPassword').fill('SecondPassword123!');
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
|
||||
// Should show error (already used) - page shows "Lien invalide" heading
|
||||
await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,9 +22,13 @@
|
||||
// Critères de validation du mot de passe
|
||||
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 && hasDigit && passwordsMatch);
|
||||
const isPasswordValid = $derived(
|
||||
hasMinLength && hasUppercase && hasLowercase && hasDigit && hasSpecial && passwordsMatch
|
||||
);
|
||||
|
||||
// Query pour récupérer les infos du token
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -209,11 +213,19 @@
|
||||
</li>
|
||||
<li class:valid={hasUppercase}>
|
||||
<span class="check">{hasUppercase ? '✓' : '○'}</span>
|
||||
Au moins 1 majuscule
|
||||
Une majuscule
|
||||
</li>
|
||||
<li class:valid={hasLowercase}>
|
||||
<span class="check">{hasLowercase ? '✓' : '○'}</span>
|
||||
Une minuscule
|
||||
</li>
|
||||
<li class:valid={hasDigit}>
|
||||
<span class="check">{hasDigit ? '✓' : '○'}</span>
|
||||
Au moins 1 chiffre
|
||||
Un chiffre
|
||||
</li>
|
||||
<li class:valid={hasSpecial}>
|
||||
<span class="check">{hasSpecial ? '✓' : '○'}</span>
|
||||
Un caractère spécial
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
416
frontend/src/routes/mot-de-passe-oublie/+page.svelte
Normal file
416
frontend/src/routes/mot-de-passe-oublie/+page.svelte
Normal file
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
|
||||
// Form state
|
||||
let email = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let isSubmitted = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSubmitting) return;
|
||||
|
||||
error = null;
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseUrl()}/password/forgot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email: email.trim().toLowerCase() })
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
error = 'Trop de demandes. Veuillez patienter avant de réessayer.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Always show success message (no email enumeration)
|
||||
isSubmitted = true;
|
||||
} catch {
|
||||
error = 'Une erreur est survenue. Veuillez réessayer.';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mot de passe oublié | 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="page">
|
||||
<div class="container">
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<span class="logo-icon">📚</span>
|
||||
<span class="logo-text">Classeo</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{#if isSubmitted}
|
||||
<!-- Success State -->
|
||||
<div class="success-state">
|
||||
<div class="success-icon">
|
||||
<span>✉</span>
|
||||
</div>
|
||||
<h1>Vérifiez votre boîte mail</h1>
|
||||
<p>
|
||||
Si un compte existe avec l'adresse <strong>{email}</strong>, vous recevrez
|
||||
un email avec les instructions pour réinitialiser votre mot de passe.
|
||||
</p>
|
||||
<p class="hint">
|
||||
Le lien sera valide pendant <strong>1 heure</strong>.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="/login" class="link-button">Retour à la connexion</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Form State -->
|
||||
<h1>Mot de passe oublié</h1>
|
||||
<p class="description">
|
||||
Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser
|
||||
votre mot de passe.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">
|
||||
<span class="error-icon">⚠</span>
|
||||
<span class="error-message">{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse email</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="votre@email.com"
|
||||
bind:value={email}
|
||||
disabled={isSubmitting}
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button" disabled={isSubmitting || !email}>
|
||||
{#if isSubmitting}
|
||||
<span class="spinner"></span>
|
||||
Envoi en cours...
|
||||
{:else}
|
||||
Envoyer le lien de réinitialisation
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="links">
|
||||
<a href="/login" class="back-link">← Retour à la connexion</a>
|
||||
</div>
|
||||
{/if}
|
||||
</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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Success State */
|
||||
.success-state {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-state .success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, hsl(142, 76%, 95%) 0%, hsl(142, 76%, 90%) 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.success-state h1 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-state p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-state .hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 12px;
|
||||
background: var(--surface-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: hsl(199, 89%, 42%);
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, hsl(0, 76%, 95%) 0%, hsl(0, 76%, 97%) 100%);
|
||||
border: 1px solid hsl(0, 76%, 85%);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--color-alert);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.input-wrapper input:disabled {
|
||||
background: var(--surface-primary);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
background: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.links {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 14px;
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
596
frontend/src/routes/reset-password/[token]/+page.svelte
Normal file
596
frontend/src/routes/reset-password/[token]/+page.svelte
Normal file
@@ -0,0 +1,596 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
|
||||
const token = $derived($page.params.token);
|
||||
|
||||
// Form state
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let isSuccess = $state(false);
|
||||
let error = $state<{ type: string; message: string } | null>(null);
|
||||
|
||||
// Password visibility
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
|
||||
// Password validation
|
||||
const passwordRequirements = $derived({
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: /[^A-Za-z0-9]/.test(password)
|
||||
});
|
||||
|
||||
const isPasswordValid = $derived(
|
||||
passwordRequirements.length &&
|
||||
passwordRequirements.uppercase &&
|
||||
passwordRequirements.lowercase &&
|
||||
passwordRequirements.number &&
|
||||
passwordRequirements.special
|
||||
);
|
||||
|
||||
const passwordsMatch = $derived(password === confirmPassword && password.length > 0);
|
||||
const canSubmit = $derived(isPasswordValid && passwordsMatch && !isSubmitting);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSubmit) return;
|
||||
|
||||
error = null;
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseUrl()}/password/reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
isSuccess = true;
|
||||
// Redirect to login after 3 seconds
|
||||
globalThis.setTimeout(() => {
|
||||
goto('/login?password_reset=true');
|
||||
}, 3000);
|
||||
} else if (response.status === 400) {
|
||||
error = {
|
||||
type: 'invalid_token',
|
||||
message: 'Ce lien de réinitialisation est invalide.'
|
||||
};
|
||||
} else if (response.status === 410) {
|
||||
const data = await response.json();
|
||||
error = {
|
||||
type: 'expired_token',
|
||||
message: data.detail || 'Ce lien a expiré ou a déjà été utilisé.'
|
||||
};
|
||||
} else if (response.status === 422) {
|
||||
const data = await response.json();
|
||||
error = {
|
||||
type: 'validation_error',
|
||||
message: data.detail || 'Le mot de passe ne respecte pas les critères de sécurité.'
|
||||
};
|
||||
} else {
|
||||
error = {
|
||||
type: 'server_error',
|
||||
message: 'Une erreur est survenue. Veuillez réessayer.'
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
error = {
|
||||
type: 'network_error',
|
||||
message: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
|
||||
};
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Nouveau mot de passe | 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="page">
|
||||
<div class="container">
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<span class="logo-icon">📚</span>
|
||||
<span class="logo-text">Classeo</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{#if isSuccess}
|
||||
<!-- Success State -->
|
||||
<div class="success-state">
|
||||
<div class="success-icon">
|
||||
<span>✓</span>
|
||||
</div>
|
||||
<h1>Mot de passe modifié</h1>
|
||||
<p>
|
||||
Votre mot de passe a été réinitialisé avec succès.<br />
|
||||
Vous allez être redirigé vers la page de connexion...
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="/login" class="link-button">Se connecter maintenant</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error?.type === 'invalid_token' || error?.type === 'expired_token'}
|
||||
<!-- Token Error State -->
|
||||
<div class="error-state">
|
||||
<div class="error-state-icon">
|
||||
<span>⚠</span>
|
||||
</div>
|
||||
<h1>Lien invalide</h1>
|
||||
<p>{error.message}</p>
|
||||
<p class="hint">
|
||||
Vous pouvez faire une nouvelle demande de réinitialisation.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="/mot-de-passe-oublie" class="link-button">Nouvelle demande</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Form State -->
|
||||
<h1>Nouveau mot de passe</h1>
|
||||
<p class="description">Choisissez un nouveau mot de passe sécurisé pour votre compte.</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">
|
||||
<span class="error-icon-inline">⚠</span>
|
||||
<span class="error-message">{error.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="password">Nouveau mot de passe</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
placeholder="Votre nouveau mot de passe"
|
||||
bind:value={password}
|
||||
disabled={isSubmitting}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-visibility"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
>
|
||||
{showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Password requirements -->
|
||||
<ul class="requirements">
|
||||
<li class:valid={passwordRequirements.length}>Au moins 8 caractères</li>
|
||||
<li class:valid={passwordRequirements.uppercase}>Une majuscule</li>
|
||||
<li class:valid={passwordRequirements.lowercase}>Une minuscule</li>
|
||||
<li class:valid={passwordRequirements.number}>Un chiffre</li>
|
||||
<li class:valid={passwordRequirements.special}>Un caractère spécial</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirmer le mot de passe</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
required
|
||||
placeholder="Confirmer votre mot de passe"
|
||||
bind:value={confirmPassword}
|
||||
disabled={isSubmitting}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-visibility"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
aria-label={showConfirmPassword ? 'Masquer' : 'Afficher'}
|
||||
>
|
||||
{showConfirmPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
{#if confirmPassword && !passwordsMatch}
|
||||
<p class="field-error">Les mots de passe ne correspondent pas</p>
|
||||
{/if}
|
||||
{#if passwordsMatch && confirmPassword}
|
||||
<p class="field-success">Les mots de passe correspondent</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button" disabled={!canSubmit}>
|
||||
{#if isSubmitting}
|
||||
<span class="spinner"></span>
|
||||
Modification en cours...
|
||||
{:else}
|
||||
Réinitialiser le mot de passe
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Success State */
|
||||
.success-state {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-state .success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, hsl(142, 76%, 95%) 0%, hsl(142, 76%, 90%) 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
font-size: 28px;
|
||||
color: var(--color-calm);
|
||||
}
|
||||
|
||||
.success-state h1 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-state p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-state-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, hsl(38, 92%, 95%) 0%, hsl(38, 92%, 90%) 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.error-state h1 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-state .hint {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: hsl(199, 89%, 42%);
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, hsl(0, 76%, 95%) 0%, hsl(0, 76%, 97%) 100%);
|
||||
border: 1px solid hsl(0, 76%, 85%);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--color-alert);
|
||||
}
|
||||
|
||||
.error-icon-inline {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 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 48px 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);
|
||||
}
|
||||
|
||||
.input-wrapper input:disabled {
|
||||
background: var(--surface-primary);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-visibility {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.toggle-visibility:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Password requirements */
|
||||
.requirements {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.requirements li {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding-left: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.requirements li::before {
|
||||
content: '○';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.requirements li.valid {
|
||||
color: var(--color-calm);
|
||||
}
|
||||
|
||||
.requirements li.valid::before {
|
||||
content: '✓';
|
||||
color: var(--color-calm);
|
||||
}
|
||||
|
||||
/* Field messages */
|
||||
.field-error {
|
||||
font-size: 12px;
|
||||
color: var(--color-alert);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.field-success {
|
||||
font-size: 12px;
|
||||
color: var(--color-calm);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
margin-top: 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 {
|
||||
background: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user