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:
394
frontend/e2e/parent-invitation-import.spec.ts
Normal file
394
frontend/e2e/parent-invitation-import.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const ADMIN_EMAIL = 'e2e-parent-import-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'ParentImportTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
const UNIQUE_SUFFIX = Date.now().toString().slice(-8);
|
||||
|
||||
// Student IDs — deterministic UUIDs for cleanup
|
||||
const STUDENT1_ID = `e2e00001-0000-4000-8000-${UNIQUE_SUFFIX}0001`;
|
||||
const STUDENT2_ID = `e2e00001-0000-4000-8000-${UNIQUE_SUFFIX}0002`;
|
||||
|
||||
// Unique student names to avoid collision with existing data
|
||||
const STUDENT1_FIRST = `Alice${UNIQUE_SUFFIX}`;
|
||||
const STUDENT1_LAST = `Dupont${UNIQUE_SUFFIX}`;
|
||||
const STUDENT2_FIRST = `Bob${UNIQUE_SUFFIX}`;
|
||||
const STUDENT2_LAST = `Martin${UNIQUE_SUFFIX}`;
|
||||
|
||||
function runCommand(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
function createCsvFixture(filename: string, content: string): string {
|
||||
const tmpDir = join(__dirname, 'fixtures');
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const filePath = join(tmpDir, filename);
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
test.describe('Parent Invitation Import via CSV', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create 2 students with unique names for matching
|
||||
// Note: \\" produces \" in the string, which the shell interprets as literal " inside double quotes
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${STUDENT1_ID}', '${TENANT_ID}', NULL, '${STUDENT1_FIRST}', '${STUDENT1_LAST}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch { /* may already exist */ }
|
||||
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${STUDENT2_ID}', '${TENANT_ID}', NULL, '${STUDENT2_FIRST}', '${STUDENT2_LAST}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch { /* may already exist */ }
|
||||
|
||||
// Clear user cache to ensure students are visible
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('displays the import wizard page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /import d'invitations parents/i })).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Verify stepper is visible with 4 steps
|
||||
await expect(page.locator('.stepper .step')).toHaveCount(4);
|
||||
|
||||
// Verify dropzone is visible
|
||||
await expect(page.locator('.dropzone')).toBeVisible();
|
||||
await expect(page.getByText(/glissez votre fichier/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('uploads a CSV file and shows mapping step', async ({ page }) => {
|
||||
const csvContent = `Nom élève;Email parent 1;Email parent 2\n${STUDENT1_LAST} ${STUDENT1_FIRST};parent1@test.fr;parent2@test.fr\n`;
|
||||
const csvPath = createCsvFixture('e2e-parent-import-test.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
// Should transition to mapping step
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// File info should be visible
|
||||
await expect(page.getByText(/e2e-parent-import-test\.csv/i)).toBeVisible();
|
||||
await expect(page.getByText(/1 lignes/i)).toBeVisible();
|
||||
|
||||
// Column names should appear in mapping
|
||||
await expect(page.locator('.column-name').filter({ hasText: /^Nom élève$/ })).toBeVisible();
|
||||
await expect(page.locator('.column-name').filter({ hasText: /^Email parent 1$/ })).toBeVisible();
|
||||
await expect(page.locator('.column-name').filter({ hasText: /^Email parent 2$/ })).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('validates required fields in mapping', async ({ page }) => {
|
||||
const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};alice@test.fr\n`;
|
||||
const csvPath = createCsvFixture('e2e-parent-import-required.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// The mapping should be auto-suggested, so the "Valider le mapping" button should be enabled
|
||||
const validateButton = page.getByRole('button', { name: /valider le mapping/i });
|
||||
await expect(validateButton).toBeVisible();
|
||||
await expect(validateButton).toBeEnabled();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('navigates back from mapping to upload', async ({ page }) => {
|
||||
const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};alice@test.fr\n`;
|
||||
const csvPath = createCsvFixture('e2e-parent-import-back.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click back button
|
||||
await page.getByRole('button', { name: /retour/i }).click();
|
||||
|
||||
// Should be back on upload step
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('rejects non-CSV files', async ({ page }) => {
|
||||
const pdfPath = createCsvFixture('e2e-parent-import-bad.pdf', 'not a csv file');
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
|
||||
// Should show error
|
||||
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try { unlinkSync(pdfPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('shows preview step with valid/error counts', async ({ page }) => {
|
||||
// Use only one row with a clearly non-existent student to verify error display
|
||||
const csvContent =
|
||||
'Nom élève;Email parent 1\nZzznotfound99 Xxxxnomatch88;parent.err@test.fr\n';
|
||||
const csvPath = createCsvFixture('e2e-parent-import-preview.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(csvPath);
|
||||
|
||||
// Wait for mapping step
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Submit mapping
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Wait for preview step
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should show 0 valid and 1 error
|
||||
await expect(page.locator('.summary-card.valid')).toBeVisible();
|
||||
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0');
|
||||
await expect(page.locator('.summary-card.error')).toBeVisible();
|
||||
await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1');
|
||||
|
||||
// Error detail should mention the unknown student
|
||||
await expect(page.locator('.error-detail').first()).toContainText(/non trouvé/i);
|
||||
|
||||
// Send button should be disabled (no valid rows)
|
||||
await expect(page.getByRole('button', { name: /envoyer/i })).toBeDisabled();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P0] completes full import flow', async ({ page }) => {
|
||||
const email1 = `parent.import.${UNIQUE_SUFFIX}@test.fr`;
|
||||
const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};${email1}\n`;
|
||||
const csvPath = createCsvFixture('e2e-parent-import-full-flow.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
// Step 1: Upload
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Step 2: Mapping
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Step 3: Preview
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('1');
|
||||
await page.getByRole('button', { name: /envoyer 1 invitation/i }).click();
|
||||
|
||||
// Step 4: Result
|
||||
await expect(page.getByRole('heading', { name: /invitations envoyées/i })).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Verify report stats
|
||||
const stats = page.locator('.report-stats .stat');
|
||||
const sentStat = stats.filter({ hasText: /envoyées/ });
|
||||
await expect(sentStat.locator('.stat-value')).toHaveText('1');
|
||||
const errorStat = stats.filter({ hasText: /erreurs/ });
|
||||
await expect(errorStat.locator('.stat-value')).toHaveText('0');
|
||||
|
||||
// Verify action buttons
|
||||
await expect(page.getByRole('button', { name: /voir les invitations/i })).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup: remove the created invitation
|
||||
try {
|
||||
runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT1_ID}' AND parent_email = '${email1}'`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] handles multiple emails per student', async ({ page }) => {
|
||||
const email1 = `parent1.multi.${UNIQUE_SUFFIX}@test.fr`;
|
||||
const email2 = `parent2.multi.${UNIQUE_SUFFIX}@test.fr`;
|
||||
const csvContent = `Nom élève;Email parent 1;Email parent 2\n${STUDENT2_LAST} ${STUDENT2_FIRST};${email1};${email2}\n`;
|
||||
const csvPath = createCsvFixture('e2e-parent-import-multi-email.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
// Upload
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Mapping
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Preview — should show 1 valid row
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('1');
|
||||
|
||||
// Send — 2 invitations (one per email)
|
||||
await page.getByRole('button', { name: /envoyer 1 invitation/i }).click();
|
||||
|
||||
// Result
|
||||
await expect(page.locator('.report-stats')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Should have created 2 invitations (email1 + email2)
|
||||
const stats = page.locator('.report-stats .stat');
|
||||
const sentStat = stats.filter({ hasText: /envoyées/ });
|
||||
await expect(sentStat.locator('.stat-value')).toHaveText('2');
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT2_ID}' AND parent_email IN ('${email1}', '${email2}')`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] shows invalid email errors in preview', async ({ page }) => {
|
||||
const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};not-an-email\n`;
|
||||
const csvPath = createCsvFixture('e2e-parent-import-invalid-email.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Mapping
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
|
||||
// Preview — should show 0 valid, 1 error
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0');
|
||||
await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1');
|
||||
|
||||
// Error detail should mention invalid email
|
||||
await expect(page.locator('.error-detail').first()).toContainText(/invalide/i);
|
||||
|
||||
// Send button should be disabled (0 valid rows)
|
||||
await expect(page.getByRole('button', { name: /envoyer/i })).toBeDisabled();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => {
|
||||
const csvContent = [
|
||||
'Nom élève;Email parent 1',
|
||||
'Eleve Un;parent1@test.fr',
|
||||
'Eleve Deux;parent2@test.fr',
|
||||
'Eleve Trois;parent3@test.fr',
|
||||
'Eleve Quatre;parent4@test.fr',
|
||||
'Eleve Cinq;parent5@test.fr',
|
||||
'Eleve Six;parent6@test.fr',
|
||||
'Eleve Sept;parent7@test.fr'
|
||||
].join('\n') + '\n';
|
||||
const csvPath = createCsvFixture('e2e-parent-import-preview-5.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/parents`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
|
||||
// Wait for mapping step
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify preview section exists
|
||||
await expect(page.locator('.preview-section')).toBeVisible();
|
||||
|
||||
// Verify exactly 5 rows in the preview table (not 7)
|
||||
await expect(page.locator('.preview-table tbody tr')).toHaveCount(5);
|
||||
|
||||
// Verify total row count in file info
|
||||
await expect(page.getByText(/7 lignes/i)).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up test students
|
||||
try {
|
||||
runCommand(`DELETE FROM parent_invitations WHERE student_id IN ('${STUDENT1_ID}', '${STUDENT2_ID}')`);
|
||||
runCommand(`DELETE FROM users WHERE id IN ('${STUDENT1_ID}', '${STUDENT2_ID}')`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clear cache
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
386
frontend/e2e/parent-invitations.spec.ts
Normal file
386
frontend/e2e/parent-invitations.spec.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
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);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const ADMIN_EMAIL = 'e2e-parent-inv-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'ParentInvTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
const UNIQUE_SUFFIX = Date.now().toString().slice(-8);
|
||||
const STUDENT_ID = `e2e00002-0000-4000-8000-${UNIQUE_SUFFIX}0001`;
|
||||
const PARENT_EMAIL = `e2e-parent-inv-${UNIQUE_SUFFIX}@test.fr`;
|
||||
|
||||
function runCommand(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
test.describe('Parent Invitations', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create a student with known name for invite tests
|
||||
// Note: \\" produces \" in the string, which the shell interprets as literal " inside double quotes
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${STUDENT_ID}', '${TENANT_ID}', NULL, 'Camille', 'Testinv', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch { /* may already exist */ }
|
||||
|
||||
// Clean up invitations from previous runs
|
||||
try {
|
||||
runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clear user cache
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('admin can navigate to parent invitations page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
// Page should load (empty state or table)
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Title should be visible
|
||||
await expect(page.getByRole('heading', { name: /invitations parents/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin sees empty state or data table', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
// Wait for page to load — either empty state or data table
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify whichever state is shown has correct content
|
||||
const emptyState = page.locator('.empty-state');
|
||||
const dataTable = page.locator('.data-table');
|
||||
const isEmptyStateVisible = await emptyState.isVisible();
|
||||
|
||||
if (isEmptyStateVisible) {
|
||||
await expect(emptyState.getByText(/aucune invitation/i)).toBeVisible();
|
||||
} else {
|
||||
await expect(dataTable).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('admin can open the invite modal', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click "Inviter les parents" button
|
||||
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
||||
|
||||
// Modal should appear
|
||||
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
||||
await expect(page.locator('#invite-modal-title')).toHaveText('Inviter les parents');
|
||||
|
||||
// Form fields should be visible
|
||||
await expect(page.locator('#invite-student')).toBeVisible();
|
||||
await expect(page.locator('#invite-email1')).toBeVisible();
|
||||
await expect(page.locator('#invite-email2')).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin can close the invite modal with Escape', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open modal
|
||||
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
||||
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
||||
|
||||
// Close with Escape
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#invite-modal-title')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('send invitation requires student and email', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open modal
|
||||
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
||||
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
||||
|
||||
// Submit button should be disabled when empty
|
||||
const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i });
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[P0] admin can create an invitation via modal', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open modal
|
||||
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
||||
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
||||
|
||||
// Wait for students to load in select (more than just the default empty option)
|
||||
await expect(page.locator('#invite-student option')).not.toHaveCount(1, { timeout: 10000 });
|
||||
|
||||
// Select the first available student (not the placeholder)
|
||||
const firstStudentOption = page.locator('#invite-student option:not([value=""])').first();
|
||||
await expect(firstStudentOption).toBeAttached({ timeout: 10000 });
|
||||
const studentValue = await firstStudentOption.getAttribute('value');
|
||||
await page.locator('#invite-student').selectOption(studentValue!);
|
||||
|
||||
// Fill parent email
|
||||
await page.locator('#invite-email1').fill(PARENT_EMAIL);
|
||||
|
||||
// Submit button should be enabled
|
||||
const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i });
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
// Submit
|
||||
await submitBtn.click();
|
||||
|
||||
// Modal should close and success message should appear
|
||||
await expect(page.locator('#invite-modal-title')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/invitation.*envoyée/i);
|
||||
});
|
||||
|
||||
test('[P0] invitation appears in the table after creation', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
// Wait for table to load (should no longer be empty state)
|
||||
await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The invitation should appear with the parent email
|
||||
await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible();
|
||||
|
||||
// Student name should appear (any student name in the row)
|
||||
const invitationRow = page.locator('tr').filter({ hasText: PARENT_EMAIL });
|
||||
await expect(invitationRow).toBeVisible();
|
||||
|
||||
// Status should be "Envoyée"
|
||||
await expect(page.locator('.data-table .status-badge').first()).toContainText(/envoyée/i);
|
||||
});
|
||||
|
||||
test('[P1] admin can resend an invitation', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find the row with our invitation and click "Renvoyer"
|
||||
const row = page.locator('tr').filter({ hasText: PARENT_EMAIL });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('button', { name: /renvoyer/i }).click();
|
||||
|
||||
// Success message should appear
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/renvoyée/i);
|
||||
});
|
||||
|
||||
test('admin can navigate to file import page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click "Importer un fichier" link
|
||||
await page.getByRole('link', { name: /importer un fichier/i }).click();
|
||||
|
||||
// Should navigate to the import wizard page
|
||||
await expect(page).toHaveURL(/\/admin\/import\/parents/);
|
||||
await expect(page.getByRole('heading', { name: /import d'invitations parents/i })).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
});
|
||||
|
||||
test('filter by status changes the URL', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select a status filter
|
||||
await page.locator('#filter-status').selectOption('sent');
|
||||
await page.getByRole('button', { name: /filtrer/i }).click();
|
||||
|
||||
// URL should have status param
|
||||
await expect(page).toHaveURL(/status=sent/);
|
||||
});
|
||||
|
||||
test('reset filters clears URL params', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations?status=sent`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click reset (exact match to avoid ambiguity with "Réinitialiser les filtres" in empty state)
|
||||
await page.getByRole('button', { name: 'Réinitialiser', exact: true }).click();
|
||||
|
||||
// URL should no longer contain status param
|
||||
await expect(page).not.toHaveURL(/status=/);
|
||||
});
|
||||
|
||||
test('[P1] filter by sent status shows the created invitation', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
||||
|
||||
await expect(
|
||||
page.locator('.data-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Filter by "sent" status
|
||||
await page.locator('#filter-status').selectOption('sent');
|
||||
await page.getByRole('button', { name: /filtrer/i }).click();
|
||||
|
||||
// Our invitation should still be visible
|
||||
await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up invitations (by student or by email) and student
|
||||
try {
|
||||
runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`);
|
||||
runCommand(`DELETE FROM users WHERE id = '${STUDENT_ID}'`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clear cache
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Parent Activation Page', () => {
|
||||
test('displays form for parent activation page', async ({ page }) => {
|
||||
// Navigate to the parent activation page with a dummy code
|
||||
await page.goto('/parent-activate/test-code-that-does-not-exist');
|
||||
|
||||
// Page should load
|
||||
await expect(page.getByRole('heading', { name: /activation.*parent/i })).toBeVisible();
|
||||
|
||||
// Form fields should be visible
|
||||
await expect(page.locator('#firstName')).toBeVisible();
|
||||
await expect(page.locator('#lastName')).toBeVisible();
|
||||
await expect(page.locator('#password')).toBeVisible();
|
||||
await expect(page.locator('#passwordConfirmation')).toBeVisible();
|
||||
});
|
||||
|
||||
test('validates password requirements in real-time', async ({ page }) => {
|
||||
await page.goto('/parent-activate/test-code');
|
||||
|
||||
await expect(page.locator('#password')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type a weak password
|
||||
await page.locator('#password').fill('abc');
|
||||
|
||||
// Check that requirements are shown
|
||||
const requirements = page.locator('.password-requirements');
|
||||
await expect(requirements).toBeVisible();
|
||||
|
||||
// Min length should NOT be valid
|
||||
const minLengthItem = requirements.locator('li').filter({ hasText: /8 caractères/ });
|
||||
await expect(minLengthItem).not.toHaveClass(/valid/);
|
||||
|
||||
// Type a strong password
|
||||
await page.locator('#password').fill('StrongP@ss1');
|
||||
|
||||
// All requirements should be valid
|
||||
const allItems = requirements.locator('li.valid');
|
||||
await expect(allItems).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('validates password confirmation match', async ({ page }) => {
|
||||
await page.goto('/parent-activate/test-code');
|
||||
|
||||
await expect(page.locator('#password')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.locator('#password').fill('StrongP@ss1');
|
||||
await page.locator('#passwordConfirmation').fill('DifferentPass');
|
||||
|
||||
// Error should show
|
||||
await expect(page.getByText(/ne correspondent pas/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('submit button is disabled until form is valid', async ({ page }) => {
|
||||
await page.goto('/parent-activate/test-code');
|
||||
|
||||
await expect(page.locator('#password')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Submit should be disabled initially
|
||||
const submitBtn = page.getByRole('button', { name: /activer mon compte/i });
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Fill all fields with valid data
|
||||
await page.locator('#firstName').fill('Jean');
|
||||
await page.locator('#lastName').fill('Parent');
|
||||
await page.locator('#password').fill('StrongP@ss1');
|
||||
await page.locator('#passwordConfirmation').fill('StrongP@ss1');
|
||||
|
||||
// Submit should be enabled
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@ export default tseslint.config(
|
||||
'build/**',
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'playwright-report/**',
|
||||
'test-results/**',
|
||||
'test-results-debug/**',
|
||||
'*.config.js',
|
||||
'*.config.ts'
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
104
frontend/src/lib/features/import/api/parentInvitationImport.ts
Normal file
104
frontend/src/lib/features/import/api/parentInvitationImport.ts
Normal 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();
|
||||
}
|
||||
@@ -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