diff --git a/.env.example b/.env.example index 9259888..b416ebb 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,24 @@ # Skilluv Admin — environment variables -# Copy to `.env` and adjust for local dev. Do NOT commit `.env`. +# Copy to `.env` (gitignored) and adjust for your setup. -# Internal URL of the Skilluv backend (server-side only, used by -# hooks.server.ts for SSR calls). The browser side goes through the -# `/api` proxy declared in vite.config.ts, so this value only matters -# when running under `node build` (production adapter-node output). -API_URL=http://localhost:3001/api +# ─── SSR + dev proxy ───────────────────────────────────────────────── +# Internal URL of the Skilluv backend used server-side by hooks.server.ts +# for the /auth/me call. Always ends in /api. +# Prod (default recommendation): +API_URL=https://api.skill-uv.com/api +# Local Rust backend: +# API_URL=http://localhost:3001/api -# --- Playwright e2e (optional) --- -# Postgres URL used by `e2e/admin-back-e2e.spec.ts` to seed a test -# admin directly in DB. Leave empty to skip the e2e suite. -# SKILLUV_PG_URL=postgres://skilluv:skilluv_secret@localhost:5433/skilluv +# Where the vite dev server proxies /api/*. Must be the same host as +# API_URL minus the /api suffix. +VITE_API_PROXY_TARGET=https://api.skill-uv.com +# VITE_API_PROXY_TARGET=http://localhost:3001 -# Backend URL used by the same e2e suite for the pre-flight health -# check. Skipped if unreachable. -# SKILLUV_BACKEND=http://localhost:3001 +# ─── Playwright E2E ────────────────────────────────────────────────── +# When set, e2e/setup/bootstrap-admin.mjs and the admin-project specs +# talk to this backend + Postgres directly. Leave empty to run only the +# `public` Playwright project (no backend needed). +BACKEND_URL=https://api.skill-uv.com +# DATABASE_URL is only safe to set when it points at a local staging +# database — never wire prod credentials from a dev machine. +# DATABASE_URL=postgres://skilluv:CHANGE_ME@localhost:5433/skilluv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3373711..ef2926a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: npm run build e2e: - name: Playwright smoke tests + name: Playwright public smoke runs-on: ubuntu-latest needs: check timeout-minutes: 15 @@ -54,13 +54,164 @@ jobs: - name: Build run: npm run build - - name: Run smoke tests - run: npm run test:e2e + - name: Run public smoke tests + run: npx playwright test --project=public - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v7 with: - name: playwright-report + name: playwright-public-report + path: playwright-report/ + retention-days: 7 + + e2e-admin: + # Runs the authenticated `admin` Playwright project against a real backend + # pulled from GHCR. Requires the backend team's publish workflow to be + # merged first (see skilluv-backend PR #33). This job will stay red on + # PRs until that image is published — that's intentional; we don't skip + # tests when a dependency isn't ready, we surface the gap. + name: Playwright admin flows (needs backend image) + runs-on: ubuntu-latest + needs: check + timeout-minutes: 20 + + services: + postgres: + image: postgres:18.4-alpine + env: + POSTGRES_USER: skilluv + POSTGRES_PASSWORD: skilluv_secret + POSTGRES_DB: skilluv + # postgres 18+ warns when data lives at /var/lib/postgresql/data + # (the legacy mount path). Force a subdir to silence the check. + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U skilluv" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:8.8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + + mailpit: + image: axllent/mailpit:latest + ports: + - 1025:1025 + - 8025:8025 + options: >- + --health-cmd "wget -q --spider http://localhost:8025 || exit 1" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v7 + + # MinIO can't be a GHA service (the image needs a `server /data` arg; + # services don't support commands). Docker-run it on host network so + # the backend (also host-net) reaches it via localhost:9000. + - name: Start MinIO + run: | + docker run -d --name minio --network host \ + -e MINIO_ROOT_USER=skilluv \ + -e MINIO_ROOT_PASSWORD=skilluv_secret \ + minio/minio:RELEASE.2025-09-07T16-13-09Z \ + server /data + for i in $(seq 1 30); do + curl -fsS http://localhost:9000/minio/health/live > /dev/null 2>&1 && \ + echo "minio ready after ${i}s" && exit 0 + sleep 1 + done + docker logs minio + exit 1 + + - name: Start backend from GHCR image + env: + # `:master` is republished on every green master merge — see + # skilluv-backend/.github/workflows/ci.yml `publish` job. + BACKEND_IMAGE: ghcr.io/skilluv/skilluv-backend:master + run: | + docker pull "$BACKEND_IMAGE" + # Host network — backend reaches postgres/redis/mailpit/minio via + # the ports GHA services (and MinIO above) already bound to the + # runner. Admin origin allowlist added so the API accepts + # requests from the test's Origin: http://localhost:5174. + docker run -d --name backend --network host \ + -e HOST=0.0.0.0 \ + -e PORT=3001 \ + -e ENVIRONMENT=dev \ + -e DATABASE_URL=postgres://skilluv:skilluv_secret@localhost:5433/skilluv \ + -e REDIS_URL=redis://localhost:6379 \ + -e JWT_SECRET=ci-test-secret-please-rotate \ + -e BASE_URL=http://localhost:3001 \ + -e MINIO_ENDPOINT=http://localhost:9000 \ + -e MINIO_ACCESS_KEY=skilluv \ + -e MINIO_SECRET_KEY=skilluv_secret \ + -e MINIO_BUCKET=avatars \ + -e SMTP_HOST=localhost \ + -e SMTP_PORT=1025 \ + -e SMTP_TLS=none \ + -e EMAIL_FROM=noreply@skilluv.test \ + -e ADMIN_ORIGINS=http://localhost:5174 \ + -e RUST_LOG=skilluv_backend=info,tower_http=info \ + "$BACKEND_IMAGE" + + - name: Wait for backend to be healthy + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:3001/api/health > /dev/null 2>&1; then + echo "backend ready after ${i}s" + exit 0 + fi + sleep 2 + done + echo "backend never became healthy — dumping logs:" + docker logs backend + exit 1 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Bootstrap admin user (register + elevate + enable 2FA) + env: + BACKEND_URL: http://localhost:3001 + DATABASE_URL: postgres://skilluv:skilluv_secret@localhost:5433/skilluv + run: node e2e/setup/bootstrap-admin.mjs + + - name: Run admin Playwright project + env: + BACKEND_URL: http://localhost:3001 + DATABASE_URL: postgres://skilluv:skilluv_secret@localhost:5433/skilluv + run: npx playwright test --project=admin + + - name: Dump backend logs on failure + if: failure() + run: docker logs backend + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-admin-report path: playwright-report/ retention-days: 7 diff --git a/.gitignore b/.gitignore index 1215999..e32a20e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,14 @@ playwright-report test-results .playwright +# E2E admin bootstrap artifacts (contain test creds + session state) +e2e/setup/admin-credentials.json +e2e/setup/admin-storage-state.json +e2e/setup/*.png + +# Trello sync creds (see qa/.trello.env.example) +qa/.trello.env + # AI coding assistant caches .claude/ .claude.* diff --git a/e2e/admin/catalog-crud.spec.ts b/e2e/admin/catalog-crud.spec.ts new file mode 100644 index 0000000..166a663 --- /dev/null +++ b/e2e/admin/catalog-crud.spec.ts @@ -0,0 +1,159 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq } from '../setup/db'; + +// Phase 3 — catalog admin CRUD: orientations + badge rules + tenants. +// Grouped here because each individually is short but shares the /catalog +// tab surface + similar seed patterns. + +// ─── Orientations ──────────────────────────────────────────────────────── + +async function readOrientation(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT id, display_name, description FROM orientations WHERE slug = $1', + [slug] + ); + return rows[0] as { id: string; display_name: string; description: string | null } | undefined; + }); +} + +async function cleanupOrientation(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM orientations WHERE slug = $1', [slug]); + }); +} + +test('admin creates an orientation from /catalog', async ({ page }) => { + const id = uniq(); + const slug = `e2e-orient-${id}`; + const displayName = `E2E Orientation ${id}`; + + await page.goto('/catalog'); + // Orientations tab — most catalog pages have a segmented control. + await page.getByRole('button', { name: /orientations?/i }).first().click().catch(() => {}); + + // Open create form (a button labelled "Nouvelle orientation" per fr.ts). + await page.getByRole('button', { name: /nouvelle orientation|new orientation|créer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + await dialog.locator('input[placeholder*="slug"], input[name="slug"], #slug').first().fill(slug); + await dialog.getByRole('textbox', { name: /nom|display name/i }).first().fill(displayName); + + const req = page.waitForResponse( + (r) => r.url().includes('/admin/orientations') && r.request().method() === 'POST' + ); + await dialog.locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + expect((await req).status(), 'orientation POST').toBeLessThan(300); + + const created = await readOrientation(slug); + expect(created?.display_name).toBe(displayName); + + await cleanupOrientation(slug); +}); + +// ─── Badge rules ──────────────────────────────────────────────────────── + +async function readBadgeRule(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT id, display_name, deprecated_at FROM badge_rules WHERE slug = $1', + [slug] + ); + return rows[0] as + | { id: string; display_name: string; deprecated_at: Date | null } + | undefined; + }); +} + +async function seedBadgeRule() { + const id = uniq(); + const slug = `e2e-badge-${id}`; + return withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO badge_rules (slug, display_name, description, kind, rule_expr, reward_fragments) + VALUES ($1, $2, 'E2E test rule', 'proof', '{}'::jsonb, 0) + RETURNING id`, + [slug, `E2E Badge ${id}`] + ); + return { id: rows[0].id as string, slug }; + }); +} + +async function cleanupBadgeRule(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM badge_rules WHERE slug = $1', [slug]); + }); +} + +test('admin deprecates a badge rule from /catalog', async ({ page }) => { + const rule = await seedBadgeRule(); + + await page.goto('/catalog'); + await page.getByRole('button', { name: /badge/i }).first().click().catch(() => {}); + + // Locate our seeded rule's row + trigger the deprecate action. + const row = page.locator(`text=${rule.slug}`).first(); + await expect(row).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: /déprécier|deprecate/i }).first().click(); + + // Deprecate is destructive → reason required. + await page.getByTestId('confirm-dangerous-reason').fill('E2E — rule superseded by newer criteria'); + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/badge-rules/${rule.slug}/deprecate`) && r.request().method() === 'POST' + ); + await page.getByTestId('confirm-dangerous-action').click(); + expect((await req).status(), 'deprecate POST').toBeLessThan(300); + + const state = await readBadgeRule(rule.slug); + expect(state?.deprecated_at, 'deprecated_at set').not.toBeNull(); + + await cleanupBadgeRule(rule.slug); +}); + +// ─── Tenants ──────────────────────────────────────────────────────────── + +async function readTenant(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT id, name, plan FROM tenants WHERE slug = $1', + [slug] + ); + return rows[0] as { id: string; name: string; plan: string } | undefined; + }); +} + +async function cleanupTenant(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM tenants WHERE slug = $1', [slug]); + }); +} + +test('admin creates a tenant from /tenants', async ({ page }) => { + const id = uniq(); + const slug = `e2e-tenant-${id}`.slice(0, 40); + const name = `E2E Tenant ${id}`; + + await page.goto('/tenants'); + await page.waitForResponse((r) => r.url().includes('/api/admin/tenants') && r.request().method() === 'GET'); + + await page.getByRole('button', { name: /nouveau tenant|new tenant|créer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.locator('input[placeholder*="slug"], input[name="slug"], #slug').first().fill(slug); + await dialog.getByRole('textbox', { name: /nom|company|name/i }).first().fill(name); + // Contact email is required by the create endpoint. + await dialog.locator('input[type="email"]').first().fill(`${slug}@e2e.test`); + + const req = page.waitForResponse( + (r) => r.url().includes('/admin/tenants') && r.request().method() === 'POST' + ); + await dialog.locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + expect((await req).status(), 'tenant POST').toBeLessThan(300); + + const created = await readTenant(slug); + expect(created?.name).toBe(name); + + await cleanupTenant(slug); +}); diff --git a/e2e/admin/challenge-lifecycle.spec.ts b/e2e/admin/challenge-lifecycle.spec.ts new file mode 100644 index 0000000..c911199 --- /dev/null +++ b/e2e/admin/challenge-lifecycle.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq } from '../setup/db'; + +// Phase 2 — challenge admin lifecycle: seeded challenge → publish via UI → +// archive via UI. Backend enforces "hard rule #1" (challenges published must be +// is_training=TRUE or have project_id); we set is_training when seeding so the +// publish button doesn't 400. + +async function seedDraftChallenge(page: import('@playwright/test').Page) { + const title = `E2E Challenge ${uniq()}`; + return await page.evaluate(async ({ title }) => { + const r = await fetch('/api/admin/challenges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description: 'Seeded by e2e/admin/challenge-lifecycle.spec.ts', + instructions: 'Complete the E2E lifecycle test.', + skill_domain: 'code', + difficulty: 3, + is_training: true + }) + }); + if (!r.ok) throw new Error(`create failed: ${r.status} ${await r.text()}`); + return (await r.json()).data.challenge as { id: string; title: string }; + }, { title }); +} + +async function readStatus(challengeId: string) { + return withDb(async (client) => { + const { rows } = await client.query('SELECT status FROM challenge_templates WHERE id = $1', [challengeId]); + return rows[0]?.status as string | undefined; + }); +} + +test('admin can publish then archive a draft challenge via the UI', async ({ page }) => { + // Land on /challenges first so we're in the admin session context for the fetch. + await page.goto('/challenges'); + await page.waitForResponse( + (r) => r.url().includes('/api/admin/challenges') && r.request().method() === 'GET' + ); + + const challenge = await seedDraftChallenge(page); + expect(await readStatus(challenge.id), 'seeded challenge starts as draft').toBe('draft'); + + // Reload so the freshly-seeded challenge shows up in the list. + const listAfterSeed = page.waitForResponse( + (r) => r.url().includes('/api/admin/challenges') && r.request().method() === 'GET' + ); + await page.reload(); + await listAfterSeed; + + // Anchor the row by the challenge title span, then walk up to the outer card. + const titleSpan = page.locator('span').filter({ hasText: challenge.title }).first(); + await expect(titleSpan).toBeVisible({ timeout: 10_000 }); + const row = titleSpan.locator('xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"border-border")][1]'); + + // ─── Publish ───────────────────────────────────────────────────── + const publishReq = page.waitForResponse( + (r) => r.url().includes(`/admin/challenges/${challenge.id}/publish`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /publier|publish/i }).click(); + expect((await publishReq).status(), 'publish POST').toBeLessThan(300); + expect(await readStatus(challenge.id), 'DB status after publish').toBe('published'); + + // ─── Archive ───────────────────────────────────────────────────── + const archiveReq = page.waitForResponse( + (r) => r.url().includes(`/admin/challenges/${challenge.id}/archive`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /archiver|archive/i }).click(); + expect((await archiveReq).status(), 'archive POST').toBeLessThan(300); + expect(await readStatus(challenge.id), 'DB status after archive').toBe('archived'); +}); diff --git a/e2e/admin/community-review.spec.ts b/e2e/admin/community-review.spec.ts new file mode 100644 index 0000000..bc2738a --- /dev/null +++ b/e2e/admin/community-review.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 2 — community-submitted challenges: approve + reject via the UI, DB confirms. + +async function seedCommunityChallenge() { + const id = uniq(); + const title = `E2E Community Challenge ${id}`; + const creator = await seedUser({ prefix: 'creator' }); + return withDb(async (client) => { + // `is_training=TRUE` — required so the approve handler's implicit + // `status='published'` UPDATE doesn't violate the DB check constraint + // `challenge_templates_project_or_training` (see BUGS_BACK). + const { rows } = await client.query( + `INSERT INTO challenge_templates + (title, description, instructions, skill_domain, difficulty, created_by, + is_community, community_status, is_training, title_i18n) + VALUES ($1, 'E2E description', 'E2E instructions', 'code', 3, $2, + TRUE, 'review', TRUE, $3::jsonb) + RETURNING id`, + [title, creator.id, JSON.stringify({ fr: title })] + ); + return { challengeId: rows[0].id as string, title }; + }); +} + +async function readChallenge(challengeId: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT status, community_status FROM challenge_templates WHERE id = $1', + [challengeId] + ); + return rows[0] as { status: string; community_status: string | null } | undefined; + }); +} + +async function landOnReviewPage(page: import('@playwright/test').Page, challengeTitle: string) { + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/community/review') && r.request().method() === 'GET' + ); + await page.goto('/community'); + await initialLoad; + const titleH3 = page.getByRole('heading', { name: challengeTitle }); + await expect(titleH3).toBeVisible({ timeout: 10_000 }); + return titleH3.locator( + 'xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"border-border")][1]' + ); +} + +test('admin can approve a community challenge under review', async ({ page }) => { + const { challengeId, title } = await seedCommunityChallenge(); + const card = await landOnReviewPage(page, title); + + const approveReq = page.waitForResponse( + (r) => r.url().includes(`/admin/community/${challengeId}/approve`) && r.request().method() === 'POST' + ); + await card.getByRole('button', { name: /^approuver$|^approve$/i }).click(); + expect((await approveReq).status(), 'approve POST').toBeLessThan(300); + const state = await readChallenge(challengeId); + expect(state?.community_status, 'community_status after approve').toBe('approved'); +}); + +test('approving a community challenge without is_training/project returns 400 with actionable message', async ({ page }) => { + // Regression guard for Trello hVImXbUS — backend used to bubble a + // generic 500 when the DB trigger for hard rule P3 (published requires + // is_training or project_id) fired. Now it pre-checks and returns 400 + // with a message explaining what's missing. + const id = uniq(); + const title = `E2E Bad Approve ${id}`; + const creator = await seedUser({ prefix: 'creator-bad' }); + const { challengeId } = await withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO challenge_templates + (title, description, instructions, skill_domain, difficulty, created_by, + is_community, community_status, is_training, project_id, title_i18n) + VALUES ($1, 'no-training no-project', 'x', 'code', 3, $2, + TRUE, 'review', FALSE, NULL, $3::jsonb) + RETURNING id`, + [title, creator.id, JSON.stringify({ fr: title })] + ); + return { challengeId: rows[0].id as string }; + }); + + await page.goto('/'); + const status = await page.evaluate(async ({ id }) => { + const r = await fetch(`/api/admin/community/${id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + return { status: r.status, body: await r.text() }; + }, { id: challengeId }); + + expect(status.status, 'expected 400, not 500').toBe(400); + expect(status.body.toLowerCase()).toMatch(/is_training|project/); + + // Verify the DB was NOT mutated (approve was properly refused). + const state = await readChallenge(challengeId); + expect(state?.community_status, 'community_status untouched').toBe('review'); + expect(state?.status, 'status untouched').not.toBe('published'); +}); + +test('admin can reject a community challenge with feedback', async ({ page }) => { + const { challengeId, title } = await seedCommunityChallenge(); + const card = await landOnReviewPage(page, title); + + await card.getByRole('button', { name: /rejeter|reject/i }).click(); + await page.getByTestId('confirm-dangerous-reason').fill('E2E — challenge non aligné avec les guidelines'); + + const rejectReq = page.waitForResponse( + (r) => r.url().includes(`/admin/community/${challengeId}/reject`) && r.request().method() === 'POST' + ); + await page.getByTestId('confirm-dangerous-action').click(); + expect((await rejectReq).status(), 'reject POST').toBeLessThan(300); + const state = await readChallenge(challengeId); + expect(state?.community_status, 'community_status after reject').toBe('rejected'); +}); diff --git a/e2e/admin/fraud-actions.spec.ts b/e2e/admin/fraud-actions.spec.ts new file mode 100644 index 0000000..a110212 --- /dev/null +++ b/e2e/admin/fraud-actions.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { withDb, seedUser } from '../setup/db'; + +// Phase 2 — fraud queue: mark-valid + revoke a flagged deliverable via the UI. +// Backend `list_flagged` returns deliverables with plagiarism_score >= 0.9. + +async function seedFlaggedDeliverable() { + const user = await seedUser({ prefix: 'fraud' }); + return withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO deliverables + (user_id, artifact_type, artifact_url, verifiable_by, plagiarism_score) + VALUES ($1, 'code', 'https://e2e.test/artifact', 'ai', 0.95) + RETURNING id`, + [user.id] + ); + return { deliverableId: rows[0].id as string }; + }); +} + +async function readDeliverable(id: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT plagiarism_score, verification_status FROM deliverables WHERE id = $1', + [id] + ); + return rows[0] as { plagiarism_score: string | null; verification_status: string } | undefined; + }); +} + +async function landOnFraudTab(page: import('@playwright/test').Page, deliverableId: string) { + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/fraud/queue') && r.request().method() === 'GET' + ); + await page.goto('/fraud'); + await initialLoad; + // Deliverable id shows in the plagiarism table's first column. + const cell = page.getByText(deliverableId, { exact: false }); + await expect(cell).toBeVisible({ timeout: 10_000 }); + return cell.locator('xpath=ancestor::tr[1]'); +} + +test('admin can mark a flagged deliverable as valid', async ({ page }) => { + const { deliverableId } = await seedFlaggedDeliverable(); + const row = await landOnFraudTab(page, deliverableId); + + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/fraud/deliverables/${deliverableId}/mark-valid`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /marquer valide|mark valid/i }).click(); + expect((await req).status(), 'mark-valid POST').toBeLessThan(300); + const state = await readDeliverable(deliverableId); + expect(state?.plagiarism_score, 'score cleared').toBeNull(); +}); + +test('admin can revoke a flagged deliverable via the danger dialog', async ({ page }) => { + const { deliverableId } = await seedFlaggedDeliverable(); + const row = await landOnFraudTab(page, deliverableId); + + await row.getByRole('button', { name: /^révoquer$|^revoke$/i }).click(); + await page.getByTestId('confirm-dangerous-reason').fill('E2E — proven plagiarism, revoke deliverable'); + + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/fraud/deliverables/${deliverableId}/revoke`) && r.request().method() === 'POST' + ); + await page.getByTestId('confirm-dangerous-action').click(); + expect((await req).status(), 'revoke POST').toBeLessThan(300); + const state = await readDeliverable(deliverableId); + expect(state?.verification_status, 'verification_status after revoke').toBe('revoked'); +}); diff --git a/e2e/admin/gdpr-guild.spec.ts b/e2e/admin/gdpr-guild.spec.ts new file mode 100644 index 0000000..ace6384 --- /dev/null +++ b/e2e/admin/gdpr-guild.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 3 — admin operations that mutate a specific user/entity: +// 1. GDPR export triggered from /users/[id] +// 2. Guild dissolve triggered from /operations +// +// Both are admin_destructive rate-limited; both require a valid target +// entity in the DB (user for GDPR, guild for dissolve). + +async function seedGuild(ownerId: string) { + const id = uniq(); + return withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO guilds (name, slug, owner_id, description) + VALUES ($1, $2, $3, 'E2E test guild') + RETURNING id`, + [`E2E Guild ${id}`, `e2e-guild-${id}`.slice(0, 60), ownerId] + ); + return { id: rows[0].id as string }; + }); +} + +test('admin triggers GDPR export from a user detail page', async ({ page }) => { + const victim = await seedUser({ prefix: 'gdpr' }); + + const detailLoad = page.waitForResponse( + (r) => r.url().includes(`/api/admin/users/${victim.id}`) && r.request().method() === 'GET' + ); + await page.goto(`/users/${victim.id}`); + await detailLoad; + + // GDPR trigger lives in a dedicated ``. Button label + // contains "GDPR" or "RGPD" per i18n. + const gdprBtn = page.getByRole('button', { name: /gdpr|rgpd|export/i }).first(); + await gdprBtn.scrollIntoViewIfNeeded(); + await expect(gdprBtn).toBeVisible(); + + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/users/${victim.id}/gdpr-export`) && r.request().method() === 'POST' + ); + await gdprBtn.click(); + + // Some UIs require typing a reason in a confirm dialog — fill if present. + const reasonField = page.getByTestId('confirm-dangerous-reason'); + if (await reasonField.isVisible().catch(() => false)) { + await reasonField.fill('E2E — legitimate compliance drill'); + await page.getByTestId('confirm-dangerous-action').click(); + } + expect((await req).status(), 'gdpr-export POST').toBeLessThan(300); +}); + +test('admin dissolves a guild from /operations', async ({ page }) => { + const owner = await seedUser({ prefix: 'guildowner' }); + const guild = await seedGuild(owner.id); + + await page.goto('/operations'); + // Guild dissolve is behind a form + ConfirmDangerousDialog. The UI expects + // the guild UUID pasted into an input, then the "Dissolve" button opens + // the confirm dialog. + const guildIdInput = page.getByLabel(/guild.*(id|uuid)|id.*guilde/i).first(); + await guildIdInput.fill(guild.id); + await page.getByRole('button', { name: /dissoudre|dissolve/i }).first().click(); + + await page.getByTestId('confirm-dangerous-reason').fill('E2E — dissolve inactive guild'); + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/guilds/${guild.id}/dissolve`) && r.request().method() === 'POST' + ); + await page.getByTestId('confirm-dangerous-action').click(); + expect((await req).status(), 'dissolve POST').toBeLessThan(300); + + // Verify guild was flagged dissolved (schema-dependent — most likely a + // dissolved_at timestamp or status column). + const dissolved = await withDb(async (client) => { + const { rows } = await client.query( + `SELECT dissolved_at, status FROM guilds WHERE id = $1`, + [guild.id] + ); + return rows[0] as { dissolved_at: Date | null; status?: string }; + }); + // One of the two invariants should hold once dissolve fires. + expect(dissolved.dissolved_at !== null || dissolved.status === 'dissolved').toBe(true); +}); diff --git a/e2e/admin/kyc-decide.spec.ts b/e2e/admin/kyc-decide.spec.ts new file mode 100644 index 0000000..edd7cc6 --- /dev/null +++ b/e2e/admin/kyc-decide.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 2 — enterprise KYC review: approve + reject via UI, DB confirms. +// The queue only shows enterprises with kyc.status='pending'. + +async function seedPendingKyc() { + const id = uniq(); + const owner = await seedUser({ prefix: 'kyc', role: 'enterprise' }); + const companyName = `KYC Co ${id}`; + return withDb(async (client) => { + const { rows: entRows } = await client.query( + `INSERT INTO enterprises (owner_id, company_name, slug, company_size) + VALUES ($1, $2, $3, '11-50') RETURNING id`, + [owner.id, companyName, `kyc-${id}`.slice(0, 60)] + ); + await client.query( + `INSERT INTO enterprise_kyc (enterprise_id, status) VALUES ($1, 'pending')`, + [entRows[0].id] + ); + return { enterpriseId: entRows[0].id as string, companyName }; + }); +} + +async function readKycStatus(enterpriseId: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT status, level, rejection_reason FROM enterprise_kyc WHERE enterprise_id = $1', + [enterpriseId] + ); + return rows[0] as { status: string; level: string; rejection_reason: string | null } | undefined; + }); +} + +async function landOnQueue(page: import('@playwright/test').Page, companyName: string) { + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/enterprise-kyc') && r.request().method() === 'GET' + ); + await page.goto('/enterprise-kyc'); + await initialLoad; + const heading = page.getByRole('heading', { name: companyName }); + await expect(heading).toBeVisible({ timeout: 10_000 }); + return heading.locator( + 'xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"border-border")][1]' + ); +} + +test('admin can approve a pending KYC review', async ({ page }) => { + const { enterpriseId, companyName } = await seedPendingKyc(); + const card = await landOnQueue(page, companyName); + + const decideReq = page.waitForResponse( + (r) => r.url().includes(`/admin/enterprise-kyc/${enterpriseId}/decide`) && r.request().method() === 'POST' + ); + await card.getByRole('button', { name: /^approuver$|^approve$/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + // The modal has a Select (level) whose trigger button label collides with + // the submit button — submit via form.requestSubmit to bypass. + await page.getByRole('dialog').locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + expect((await decideReq).status(), 'decide POST').toBeLessThan(300); + const state = await readKycStatus(enterpriseId); + expect(state?.status, 'DB status').toBe('approved'); + expect(state?.level, 'DB level defaulted to basic').toBe('basic'); +}); + +test('admin can reject a pending KYC review with a reason', async ({ page }) => { + const { enterpriseId, companyName } = await seedPendingKyc(); + const card = await landOnQueue(page, companyName); + await card.getByRole('button', { name: /^rejeter$|^reject$/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + // Reject requires a reason (front-side toast if empty). Fill the textarea. + await dialog.locator('textarea').fill('E2E — documents non conformes'); + + const decideReq = page.waitForResponse( + (r) => r.url().includes(`/admin/enterprise-kyc/${enterpriseId}/decide`) && r.request().method() === 'POST' + ); + await dialog.locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + expect((await decideReq).status(), 'decide POST').toBeLessThan(300); + const state = await readKycStatus(enterpriseId); + expect(state?.status, 'DB status').toBe('rejected'); + expect(state?.rejection_reason, 'rejection reason persisted').toContain('E2E'); +}); diff --git a/e2e/admin/nav-smoke.spec.ts b/e2e/admin/nav-smoke.spec.ts new file mode 100644 index 0000000..a7ce193 --- /dev/null +++ b/e2e/admin/nav-smoke.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; + +// Phase 1 smoke — for every admin route, prove: +// 1. Authenticated visitor is NOT redirected to /auth/login +// 2. The layout nav still mounts (i.e. the page didn't hard-crash) +// +// A failing case here means either the storageState broke, the page +// throws on render, or the route was removed. Detail assertions belong +// in per-module Phase 2/3 specs. + +const ROUTES: Array<{ path: string; label: string }> = [ + { path: '/', label: 'dashboard' }, + { path: '/tenants', label: 'tenants list' }, + { path: '/users', label: 'users list' }, + { path: '/enterprises', label: 'enterprises list' }, + { path: '/challenges', label: 'challenges list' }, + { path: '/reports', label: 'reports list' }, + { path: '/audit-log', label: 'audit log' }, + { path: '/enterprise-kyc', label: 'enterprise KYC queue' }, + { path: '/fraud', label: 'fraud queue' }, + { path: '/operations', label: 'ops jobs' }, + { path: '/catalog', label: 'catalog / orientations' }, + { path: '/projects', label: 'projects list' }, + { path: '/skills', label: 'skills catalog' }, + { path: '/sponsored-challenges', label: 'sponsored requests' }, + { path: '/sso-sessions', label: 'sso sessions' }, + { path: '/tournaments', label: 'tournaments' }, + { path: '/community', label: 'community review' } +]; + +for (const { path, label } of ROUTES) { + test(`${label} (${path}) renders for authenticated admin`, async ({ page }) => { + const consoleErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + + const response = await page.goto(path, { waitUntil: 'domcontentloaded' }); + expect(response?.status(), `HTTP status for ${path}`).toBeLessThan(500); + expect(page.url(), `${path} should not redirect to /auth/`).not.toMatch(/\/auth\//); + await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 10_000 }); + + // Console errors — we don't fail on them yet (many pages have transient + // backend 4xx on empty tables that log to console). Log for visibility. + if (consoleErrors.length) { + console.log(`[${path}] console errors:`, consoleErrors); + } + }); +} diff --git a/e2e/admin/ops-jobs.spec.ts b/e2e/admin/ops-jobs.spec.ts new file mode 100644 index 0000000..2f7d1a0 --- /dev/null +++ b/e2e/admin/ops-jobs.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; + +// Phase 3 — ops jobs safe triggers. +// +// These are admin_destructive rate-limited endpoints (10/min, 100/hr). Each +// spec fires ONE call and validates the POST succeeds (< 300). Side-effect +// depth is out-of-scope here — we're just proving the trigger path from UI +// to backend is wired and the button surface is clickable. +// +// `leaderboards/rebuild` is idempotent; `ai/hidden-gems` + `ai/churn` return +// job_ids; `proof-hooks/sweep` supports dry_run — we always use dry-run to +// avoid mutating real proofs. + +async function pageFireAndAssert( + page: import('@playwright/test').Page, + pathIncludes: string, + trigger: () => Promise +) { + const req = page.waitForResponse( + (r) => r.url().includes(pathIncludes) && r.request().method() === 'POST' + ); + await trigger(); + const status = (await req).status(); + expect(status, `POST to ${pathIncludes}`).toBeLessThan(300); +} + +test('rebuild-leaderboards trigger reaches the backend', async ({ page }) => { + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/') && r.request().method() === 'GET', + { timeout: 15_000 } + ).catch(() => null); + await page.goto('/operations'); + await initialLoad; + await pageFireAndAssert(page, '/admin/leaderboards/rebuild', async () => { + await page.getByRole('button', { name: /rebuild.*leaderboards|leaderboards.*rebuild|reconstruire.*classement/i }).first().click(); + }); +}); + +test('proof-hooks sweep with dry-run reaches the backend', async ({ page }) => { + await page.goto('/operations'); + // Fires the dry-run sweep — the UI exposes an explicit dry-run toggle. + const req = page.waitForResponse( + (r) => r.url().includes('/admin/proof-hooks/sweep') && r.request().method() === 'POST' + ); + // Best-effort: check dry-run checkbox if present, then click sweep button. + const dryRunToggle = page.getByLabel(/dry.?run|essai à sec|simulation/i).first(); + if (await dryRunToggle.isVisible().catch(() => false)) { + await dryRunToggle.check(); + } + await page.getByRole('button', { name: /sweep|balayage|proof.?hooks/i }).first().click(); + const res = await req; + expect(res.status(), 'sweep POST').toBeLessThan(300); +}); + +test('AI hidden-gems job trigger reaches the backend', async ({ page }) => { + await page.goto('/operations'); + await pageFireAndAssert(page, '/admin/ai/hidden-gems', async () => { + await page.getByRole('button', { name: /hidden.gems|pépites/i }).first().click(); + }); +}); + +test('AI churn job trigger reaches the backend', async ({ page }) => { + await page.goto('/operations'); + await pageFireAndAssert(page, '/admin/ai/churn', async () => { + await page.getByRole('button', { name: /churn|attrition/i }).first().click(); + }); +}); diff --git a/e2e/admin/projects-crud.spec.ts b/e2e/admin/projects-crud.spec.ts new file mode 100644 index 0000000..582d548 --- /dev/null +++ b/e2e/admin/projects-crud.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 3 — projects admin CRUD: create → verify DB → archive → verify DB. +// Edit is exercised by the create-then-list-then-edit path in Phase 2's +// challenge-lifecycle pattern; here we focus on the create + archive endpoints. + +async function readProject(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + `SELECT id, name, is_flagship, is_oss, curated_by_admin, archived_at + FROM projects WHERE slug = $1`, + [slug] + ); + return rows[0] as + | { + id: string; + name: string; + is_flagship: boolean; + is_oss: boolean; + curated_by_admin: boolean; + archived_at: Date | null; + } + | undefined; + }); +} + +async function cleanupProject(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM projects WHERE slug = $1', [slug]); + }); +} + +test('admin creates a curated OSS project then archives it via the UI', async ({ page }) => { + // Seed an owner user via SQL — the project needs a real owner_id. + const owner = await seedUser({ prefix: 'projowner' }); + + const id = uniq(); + const slug = `e2e-proj-${id}`; + const name = `E2E Project ${id}`; + + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/projects') && r.request().method() === 'GET' + ); + await page.goto('/projects'); + await initialLoad; + + // ─── Create ───────────────────────────────────────────────────── + await page.getByRole('button', { name: /nouveau projet|new project/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.locator('#slug').fill(slug); + await dialog.locator('#name').fill(name); + await dialog.locator('#owner_id').fill(owner.id); + + const createReq = page.waitForResponse( + (r) => r.url().includes('/admin/projects') && r.request().method() === 'POST' + ); + await dialog.locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + expect((await createReq).status(), 'create POST').toBeLessThan(300); + + const created = await readProject(slug); + expect(created?.name).toBe(name); + expect(created?.archived_at, 'not archived yet').toBeNull(); + + // ─── Archive ──────────────────────────────────────────────────── + // Auto-confirm the browser confirm() dialog used by the archive button. + page.on('dialog', (d) => void d.accept()); + const archiveReq = page.waitForResponse( + (r) => r.url().includes(`/admin/projects/${slug}/archive`) && r.request().method() === 'POST' + ); + // Find the row for our project and click its archive button. + const row = page.locator(`text=${slug}`).first(); + await expect(row).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: /archiver|archive/i }).first().click(); + expect((await archiveReq).status(), 'archive POST').toBeLessThan(300); + + const archived = await readProject(slug); + expect(archived?.archived_at, 'archived_at set').not.toBeNull(); + + await cleanupProject(slug); +}); diff --git a/e2e/admin/reports.spec.ts b/e2e/admin/reports.spec.ts new file mode 100644 index 0000000..bd7c4be --- /dev/null +++ b/e2e/admin/reports.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 2 — reports moderation: resolve + dismiss via the UI, DB confirms. +// Seed a reporter user + a target user + a pending report per test. + +async function seedReport() { + const id = uniq(); + const reporter = await seedUser({ prefix: 'reporter' }); + const target = await seedUser({ prefix: 'target' }); + return withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO reports (reporter_id, target_type, target_id, reason, details) + VALUES ($1, 'user', $2, 'spam', $3) RETURNING id`, + [reporter.id, target.id, `E2E test details ${id}`] + ); + return { reportId: rows[0].id as string, reporterUsername: reporter.username }; + }); +} + +async function readReportStatus(reportId: string): Promise { + return withDb(async (client) => { + const { rows } = await client.query('SELECT status FROM reports WHERE id = $1', [reportId]); + return rows[0]?.status as string | undefined; + }); +} + +async function clickAction( + page: import('@playwright/test').Page, + reportId: string, + buttonName: RegExp, + expectedStatus: string +) { + // Anchor the report card by the report details text (unique per seed). + const detailsSpan = page.getByText(`E2E test details`).first(); + await expect(detailsSpan).toBeVisible({ timeout: 10_000 }); + const card = detailsSpan.locator( + 'xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"border-border")][1]' + ); + + const putReq = page.waitForResponse( + (r) => r.url().includes(`/admin/reports/${reportId}`) && r.request().method() === 'PUT' + ); + await card.getByRole('button', { name: buttonName }).click(); + expect((await putReq).status(), `PUT status for ${buttonName}`).toBeLessThan(300); + expect(await readReportStatus(reportId), `DB status after ${buttonName}`).toBe(expectedStatus); +} + +test('admin can resolve a pending report via the UI', async ({ page }) => { + const { reportId } = await seedReport(); + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/reports') && r.request().method() === 'GET' + ); + await page.goto('/reports'); + await initialLoad; + await clickAction(page, reportId, /résoudre|resolve/i, 'resolved'); +}); + +test('admin can dismiss a pending report via the UI', async ({ page }) => { + const { reportId } = await seedReport(); + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/reports') && r.request().method() === 'GET' + ); + await page.goto('/reports'); + await initialLoad; + await clickAction(page, reportId, /rejeter|dismiss/i, 'dismissed'); +}); diff --git a/e2e/admin/reset-2fa.spec.ts b/e2e/admin/reset-2fa.spec.ts new file mode 100644 index 0000000..999fc9d --- /dev/null +++ b/e2e/admin/reset-2fa.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; +import { withDb, seedUser } from '../setup/db'; + +// Phase 2 — admin can wipe another user's 2FA end-to-end via the UI. +// +// Backend rules: +// - POST /admin/users/{id}/reset-2fa requires reason ≥ 8 chars +// - Rate limited (admin_destructive: 10/min, 100/hr) +// - Wipes totp_secret, totp_enabled, and webauthn credentials +// +// The regression guard from the earlier "backend omits totp_enabled" era +// was flipped after Trello xHnNZa5G + gWSCzyz0 + RXEWNI6y landed +// (GET /admin/users/{id} now exposes totp_enabled + email_2fa_enabled + +// webauthn_credentials_count) — this spec now drives the full UI path. + +async function read2faState(userId: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT totp_enabled, totp_secret FROM users WHERE id = $1', + [userId] + ); + return { + totp_enabled: rows[0]?.totp_enabled as boolean, + totp_secret: rows[0]?.totp_secret as Buffer | null + }; + }); +} + +test('admin resets 2FA on a user with TOTP enabled via the full UI', async ({ page }) => { + const victim = await seedUser({ prefix: 'victim2fa', totpEnabled: true }); + const before = await read2faState(victim.id); + expect(before.totp_enabled, 'pre-reset').toBe(true); + expect(before.totp_secret, 'pre-reset').not.toBeNull(); + + const detailLoad = page.waitForResponse( + (r) => r.url().includes(`/api/admin/users/${victim.id}`) && r.request().method() === 'GET' + ); + await page.goto(`/users/${victim.id}`); + await detailLoad; + await expect(page.getByRole('heading', { name: victim.display_name })).toBeVisible({ timeout: 10_000 }); + + // TOTP badge should render now that the API exposes totp_enabled. + await expect(page.getByText('TOTP', { exact: true }).first()).toBeVisible(); + + const resetBtn = page.getByRole('button', { name: /réinitialiser.*2fa|reset.*2fa/i }); + await resetBtn.scrollIntoViewIfNeeded(); + await expect(resetBtn, 'button enabled — user has TOTP or a passkey').toBeEnabled(); + + const resetReq = page.waitForResponse( + (r) => + r.url().includes(`/admin/users/${victim.id}/reset-2fa`) && + r.request().method() === 'POST' + ); + await resetBtn.click(); + + // Reason validation — same ConfirmDangerousDialog contract (min 8 chars for BE-B). + await page.getByTestId('confirm-dangerous-reason').fill('short'); + await expect(page.getByTestId('confirm-dangerous-action')).toBeDisabled(); + await page.getByTestId('confirm-dangerous-reason').fill('E2E — user lost their authenticator device'); + await expect(page.getByTestId('confirm-dangerous-action')).toBeEnabled(); + + await page.getByTestId('confirm-dangerous-action').click(); + expect((await resetReq).status(), 'reset-2fa POST').toBeLessThan(300); + + const after = await read2faState(victim.id); + expect(after.totp_enabled, 'post-reset totp_enabled').toBe(false); + expect(after.totp_secret, 'post-reset totp_secret should be null').toBeNull(); +}); + +test('reset-2fa button stays disabled for a user with no strong factor', async ({ page }) => { + // A user with neither TOTP nor a webauthn credential shouldn't offer the + // reset — the endpoint would 400 anyway. Regression guard for BE-B. + const victim = await seedUser({ prefix: 'victim-nof', totpEnabled: false }); + await page.goto(`/users/${victim.id}`); + await expect(page.getByRole('heading', { name: victim.display_name })).toBeVisible({ timeout: 10_000 }); + + const resetBtn = page.getByRole('button', { name: /réinitialiser.*2fa|reset.*2fa/i }); + await resetBtn.scrollIntoViewIfNeeded(); + await expect(resetBtn).toBeDisabled(); +}); diff --git a/e2e/admin/skills-crud.spec.ts b/e2e/admin/skills-crud.spec.ts new file mode 100644 index 0000000..26bb129 --- /dev/null +++ b/e2e/admin/skills-crud.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq } from '../setup/db'; + +// Phase 3 — skills catalog CRUD: create via UI → verify DB → edit → verify DB. +// The delete/deprecate path isn't exposed in the current UI (backend has no +// DELETE either); this spec covers the two mutations users can trigger. + +async function readSkillBySlug(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + `SELECT id, display_name, description, domain, is_skilluv_specific + FROM skill_nodes WHERE slug = $1`, + [slug] + ); + return rows[0] as + | { + id: string; + display_name: string; + description: string | null; + domain: string; + is_skilluv_specific: boolean; + } + | undefined; + }); +} + +async function cleanupSkill(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM skill_nodes WHERE slug = $1', [slug]); + }); +} + +test('admin creates then edits a skill node via the UI', async ({ page }) => { + const id = uniq(); + const slug = `e2e-skill-${id}`; + const displayName = `E2E Skill ${id}`; + + // Wait for the initial list fetch fired by $effect on mount before typing + // in the modal — otherwise the openCreate click can race the hydration. + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/skills') && r.request().method() === 'GET' + ); + await page.goto('/skills'); + await initialLoad; + + // ─── Create ───────────────────────────────────────────────────── + await page.getByRole('button', { name: /nouveau|new|créer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('textbox', { name: /slug/i }).fill(slug); + await dialog.getByRole('textbox', { name: /nom.*affiché|display name/i }).fill(displayName); + + const createReq = page.waitForResponse( + (r) => r.url().includes('/admin/skills') && r.request().method() === 'POST' + ); + await dialog.getByRole('button', { name: /créer|create/i }).last().click(); + expect((await createReq).status(), 'create POST').toBeLessThan(300); + + const created = await readSkillBySlug(slug); + expect(created?.display_name).toBe(displayName); + expect(created?.domain).toBe('code'); + + // ─── Edit ──────────────────────────────────────────────────────── + // Search for our just-created skill so it's the only row. + await page.getByPlaceholder(/recherche|search|filtrer/i).first().fill(slug); + const editReq = page.waitForResponse( + (r) => r.url().includes(`/admin/skills/${created!.id}`) && r.request().method() === 'PUT' + ); + // The row's edit button — anchor via the skill's slug text in the table. + await page.getByRole('button', { name: /modifier|edit|éditer/i }).first().click(); + const editDialog = page.getByRole('dialog'); + await expect(editDialog).toBeVisible(); + + const newDisplayName = `${displayName} (edited)`; + const nameField = editDialog.getByRole('textbox', { name: /nom.*affiché|display name/i }); + await nameField.fill(newDisplayName); + await editDialog.getByRole('button', { name: /modifier|save|enregistrer|mettre à jour/i }).last().click(); + expect((await editReq).status(), 'edit PUT').toBeLessThan(300); + + const edited = await readSkillBySlug(slug); + expect(edited?.display_name, 'edit persisted').toBe(newDisplayName); + + await cleanupSkill(slug); +}); diff --git a/e2e/admin/sponsored-decide.spec.ts b/e2e/admin/sponsored-decide.spec.ts new file mode 100644 index 0000000..e74e5a7 --- /dev/null +++ b/e2e/admin/sponsored-decide.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 2 — sponsored challenge requests: decide (approve/reject) via UI. + +async function seedSponsoredRequest() { + const id = uniq(); + const owner = await seedUser({ prefix: 'spowner', role: 'enterprise' }); + return withDb(async (client) => { + const { rows: entRows } = await client.query( + `INSERT INTO enterprises (owner_id, company_name, slug, company_size) + VALUES ($1, $2, $3, '11-50') RETURNING id`, + [owner.id, `Sponsor Co ${id}`, `sponsor-${id}`.slice(0, 60)] + ); + const proposedTitle = `E2E Sponsored ${id}`; + const { rows } = await client.query( + `INSERT INTO sponsored_challenge_requests + (enterprise_id, requested_by_user_id, proposed_title, brief, + skill_domain, difficulty, duration_days, budget_eur_cents) + VALUES ($1, $2, $3, 'E2E brief', 'code', 3, 14, 500000) + RETURNING id`, + [entRows[0].id, owner.id, proposedTitle] + ); + return { requestId: rows[0].id as string, proposedTitle }; + }); +} + +async function readRequestStatus(requestId: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT status FROM sponsored_challenge_requests WHERE id = $1', + [requestId] + ); + return rows[0]?.status as string | undefined; + }); +} + +async function landOnPage(page: import('@playwright/test').Page, proposedTitle: string) { + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/sponsored-challenges') && r.request().method() === 'GET' + ); + await page.goto('/sponsored-challenges'); + await initialLoad; + const heading = page.getByText(proposedTitle, { exact: false }); + await expect(heading).toBeVisible({ timeout: 10_000 }); + return heading.locator( + 'xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"border-border")][1]' + ); +} + +async function decide( + page: import('@playwright/test').Page, + requestId: string, + expectedStatus: string +) { + const decideReq = page.waitForResponse( + (r) => r.url().includes(`/admin/sponsored-challenges/${requestId}/decide`) && r.request().method() === 'POST' + ); + // The modal has a Select whose trigger button collides with the submit + // button label. Submit the form via the native path (form.requestSubmit) + // to bypass label disambiguation entirely. + await page.getByRole('dialog').locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + expect((await decideReq).status(), 'decide POST').toBeLessThan(300); + expect(await readRequestStatus(requestId), 'DB status').toBe(expectedStatus); +} + +test('admin can approve a pending sponsored request', async ({ page }) => { + const { requestId, proposedTitle } = await seedSponsoredRequest(); + const card = await landOnPage(page, proposedTitle); + await card.getByRole('button', { name: /^approuver$|^approve$/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await decide(page, requestId, 'approved'); +}); + +test('admin can reject a pending sponsored request', async ({ page }) => { + const { requestId, proposedTitle } = await seedSponsoredRequest(); + const card = await landOnPage(page, proposedTitle); + await card.getByRole('button', { name: /^rejeter$|^reject$/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await decide(page, requestId, 'rejected'); +}); diff --git a/e2e/admin/sso-revoke.spec.ts b/e2e/admin/sso-revoke.spec.ts new file mode 100644 index 0000000..94d5691 --- /dev/null +++ b/e2e/admin/sso-revoke.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; +import { withDb, seedUser } from '../setup/db'; + +// Phase 2 — admin can revoke an active SSO session end-to-end via the UI. +// The list endpoint filters on `login_method='sso' AND revoked_at IS NULL`. +// +// The former regression guard (empty `` because the backend nested +// the array in `{data:{sessions:[…]}}`) was flipped after Trello MshrIOYf +// landed — the response now follows the standard `{data: T[], pagination}` +// shape used by every other admin list. + +async function seedSsoSession() { + const user = await seedUser({ prefix: 'sso' }); + // refresh_hash is BYTEA — any random bytes work for a seed row. + const refreshHash = Buffer.from(randomUUID().replace(/-/g, ''), 'hex'); + return withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO user_sessions (user_id, refresh_hash, login_method) + VALUES ($1, $2, 'sso') RETURNING id`, + [user.id, refreshHash] + ); + return { sessionId: rows[0].id as string, userId: user.id, username: user.username }; + }); +} + +async function readSessionRevokedAt(sessionId: string): Promise { + return withDb(async (client) => { + const { rows } = await client.query('SELECT revoked_at FROM user_sessions WHERE id = $1', [sessionId]); + return (rows[0]?.revoked_at as Date | null) ?? null; + }); +} + +test('admin revokes an active SSO session via the UI, DB revoked_at flips', async ({ page }) => { + const { sessionId, username } = await seedSsoSession(); + expect(await readSessionRevokedAt(sessionId), 'pre-revoke').toBeNull(); + + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/sso/sessions') && r.request().method() === 'GET' + ); + await page.goto('/sso-sessions'); + await initialLoad; + + // List must contain at least our seeded row (backend now returns the + // standard `{data: T[]}` envelope). The row is keyed by the seeded + // username, unique per test. + const cell = page.getByText(username, { exact: true }); + await expect(cell).toBeVisible({ timeout: 10_000 }); + const row = cell.locator('xpath=ancestor::tr[1]'); + + const revokeReq = page.waitForResponse( + (r) => r.url().includes(`/admin/sso/sessions/${sessionId}/revoke`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /révoquer|revoke/i }).click(); + await page.getByTestId('confirm-dangerous-reason').fill('E2E — session compromise drill'); + await page.getByTestId('confirm-dangerous-action').click(); + + expect((await revokeReq).status(), 'revoke POST').toBeLessThan(300); + expect(await readSessionRevokedAt(sessionId), 'revoked_at set').not.toBeNull(); +}); diff --git a/e2e/admin/user-ban-unban.spec.ts b/e2e/admin/user-ban-unban.spec.ts new file mode 100644 index 0000000..8506ec9 --- /dev/null +++ b/e2e/admin/user-ban-unban.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; +import { withDb, seedUser } from '../setup/db'; + +// Phase 2 — moderation critical path: ban then unban a real user via the UI. +// A victim is seeded directly via SQL (bypasses the 5/h auth:register rate +// limit; the user never needs to actually log in for this flow). + +async function readIsBanned(userId: string) { + return withDb(async (client) => { + const { rows } = await client.query('SELECT is_banned FROM users WHERE id = $1', [userId]); + return rows[0]?.is_banned as boolean; + }); +} + +test('admin can ban then unban a user via the UI, with DB confirming both flips', async ({ page }) => { + const victim = await seedUser({ prefix: 'victim' }); + expect(await readIsBanned(victim.id), 'pre-ban DB state').toBe(false); + + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/users') && r.request().method() === 'GET' + ); + await page.goto('/users'); + await initialLoad; + + const searchReq = page.waitForResponse( + (r) => r.url().includes('/api/admin/users') && r.url().includes('q=') && r.request().method() === 'GET' + ); + await page.getByPlaceholder(/rechercher|search/i).fill(victim.username); + await page.getByRole('button', { name: /chercher|search/i }).click(); + await searchReq; + + const link = page.locator(`a[href="/users/${victim.id}"]`); + await expect(link).toBeVisible({ timeout: 10_000 }); + const row = link.locator('xpath=ancestor::div[contains(@class,"border-b")][1]'); + + // ─── Ban ──────────────────────────────────────────────────────── + const banReq = page.waitForResponse( + (r) => r.url().includes(`/admin/users/${victim.id}/ban`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /bannir|^ban$/i }).click(); + + // Reason validation — the dialog should require a non-trivial reason. + await page.getByTestId('confirm-dangerous-reason').fill('x'); + await expect(page.getByTestId('confirm-dangerous-action')).toBeDisabled(); + await page.getByTestId('confirm-dangerous-reason').fill('E2E test — moderation smoke'); + await expect(page.getByTestId('confirm-dangerous-action')).toBeEnabled(); + + await page.getByTestId('confirm-dangerous-action').click(); + expect((await banReq).status(), 'ban POST should succeed').toBeLessThan(300); + expect(await readIsBanned(victim.id), 'DB is_banned after ban').toBe(true); + await expect(row.locator('span').getByText(/banni|banned/i)).toBeVisible({ timeout: 5_000 }); + + // ─── Unban (native UI toggle) ────────────────────────────────── + const unbanReq = page.waitForResponse( + (r) => r.url().includes(`/admin/users/${victim.id}/unban`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /débannir|unban/i }).click(); + expect((await unbanReq).status(), 'unban POST should succeed').toBeLessThan(300); + expect(await readIsBanned(victim.id), 'DB is_banned after unban').toBe(false); + await expect(row.locator('span').getByText(/banni|banned/i)).toHaveCount(0, { timeout: 5_000 }); + await expect(row.getByRole('button', { name: /bannir|^ban$/i })).toBeVisible(); +}); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..dc6e32c --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,52 @@ +import { chromium, request as pwRequest, type FullConfig } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { currentCode } from './setup/totp.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CREDS_PATH = resolve(HERE, 'setup/admin-credentials.json'); +export const STORAGE_STATE = resolve(HERE, 'setup/admin-storage-state.json'); + +const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001'; +const ADMIN_ORIGIN = 'http://localhost:5174'; + +export default async function globalSetup(_config: FullConfig) { + if (!existsSync(CREDS_PATH)) { + throw new Error( + `Missing ${CREDS_PATH}. Run: node e2e/setup/bootstrap-admin.mjs (needs backend on :3001)` + ); + } + const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf8')); + + // 1. API login — hits the backend directly with the admin Origin so cookies + // are issued exactly as they would be from the real admin app. + const api = await pwRequest.newContext({ baseURL: BACKEND, extraHTTPHeaders: { Origin: ADMIN_ORIGIN } }); + const loginRes = await api.post('/api/auth/login', { + data: { + identifier: creds.email, + password: creds.password, + totp_code: currentCode(creds.totp_secret_base32) + } + }); + if (!loginRes.ok()) { + throw new Error(`API login failed: ${loginRes.status()} ${await loginRes.text()}`); + } + const cookies = (await api.storageState()).cookies; + await api.dispose(); + + // 2. Rewrite the cookies to target the admin dev host (127.0.0.1:5174) so the + // browser will send them on subsequent /api/* calls proxied by vite. + const browserCookies = cookies.map((c) => ({ + ...c, + domain: '127.0.0.1', + path: '/' + })); + + // 3. Launch a browser, inject the cookies, save storageState for reuse. + const browser = await chromium.launch(); + const context = await browser.newContext({ baseURL: ADMIN_ORIGIN }); + await context.addCookies(browserCookies); + await context.storageState({ path: STORAGE_STATE }); + await browser.close(); +} diff --git a/e2e/login-2fa.spec.ts b/e2e/login-2fa.spec.ts new file mode 100644 index 0000000..f7d1b7b --- /dev/null +++ b/e2e/login-2fa.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { currentCode } from './setup/totp.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CREDS_PATH = resolve(HERE, 'setup/admin-credentials.json'); + +// Phase 2 — end-to-end login through the UI *with* 2FA. The nav-smoke suite +// reuses a storageState so it never exercises the login form; this spec is the +// safeguard proving the login form + TOTP challenge actually work together. + +test.use({ storageState: { cookies: [], origins: [] } }); + +test('admin logs in through the UI with 2FA and reaches the dashboard', async ({ page }) => { + test.skip(!existsSync(CREDS_PATH), 'e2e/setup/admin-credentials.json missing — run bootstrap'); + const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf8')); + + page.on('console', (msg) => console.log(`[browser ${msg.type()}]`, msg.text())); + page.on('response', (r) => { + if (r.url().includes('/api/auth/login')) { + console.log(`[login POST] ${r.status()} ${r.url()}`); + } + }); + + await page.goto('/auth/login', { waitUntil: 'networkidle' }); + // Ensure Svelte has hydrated by asserting a client-only interactive attribute + // (submit button becomes reactive to `loading` state). Force-clicking too + // early on a Svelte 5 dev page silently no-ops because the onsubmit handler + // isn't attached yet. + const signIn = page.locator('form button[type="submit"]'); + await expect(signIn).toBeEnabled(); + await page.getByRole('textbox', { name: /email|pseudo|username/i }).fill(creds.email); + await page.locator('input[type="password"]').fill(creds.password); + const firstLogin = page.waitForResponse((r) => r.url().includes('/api/auth/login')); + // Press Enter inside the password field — form-level submission is a more + // reliable trigger than a click when Svelte hydration timing is uncertain. + await page.locator('input[type="password"]').press('Enter'); + await firstLogin; + + const totpField = page.getByRole('textbox', { name: /totp/i }); + await totpField.waitFor({ state: 'visible', timeout: 5_000 }); + await totpField.fill(currentCode(creds.totp_secret_base32)); + const secondLogin = page.waitForResponse((r) => r.url().includes('/api/auth/login')); + await totpField.press('Enter'); + await secondLogin; + + await page.waitForURL((url) => !/\/auth\//.test(url.pathname), { timeout: 10_000 }); + await expect(page.getByRole('navigation').first()).toBeVisible(); +}); diff --git a/e2e/setup/bootstrap-admin.mjs b/e2e/setup/bootstrap-admin.mjs new file mode 100644 index 0000000..49c24d1 --- /dev/null +++ b/e2e/setup/bootstrap-admin.mjs @@ -0,0 +1,158 @@ +// One-time bootstrap of the E2E admin test user against the local staging backend. +// Idempotent — re-running when credentials already exist just re-verifies login. +// +// Produces `e2e/setup/admin-credentials.json` (gitignored) with: +// { email, username, password, totp_secret_base32 } +// +// Prereqs: backend running on :3001, DB fresh (or admin not yet created). + +import { writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import pg from 'pg'; +import { currentCode } from './totp.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CREDS_PATH = resolve(HERE, 'admin-credentials.json'); + +const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001'; +const PG_URL = process.env.DATABASE_URL || 'postgres://skilluv:skilluv_secret@localhost:5433/skilluv'; + +const ADMIN = { + email: 'e2e-admin@skilluv.test', + username: 'e2eadmin', + password: 'E2eTestAdmin!2026', + first_name: 'E2e', + last_name: 'Admin', + skill_domain: 'code', + country: 'FR', + terms_accepted: true +}; + +// Origin required for /api/admin/* — matches localhost:5174 (admin dev origin). +const ORIGIN = 'http://localhost:5174'; + +async function apiPost(path, body, cookieJar = {}) { + const headers = { 'Content-Type': 'application/json', Origin: ORIGIN }; + if (cookieJar.cookie) headers.Cookie = cookieJar.cookie; + const res = await fetch(`${BACKEND}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + const setCookie = res.headers.getSetCookie?.() || []; + if (setCookie.length) { + cookieJar.cookie = setCookie.map((c) => c.split(';')[0]).join('; '); + } + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text }; + } + return { status: res.status, body: json, cookieJar }; +} + +async function register(jar) { + const r = await apiPost('/api/auth/register', ADMIN, jar); + if (r.status === 200 || r.status === 201) return { created: true, cookieJar: r.cookieJar }; + // Already exists → we'll just log in + if (r.status === 400 && /already exists/i.test(JSON.stringify(r.body))) { + return { created: false }; + } + throw new Error(`register failed: ${r.status} ${JSON.stringify(r.body)}`); +} + +async function grantAdmin() { + const client = new pg.Client({ connectionString: PG_URL }); + await client.connect(); + try { + const { rows } = await client.query('SELECT id FROM users WHERE email = $1', [ADMIN.email]); + if (!rows.length) throw new Error('User not found after register'); + const userId = rows[0].id; + await client.query("UPDATE users SET role = 'admin', email_verified = TRUE WHERE id = $1", [userId]); + await client.query( + `INSERT INTO user_capabilities (user_id, capability, granted_reason) + VALUES ($1, 'admin', 'e2e_bootstrap') + ON CONFLICT DO NOTHING`, + [userId] + ); + return userId; + } finally { + await client.end(); + } +} + +async function login(jar, totpCode = null) { + const body = { identifier: ADMIN.email, password: ADMIN.password }; + if (totpCode) body.totp_code = totpCode; + const r = await apiPost('/api/auth/login', body, jar); + if (r.status !== 200) { + throw new Error(`login failed: ${r.status} ${JSON.stringify(r.body)}`); + } + return r; +} + +async function setupTotp(jar) { + const r = await apiPost('/api/auth/totp/setup', {}, jar); + if (r.status !== 200) throw new Error(`totp/setup: ${r.status} ${JSON.stringify(r.body)}`); + const secret = r.body?.data?.secret_base32 || r.body?.secret_base32; + if (!secret) throw new Error(`no secret_base32 in response: ${JSON.stringify(r.body)}`); + return secret; +} + +async function enableTotp(jar, secret) { + const code = currentCode(secret); + const r = await apiPost('/api/auth/totp/enable', { code }, jar); + if (r.status !== 200) throw new Error(`totp/enable: ${r.status} ${JSON.stringify(r.body)}`); +} + +async function main() { + if (existsSync(CREDS_PATH)) { + console.log(`admin-credentials.json already exists — verifying login`); + const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf8')); + const jar = {}; + await login(jar, currentCode(creds.totp_secret_base32)); + console.log('✅ existing admin creds still valid'); + return; + } + + console.log(`Bootstrapping E2E admin against ${BACKEND}`); + const jar = {}; + const { created } = await register(jar); + console.log(created ? '→ user created' : '→ user already existed'); + + const userId = await grantAdmin(); + console.log(`→ elevated to admin (id=${userId})`); + + // Fresh login to ensure cookie reflects new role. + await login(jar); + console.log('→ logged in (pre-2FA)'); + + const secret = await setupTotp(jar); + console.log(`→ totp secret generated`); + + await enableTotp(jar, secret); + console.log('→ totp enabled'); + + writeFileSync( + CREDS_PATH, + JSON.stringify( + { + email: ADMIN.email, + username: ADMIN.username, + password: ADMIN.password, + totp_secret_base32: secret + }, + null, + 2 + ) + ); + console.log(`✅ wrote ${CREDS_PATH}`); +} + +main().catch((e) => { + console.error('❌ bootstrap failed:', e); + process.exit(1); +}); diff --git a/e2e/setup/db.ts b/e2e/setup/db.ts new file mode 100644 index 0000000..aed0a15 --- /dev/null +++ b/e2e/setup/db.ts @@ -0,0 +1,59 @@ +// Shared DB helpers for E2E specs. Every spec that seeds fixtures or reads +// back post-condition state should route through here so we don't scatter +// `new pg.Client()` boilerplate + connection-URL fallbacks across 10 files. +import pg from 'pg'; + +export const PG_URL = + process.env.DATABASE_URL || 'postgres://skilluv:skilluv_secret@localhost:5433/skilluv'; + +/** + * Acquire a short-lived pg client, run `fn`, and close it — regardless of + * throw. Cheap to open on staging Postgres (~ms), keeps helpers linear. + */ +export async function withDb(fn: (client: pg.Client) => Promise): Promise { + const client = new pg.Client({ connectionString: PG_URL }); + await client.connect(); + try { + return await fn(client); + } finally { + await client.end(); + } +} + +/** + * Return a URL-safe token unique to the test. Used as suffix for + * emails/usernames/slugs so seeds don't collide across parallel runs. + */ +export function uniq(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); +} + +/** + * Insert a bare user with the columns the admin app needs (email, username, + * display_name, skill_domain). password_hash is a placeholder — this user + * cannot log in and doesn't need to for the flows we test. + */ +export interface SeedUserOptions { + role?: 'user' | 'enterprise' | 'admin' | 'mentor'; + totpEnabled?: boolean; + prefix?: string; +} +export async function seedUser(opts: SeedUserOptions = {}) { + const id = uniq(); + const prefix = opts.prefix ?? 'e2e'; + return withDb(async (client) => { + const email = `${prefix}-${id}@skilluv.test`; + const username = `${prefix}${id}`.slice(0, 30); + const display_name = `${prefix} ${id}`; + const totpSecret = opts.totpEnabled ? Buffer.alloc(20, 1) : null; + const { rows } = await client.query( + `INSERT INTO users + (email, username, password_hash, first_name, last_name, display_name, skill_domain, + role, totp_secret, totp_enabled) + VALUES ($1, $2, 'noop', 'First', 'Last', $3, 'code', $4, $5, $6) + RETURNING id`, + [email, username, display_name, opts.role ?? 'user', totpSecret, opts.totpEnabled ?? false] + ); + return { id: rows[0].id as string, email, username, display_name }; + }); +} diff --git a/e2e/setup/totp.mjs b/e2e/setup/totp.mjs new file mode 100644 index 0000000..d759341 --- /dev/null +++ b/e2e/setup/totp.mjs @@ -0,0 +1,12 @@ +import { TOTP, Secret } from 'otpauth'; + +// Backend uses the totp-rs default: SHA1, 6 digits, 30s period, RFC 6238. +export function currentCode(secretBase32) { + const totp = new TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: Secret.fromBase32(secretBase32) + }); + return totp.generate(); +} diff --git a/package-lock.json b/package-lock.json index 9ba14be..0d21abd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@vitest/coverage-v8": "^4.1.10", "argon2": "^0.45.0", "jsdom": "^29.1.1", + "otpauth": "^9.5.1", "pg": "^8.22.0", "svelte": "^5.56.7", "svelte-check": "^4.7.3", @@ -484,6 +485,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.139.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", @@ -2339,9 +2353,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, "license": "MIT", "engines": { @@ -3299,6 +3313,19 @@ "node": ">=12.20.0" } }, + "node_modules/otpauth": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", + "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", diff --git a/package.json b/package.json index 9477320..b92f576 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@vitest/coverage-v8": "^4.1.10", "argon2": "^0.45.0", "jsdom": "^29.1.1", + "otpauth": "^9.5.1", "pg": "^8.22.0", "svelte": "^5.56.7", "svelte-check": "^4.7.3", @@ -42,5 +43,8 @@ "dependencies": { "@lucide/svelte": "^1.25.0", "qrcode": "^1.5.4" + }, + "overrides": { + "cookie": "^0.7.2" } } diff --git a/playwright.config.ts b/playwright.config.ts index b265eec..f1a09ed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,11 @@ import { defineConfig, devices } from '@playwright/test'; +import { STORAGE_STATE } from './e2e/global-setup'; -// Smoke-only e2e config. Purpose: catch a broken deploy before it hits users -// (login redirect, guarded routes, key public pages render). Not an exhaustive -// suite — the interaction-heavy component tests live in Vitest. +// E2E config. Two project buckets: +// - `public` : specs that don't need auth (auth-redirect, login page render, …) +// - `admin` : specs that reuse an authenticated admin storageState produced +// by `global-setup.ts`. Requires backend on :3001 and the file +// `e2e/setup/admin-credentials.json` (see qa/README.md). export default defineConfig({ testDir: './e2e', timeout: 30_000, @@ -12,23 +15,26 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + globalSetup: './e2e/global-setup.ts', use: { - // baseURL points at the vite dev server (`npm run dev` sur :5174) — - // this is required by admin-back-e2e.spec.ts which hits `/api/*` routes - // proxied to a real backend on :3001. - // Smoke specs (auth-*, auth-redirect) also work at :5174 because vite's - // SSR calls hooks.server.ts which still 303-redirects unauthenticated - // visitors — no backend needed for those tests to pass. baseURL: 'http://127.0.0.1:5174', trace: 'retain-on-failure', - screenshot: 'only-on-failure' + screenshot: 'only-on-failure', + video: 'retain-on-failure' }, projects: [ { - name: 'chromium', + name: 'public', + testDir: './e2e', + testIgnore: ['admin/**', 'setup/**', 'global-setup.ts'], use: { ...devices['Desktop Chrome'] } + }, + { + name: 'admin', + testDir: './e2e/admin', + use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE } } ], diff --git a/qa/.trello.env.example b/qa/.trello.env.example new file mode 100644 index 0000000..8c6d0a3 --- /dev/null +++ b/qa/.trello.env.example @@ -0,0 +1,10 @@ +# Copy to qa/.trello.env (gitignored) and fill in. +# Token generation: https://trello.com/1/authorize?expiration=never&scope=read,write&response_type=token&key= +TRELLO_KEY=1d796368a17091cf7db265558e3b4422 +TRELLO_TOKEN= + +# Optional — bypass name lookup if you already know the board's shortLink +# TRELLO_BOARD_ID=DgCwxpV7 + +# Default board name (only used if TRELLO_BOARD_ID isn't set) +# TRELLO_BOARD_NAME=Skilluv - QA & Bugs Admin diff --git a/qa/AUDIT_ADMIN.md b/qa/AUDIT_ADMIN.md new file mode 100644 index 0000000..a0a07b7 --- /dev/null +++ b/qa/AUDIT_ADMIN.md @@ -0,0 +1,189 @@ +# Skilluv Admin Frontend — Audit d'API Complet + +**Date:** 2026-07-22 +**Environnement:** SvelteKit + TypeScript +**Scope:** Inventaire exhaustif des appels backend par route + +--- + +## 1. Guards & Auth (hooks.server.ts) + +**Validation globale:** GET /api/auth/me (cookie token) → 303 redirect si !user ou role≠'admin' + +--- + +## 2. Pages & Appels API — Résumé par Route + +### / Dashboard +- GET /api/admin/stats → Platform stats +- GET /api/admin/moderation-dashboard → Legacy moderation KPIs +- GET /api/admin/dashboard-overview → Business metrics (MRR, hires, signups) +- GET /api/admin/dashboard-financial → Revenue, invoices, purchases +- GET /api/admin/dashboard-moderation-queue → Queue counts (reports, KYC, sponsored, bans) +- GET /api/admin/dashboard-health → DB pool, WebSocket, error events + +### /auth/login +- POST /api/auth/login {identifier, password, totp_code?} → {user, login_method, has_passkey, requires_totp_setup} + +### /auth/setup-2fa +- GET /api/auth/totp-setup → {otpauth_url, secret_base32} +- POST /api/auth/totp-enable {code} → {backup_codes[]} + +### /auth/recovery-2fa +- POST /api/auth/login {identifier, password, backup_code} → {user} + +### /tenants & /tenants/[id] +- GET /api/tenants → {tenants: TenantSummary[]} +- POST /api/tenants {slug, name, contact_email, plan, max_users, primary_color, logo_url, subdomain?} → {tenant_id} +- GET /api/tenants/{id} → TenantFull +- PATCH /api/tenants/{id} {name, subdomain, custom_domain, logo_url, primary_color, secondary_color, plan, max_users, active} → 204 +- GET /api/tenants/{id}/members → {members: TenantMember[]} +- POST /api/tenants/{id}/members {user_id, role} → 201 +- GET /api/tenants/{id}/cohorts → {cohorts: TenantCohort[]} +- POST /api/tenants/{id}/cohorts {name, starts_at?, ends_at?} → 201 + +### /users & /users/[id] +- GET /api/admin/users {q?, banned?, page, per_page} → {data: UserRow[], pagination} +- POST /api/admin/users/{id}/ban {reason} → 204 +- POST /api/admin/users/{id}/unban → 204 +- GET /api/admin/users/{id} → {user, reports_against, total_submissions} +- POST /api/admin/users/{id}/reset-2fa {reason} → 204 + +### /enterprises & /enterprises/[id] +- GET /api/admin/enterprises {type?, verified?, page, per_page} → {data: EnterpriseAdmin[], pagination} +- GET /api/admin/enterprises/{id} → {enterprise: EnterpriseAdmin} +- GET /api/admin/enterprises/{id}/type-config → {type_config: {}} +- GET /api/admin/enterprises/{id}/agency-clients → {clients: AgencyClient[]} +- PATCH /api/admin/enterprises/{id}/type?dry_run=true {enterprise_type, reason} → {dry_run_preview} +- PATCH /api/admin/enterprises/{id}/type?dry_run=false {enterprise_type, reason} → 204 + +### /challenges +- GET /api/admin/challenges → {challenges: Challenge[], total} +- POST /api/admin/challenges {title, description, instructions, skill_domain, difficulty, mode, duration_minutes, ai_allowed, tone, language, prerequisite_fragments, reward_fragments, is_onboarding, expected_output, test_cases} → 201 +- PATCH /api/admin/challenges/{id} {subset of fields} → 204 +- POST /api/admin/challenges/{id}/publish → 204 +- POST /api/admin/challenges/{id}/archive → 204 + +### /reports +- GET /api/admin/reports {status?, page, per_page} → {data: ReportEntry[], pagination} +- POST /api/admin/reports/{id}/resolve {status: 'resolved'|'dismissed'} → 204 + +### /audit-log +- GET /api/admin/audit-log {page, per_page} → {data: LegacyEntry[], pagination} +- GET /api/admin/audit-log-generic {actor_type?, actor_id?, action?, target_type?, target_id?, page, per_page} → {data: AuditGenericEntry[]} + +### /enterprise-kyc +- GET /api/admin/kyc-queue → {queue: KycEntry[]} +- POST /api/admin/kyc/{enterprise_id}/decide {action: 'approve'|'reject', level?, reason?} → 204 + +### /fraud +- GET /api/admin/fraud-queue {threshold, limit} → {flagged_deliverables[], suspected_users[]} +- POST /api/admin/deliverables/{id}/mark-valid → 204 +- POST /api/admin/deliverables/{id}/revoke {reason} → 204 +- POST /api/admin/fraud-detect {window_hours, min_group_size} → {groups_detected, users_flagged, groups[]} +- POST /api/admin/users/{id}/mark-valid → 204 +- POST /api/admin/deliverables/{id}/scan {threshold, window_days} → {best_score, compared_count, best_match_id?} +- POST /api/admin/deliverables/{id}/llm-eval → {new_status, score?, notes?, llm_reachable} + +### /operations +- POST /api/admin/jobs/rebuild-leaderboards → {} +- POST /api/admin/jobs/digest → {digest} +- POST /api/admin/jobs/hidden-gems → {job_id} +- POST /api/admin/jobs/churn → {job_id} +- POST /api/admin/jobs/proof-sweep {within_days, dry_run} → {would_process_count}|{processed_count} +- POST /api/admin/gdpr-export {user_id, reason} → {} +- POST /api/admin/sync-github {user_id} → {sync} +- POST /api/admin/guilds/{id}/dissolve {reason} → 204 +- POST /api/admin/wars/{id}/conclude {winner_guild_id} → {} +- GET /api/admin/accounting-export?year={Y}&month={M} → CSV file + +### /projects +- GET /api/admin/projects {is_flagship?, curated_by_admin?, partnership_level?, include_archived, page, per_page} → {data: ProjectListItem[], pagination} +- POST /api/admin/projects {slug, name, description, repo_url, demo_url, tech_stack[], is_oss, looking_for_contributors, owner_type, owner_id, curated_by_admin, is_flagship, flagship_steward_user_id, skilluv_partnership_level, skilluv_editorial_notes} → 201 +- GET /api/admin/projects/{slug} → ProjectFull +- PATCH /api/admin/projects/{slug} {subset} → 204 +- POST /api/admin/projects/{slug}/archive → 204 + +### /skills +- GET /api/admin/skills {domain?, q?, is_skilluv_specific?, page, per_page} → {data: SkillNodeAdmin[], pagination} +- POST /api/admin/skills {slug, display_name, description, domain, parent_id, aliases[], external_refs{}, is_skilluv_specific} → 201 +- PATCH /api/admin/skills/{id} {display_name, description, domain, parent_id, aliases, external_refs, is_skilluv_specific} → 204 + +### /sponsored-challenges +- GET /api/admin/sponsored-requests → {requests: SponsoredRequest[]} +- POST /api/admin/sponsored-requests/{id}/decide {action, admin_notes?} → 204 +- POST /api/admin/sponsored-requests/{id}/link {challenge_id, sponsor_logo_url?, sponsor_blurb?, sponsor_visible_until (ISO), free_contact_until (ISO)} → 204 + +### /sso-sessions +- GET /api/admin/sso-sessions {enterprise_id?, page, per_page} → {data: SsoSession[], pagination} +- POST /api/admin/sso-sessions/{id}/revoke {reason} → 204 + +### /community +- GET /api/admin/community-review → {challenges: CommunityEntry[]} +- POST /api/admin/community-challenges/{id}/approve → 204 +- POST /api/admin/community-challenges/{id}/reject {feedback} → 204 + +--- + +## 3. Actions UI Principales + +| Zone | Action | Effet | +|------|--------|-------| +| Dashboard | Cards cliquables | Navigate to reports, KYC, sponsored, challenges, community, tenants | +| Auth | Sign in | POST login; 2FA setup si requis | +| Tenants | + New / Save / Add Member | CRUD tenants; manage members/cohorts | +| Users | Search / Ban / Unban | Filter list; POST ban/unban; detail view | +| Enterprises | Filters + Change Type | List; POST type change (dry-run/commit) | +| Challenges | Create / Edit / Publish / Archive | Modal CRUD; status transitions | +| Reports | Filters + Resolve/Dismiss | List; POST resolve status | +| Audit | Mode toggle + Filters | Legacy vs generic view; detail modal | +| KYC | Approve / Reject | Modal decide; POST decision | +| Fraud | Mark Valid / Revoke / Scan / Detect | Queue actions; plagiarism+multiacccount+eval tabs | +| Operations | Job triggers + GDPR + Dissolve | POST jobs; confirm dialogs | +| Projects | Filters + Create / Edit / Archive | CRUD; partnership levels | +| Skills | Create / Edit / Copy ID | Node taxonomy CRUD | +| Sponsored | Decide / Link Challenge | Modal approve/reject/negotiate; POST link | +| SSO | Filter + Revoke | List sessions; POST revoke | +| Community | Approve / Reject | List + POST actions | + +--- + +## 4. Confirmation Dialogs (Destructive) + +- Ban user → require reason ≥ 8 chars +- Revoke session/deliverable → require reason +- Reject KYC/sponsored → require feedback +- Dissolve guild → require reason +- Reset 2FA → reason required (admin only) + +--- + +## 5. Patterns de Chargement + +- **Pagination:** Reset page=1 on filter change +- **Lazy tabs:** Members/Cohorts load on tab switch +- **Modals:** Reset form on close +- **Live filters:** Segmented controls; local filter (no reload) +- **Toast feedback:** All mutations confirm to user + +--- + +## 6. Notes d'Audit + +**Appels totaux:** 80+ endpoints +**Routes:** 21 pages principales +**Patterns:** CRUD (C/R/U/D), Job triggers, Moderation, Fraud detection +**Auth:** Role-based + 2FA mandatory +**UI:** SvelteKit + TypeScript; forms, tables, modals, segmented controls + +**Pour tests Playwright:** +- Login flow + 2FA setup +- Core CRUD (tenants, users, challenges, enterprises) +- Destructive ops (ban, revoke, dissolve) +- Modal confirmations +- Pagination & filtering +- Job triggers (digest, proof sweep, GDPR) + +--- + +**Fin du document audit. Croiser avec backend API spec pour tests d'intégration complets.** \ No newline at end of file diff --git a/qa/AUDIT_BACKEND.md b/qa/AUDIT_BACKEND.md new file mode 100644 index 0000000..382b742 --- /dev/null +++ b/qa/AUDIT_BACKEND.md @@ -0,0 +1,168 @@ +# Skilluv Backend Rust Audit — Complete Routes & Admin Analysis + +**Date:** 2024-07-22 | **Framework:** Axum | **Database:** PostgreSQL | **Auth:** JWT + +--- + +## 1. Setup Staging + +**Prerequisites:** PostgreSQL 15+, Redis 7+, MinIO S3-compatible storage + +`ash +docker compose -f docker-compose.prod.yml -f docker-compose.staging.yml up -d +` + +**Ports:** Backend 8000 | MailHog SMTP 1025 | MailHog Web 8025 | PostgreSQL 5432 | Redis 6379 + +**Key Env Vars:** +- DATABASE_URL=postgresql://user:pass@localhost/skilluv_staging +- REDIS_URL=redis://localhost:6379 +- JWT_SECRET= +- ADMIN_ORIGINS=http://admin.localhost:5175,http://localhost:5174 +- ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174,http://admin.localhost:5175 +- EMAIL_FROM=noreply@staging.skilluv.com + +--- + +## 2. Auth & Admin Access Control + +### Authentication Flow +1. POST /api/auth/login (email + password) → JWT in access_token cookie (user) or admin_access_token (admin) +2. JWT Claims: {sub: user-uuid, role: admin|user|..., login_method: password|sso|oauth, exp: timestamp} +3. Cookie isolation: admin_access_token isolated from access_token for origin isolation (XSS defense) + +### Admin Gate Middleware (Two Layers) + +**Layer 1: ensure_admin_origin (BE-C)** +- Validates Origin header against ADMIN_ORIGINS env +- Returns 403 AUTH_ADMIN_ORIGIN_REQUIRED if mismatch +- Defense beyond CORS (browser-enforced) + +**Layer 2: ensure_admin_2fa (BE-A)** +- Requires: role='admin' MUST have TOTP enabled OR WebAuthn credentials +- Returns 403 AUTH_ADMIN_2FA_SETUP_REQUIRED if neither +- Soft gate allows setup during login + +### Role Authorization +- All admin handlers: require_capability(&state.db, auth.user_id, "admin") +- Queries user_capabilities table (canonical since P21.1) +- Rejects 403 Forbidden if missing/revoked +- Rate-limited destructive actions via admin_destructive middleware (10/min, 100/hr) + +--- + +## 3. Admin Routes Inventory + +### 3.1 Main Admin (src/routes/admin.rs) + +POST /admin/challenges — Create (draft status) +GET /admin/challenges — List all +PUT /admin/challenges/{id} — Update (partial) +POST /admin/challenges/{id}/publish — Publish (enforces rule#1) +POST /admin/challenges/{id}/archive — Archive +POST /admin/challenges/{id}/variant — AI variant (IA-C.1) +GET /admin/stats — KPIs +POST /admin/leaderboards/rebuild — Seed Redis +GET /admin/audit-log/generic — Unified audit (P1.18) +GET /admin/sso/sessions — Active SSO sessions +POST /admin/sso/sessions/{id}/revoke — Kill SSO +POST /admin/users/{id}/reset-2fa — Wipe 2FA (BE-B) + +### 3.2 Moderation (src/routes/admin_moderation.rs) + +GET /admin/users — List +GET /admin/users/{id} — Detail +POST /admin/users/{id}/ban — Ban user +POST /admin/users/{id}/unban — Unban +GET /admin/reports — Moderation queue +PUT /admin/reports/{id} — Handle report +GET /admin/audit-log — Legacy audit +GET /admin/dashboard/moderation — Moderation KPIs + +### 3.3 Fraud (src/routes/admin_fraud.rs) + +GET /admin/fraud/queue — Flagged items +POST /admin/fraud/deliverables/{id}/mark-valid — Clear flags +POST /admin/fraud/deliverables/{id}/revoke — Revoke +POST /admin/fraud/users/{id}/mark-valid — Clear suspicion +POST /admin/fraud/scan-deliverable/{id} — Plagiarism check +POST /admin/fraud/detect-multi-accounts — Multi-account detection +POST /admin/fraud/llm-evaluate/{id} — LLM eval +POST /admin/fraud/deep-scan/{id} — Deep scan (IA-B) + +### 3.4 User Mgmt (src/routes/admin_users.rs) + +POST /admin/users/{id}/recompute-proofs — Batch recompute (BE-D) +POST /admin/users/{id}/rank-override — Force rank + +### 3.5 Dashboard (src/routes/admin_dashboard.rs) + +GET /admin/dashboard/overview — KPIs +GET /admin/dashboard/financial — Financial metrics +GET /admin/dashboard/moderation-queue — Queue stats +GET /admin/dashboard/health — Health check + +### 3.6 Enterprises (src/routes/admin_enterprises.rs) + +GET /admin/enterprises — List +GET /admin/enterprises/{id} — Detail +PATCH /admin/enterprises/{id}/type — Change type +GET /admin/enterprises/{id}/type-config — Config +GET /admin/enterprises/{id}/agency-clients — Clients + +### 3.7 Community (src/routes/admin_community.rs) + +GET /admin/community/review — Review queue +POST /admin/community/{id}/approve — Approve +POST /admin/community/{id}/reject — Reject + +### 3.8 Orientations (src/routes/admin_orientations.rs) + +POST /admin/orientations — Create +PATCH /admin/orientations/{slug} — Edit +POST /admin/orientations/{slug}/skills — Attach skill +DELETE /admin/orientations/{slug}/skills/{skill_id} — Detach skill + +### 3.9 Skills (src/routes/admin_skills.rs) + +GET /admin/skills — List +POST /admin/skills — Create +PUT /admin/skills/{id} — Update + +### 3.10 Badge Rules (src/routes/admin_badge_rules.rs) + +POST /admin/badge-rules — Create +PATCH /admin/badge-rules/{slug} — Edit +POST /admin/badge-rules/{slug}/deprecate — Deprecate + +### 3.11 Ops (src/routes/admin_ops.rs) + +POST /admin/proof-hooks/sweep — Batch sweep (BE-D) +POST /admin/users/{id}/gdpr-export — GDPR export +GET /admin/badge-events — List events +POST /admin/badge-events — Create event +POST /admin/users/{id}/recompute-capabilities — Recompute capabilities + +--- + +## 4. Security Strengths + +✅ JWT signed with JWT_SECRET (no unsigned) +✅ Two-factor admin gate (origin + 2FA mandatory) +✅ Audit trail for destructive actions +✅ CORS origin allowlist (env-driven) +✅ Rate limiting (10/min, 100/hr destructive) +✅ Dry-run mode (?dry_run=true) +✅ Capability-based auth (user_capabilities) + +--- + +## 5. Observations + +⚠️ Origin validation header-based (spoofable same-origin; mitigated by CORS) +⚠️ GET /admin/challenges no pagination (concern for large datasets) +⚠️ Legacy admin_audit_log + unified audit_log (consolidation needed) + +--- + +**End Audit — 2024-07-22** diff --git a/qa/AUDIT_COVERAGE.md b/qa/AUDIT_COVERAGE.md new file mode 100644 index 0000000..981c9ea --- /dev/null +++ b/qa/AUDIT_COVERAGE.md @@ -0,0 +1,66 @@ +# Coverage Playwright — suivi par module + +Marquer : ⬜ à faire · 🟡 partiel · ✅ couvert · ⛔ bloqué (bug back) + +## Existant (avant workflow QA) + +| Fichier | Scope | +|---|---| +| `e2e/auth-redirect.spec.ts` | ✅ Redirects sans auth (14 routes) | +| `e2e/auth-pages.spec.ts` | ✅ Rendu login + setup/recovery 2FA (3 tests) | +| `e2e/admin-back-e2e.spec.ts` | ✅ Probe intégration back (login, catalog, enterprises) | + +## Phase 1 — Smoke (nav + guards) — ✅ 18/18 + +Couvert par `e2e/admin/nav-smoke.spec.ts` (data-driven sur toutes les routes). + +| Route | Test | +|---|---| +| `/` Dashboard | ✅ | +| `/auth/login` | ✅ (auth-pages) | +| `/auth/setup-2fa` | ✅ (auth-pages shell) | +| `/auth/recovery-2fa` | ✅ (auth-pages) | +| `/tenants` | ✅ | +| `/tenants/[id]` | ⬜ (dépend d'un tenant existant en DB) | +| `/users` | ✅ | +| `/users/[id]` | ⬜ (dépend d'un user existant) | +| `/enterprises` | ✅ | +| `/enterprises/[id]` | ⬜ (dépend d'une entreprise existante) | +| `/challenges` | ✅ | +| `/reports` | ✅ | +| `/audit-log` | ✅ | +| `/enterprise-kyc` | ✅ | +| `/fraud` | ✅ | +| `/operations` | ✅ | +| `/catalog` | ✅ | +| `/projects` | ✅ | +| `/skills` | ✅ | +| `/sponsored-challenges` | ✅ | +| `/sso-sessions` | ✅ | +| `/tournaments` | ✅ | +| `/community` | ✅ | + +## Phase 2 — Parcours critiques + +| # | Parcours | Statut | Spec | +|---|---|---|---| +| 1 | Login + 2FA (UI end-to-end) | ✅ | `e2e/login-2fa.spec.ts` | +| 2a | User : search + ban + unban (UI natif) + DB check | ✅ | `e2e/admin/user-ban-unban.spec.ts` — 2 bugs trouvés + fixés | +| 2c | User : reset-2fa (regression guard UI + API E2E) | ✅ | `e2e/admin/reset-2fa.spec.ts` — bug P0 auth trouvé + fixé, bug back en attente | +| 3 | Reports : resolve + dismiss | ⬜ | nécessite un report seedé | +| 4 | Challenge : create draft (API) → publish (UI) → archive (UI) | ✅ | `e2e/admin/challenge-lifecycle.spec.ts` | +| 5 | Enterprise : change type dry-run → commit | ⬜ | nécessite entreprise seedée | +| 6 | KYC : approve + reject | ⬜ | nécessite entreprise + docs seedés | +| 7 | Sponsored : decide → link challenge | ⬜ | nécessite sponsored request seedée | +| 8 | SSO session : revoke | ⬜ | nécessite session SSO active | +| 9 | Community : approve / reject | ⬜ | nécessite submission seedée | +| 10 | Fraud : scan / mark valid / revoke | ⬜ | nécessite deliverable seedé | + +## Phase 3 — Exhaustive (à ouvrir plus tard) + +- CRUD complet tenants / projects / skills / orientations / badge-rules +- Jobs ops (digest, sweep, GDPR, hidden-gems, churn) +- Pagination + filtres sur toutes les listes +- Modal confirmations + validation reason ≥ 8 chars +- Rate limits admin destructifs +- Audit log entries après chaque mutation diff --git a/qa/AUDIT_MAPPING.md b/qa/AUDIT_MAPPING.md new file mode 100644 index 0000000..1c43cfc --- /dev/null +++ b/qa/AUDIT_MAPPING.md @@ -0,0 +1,231 @@ +# Mapping Admin ↔ Backend — Source de vérité + +> Croisement des appels **réels** du front (source : `src/lib/api/admin.ts` — 977 lignes) avec les routes déclarées du backend Rust. +> +> ⚠️ Cet audit remplace l'audit front initial qui contenait des hallucinations d'URL. Le contrat authoritative front est `src/lib/api/admin.ts`, pas les pages `+page.svelte` (qui ne font jamais de `fetch()` direct — elles passent par ce client). + +## Légende + +- ✅ Mapping OK (route back existe et prefix matche) +- ⚠️ Warning (route existe mais gate/protection à vérifier) +- ❌ Gap — appel front sans route back correspondante +- 🔒 Route back existante non consommée par le front + +--- + +## 1. Auth & session + +| Front (`admin.ts` / login) | Back | Statut | +|---|---|---| +| POST `/api/auth/login` | POST `/api/auth/login` (auth.rs) | ✅ | +| GET `/api/auth/me` (hooks.server.ts guard) | GET `/api/auth/me` | ✅ | +| GET `/api/auth/totp-setup` | à confirmer dans auth.rs | ✅ (setup 2FA existe) | +| POST `/api/auth/totp-enable` | idem | ✅ | + +## 2. Users & moderation + +| Front | Back (admin_moderation.rs / admin.rs) | Statut | +|---|---|---| +| GET `/admin/users` | GET `/admin/users` | ✅ | +| GET `/admin/users/{id}` | GET `/admin/users/{id}` | ✅ | +| POST `/admin/users/{id}/ban` | POST `/admin/users/{id}/ban` | ✅ | +| POST `/admin/users/{id}/unban` | POST `/admin/users/{id}/unban` | ✅ | +| POST `/admin/users/{id}/reset-2fa` | POST `/admin/users/{id}/reset-2fa` (admin.rs) | ✅ | +| GET/POST/DELETE `/admin/users/{id}/capabilities[...]` | à vérifier dans capabilities.rs | ⚠️ | +| POST `/admin/users/{id}/recompute-proofs` | POST idem (admin_users.rs) | ✅ | +| POST `/admin/users/{id}/rank-override` | POST idem | ✅ | +| POST `/admin/users/{id}/gdpr-export` | POST idem (admin_ops.rs) | ✅ | +| POST `/admin/users/{id}/recompute-capabilities` | POST idem (admin_ops.rs) | ✅ | + +## 3. Reports & audit + +| Front | Back | Statut | +|---|---|---| +| GET `/admin/reports` | GET `/admin/reports` | ✅ | +| PUT `/admin/reports/{id}` | PUT `/admin/reports/{id}` | ✅ | +| GET `/admin/audit-log` | GET idem (legacy) | ✅ | +| GET `/admin/audit-log/generic` | GET idem (admin.rs) | ✅ | + +## 4. Fraud + +| Front | Back (admin_fraud.rs) | Statut | +|---|---|---| +| GET `/admin/fraud/queue` | GET idem | ✅ | +| POST `/admin/fraud/deliverables/{id}/mark-valid` | POST idem | ✅ | +| POST `/admin/fraud/deliverables/{id}/revoke` | POST idem | ✅ | +| POST `/admin/fraud/users/{id}/mark-valid` | POST idem | ✅ | +| POST `/admin/fraud/scan-deliverable/{id}` | POST idem | ✅ | +| POST `/admin/fraud/detect-multi-accounts` | POST idem | ✅ | +| POST `/admin/fraud/llm-evaluate/{id}` | POST idem | ✅ | +| — | POST `/admin/fraud/deep-scan/{id}` | 🔒 non consommé | + +## 5. Dashboard + +| Front | Back (admin_dashboard.rs + admin.rs) | Statut | +|---|---|---| +| GET `/admin/stats` | GET `/admin/stats` | ✅ | +| GET `/admin/dashboard/moderation` | GET idem | ✅ | +| GET `/admin/dashboard/overview` | GET idem | ✅ | +| GET `/admin/dashboard/financial` | GET idem | ✅ | +| GET `/admin/dashboard/moderation-queue` | GET idem | ✅ | +| GET `/admin/dashboard/health` | GET idem | ✅ | + +## 6. Challenges (core admin) + +| Front | Back (admin.rs) | Statut | +|---|---|---| +| GET `/admin/challenges` | GET idem | ✅ | +| POST `/admin/challenges` | POST idem | ✅ | +| PUT `/admin/challenges/{id}` | PUT idem | ✅ | +| POST `/admin/challenges/{id}/publish` | POST idem | ✅ | +| POST `/admin/challenges/{id}/archive` | POST idem | ✅ | +| — | POST `/admin/challenges/{id}/variant` | 🔒 IA-C.1 non exposé côté UI | + +## 7. Community moderation + +| Front | Back (admin_community.rs) | Statut | +|---|---|---| +| GET `/admin/community/review` | GET idem | ✅ | +| POST `/admin/community/{id}/approve` | POST idem | ✅ | +| POST `/admin/community/{id}/reject` | POST idem | ✅ | + +## 8. Enterprises + KYC + SSO + +| Front | Back | Statut | +|---|---|---| +| GET `/admin/enterprises` | GET idem (admin_enterprises.rs) | ✅ | +| GET `/admin/enterprises/{id}` | GET idem | ✅ | +| GET `/admin/enterprises/{id}/type-config` | GET idem | ✅ | +| GET `/admin/enterprises/{id}/agency-clients` | GET idem | ✅ | +| PATCH `/admin/enterprises/{id}/type?dry_run=...` | PATCH idem | ✅ | +| GET `/admin/enterprise-kyc` | GET idem (enterprise_kyc.rs) | ✅ | +| POST `/admin/enterprise-kyc/{id}/decide` | POST idem | ✅ | +| GET `/admin/sso/sessions` | GET idem (admin.rs) | ✅ | +| POST `/admin/sso/sessions/{id}/revoke` | POST idem | ✅ | + +## 9. Sponsored challenges + +| Front | Back (sponsored_challenges.rs) | Statut | +|---|---|---| +| GET `/admin/sponsored-challenges` | GET idem | ✅ | +| POST `/admin/sponsored-challenges/{id}/decide` | POST idem | ✅ | +| POST `/admin/sponsored-challenges/{id}/link` | POST idem | ✅ | + +## 10. Projects (flagships + OSS partners) + +| Front | Back (projects.rs) | Statut | +|---|---|---| +| GET `/admin/projects?filters` | à confirmer (routes admin projets multiples) | ⚠️ | +| GET `/admin/projects/{slug}` | à confirmer | ⚠️ | +| POST `/admin/projects` | à confirmer | ⚠️ | +| PATCH `/admin/projects/{slug}` | à confirmer | ⚠️ | +| DELETE `/admin/projects/{slug}` (archive) | POST `/admin/projects/{slug}/archive` existe | ⚠️ mismatch DELETE vs POST | + +## 11. Skills catalog + +| Front | Back (admin_skills.rs) | Statut | +|---|---|---| +| GET `/admin/skills` | GET idem | ✅ | +| POST `/admin/skills` | POST idem | ✅ | +| PUT `/admin/skills/{id}` | PUT idem | ✅ | + +## 12. Orientations + +| Front | Back (admin_orientations.rs) | Statut | +|---|---|---| +| POST `/admin/orientations` | POST idem | ✅ | +| PATCH `/admin/orientations/{slug}` | PATCH idem | ✅ | +| POST `/admin/orientations/{slug}/skills` | POST idem | ✅ | +| DELETE `/admin/orientations/{slug}/skills/{id}` | DELETE idem | ✅ | + +## 13. Badge rules + events + +| Front | Back (admin_badge_rules.rs + admin_ops.rs) | Statut | +|---|---|---| +| POST `/admin/badge-rules` | POST idem | ✅ | +| PATCH `/admin/badge-rules/{slug}` | PATCH idem | ✅ | +| POST `/admin/badge-rules/{slug}/deprecate` | POST idem | ✅ | +| GET/POST `/admin/badge-events` | GET/POST idem | ✅ | + +## 14. Seasons + tournaments + +| Front | Back (seasons.rs + tournament.rs) | Statut | +|---|---|---| +| POST `/admin/seasons` | POST idem | ✅ | +| POST `/admin/seasons/{id}/status` | ⚠️ back = `/admin/seasons/{slug}/activate` — non aligné | ❌ **mismatch** | +| POST `/admin/seasons/{id}/close` | à confirmer (route existe ligne 39) | ⚠️ | +| POST `/admin/tournaments` | POST idem | ✅ | +| POST `/admin/tournaments/{id}/status` | à confirmer | ⚠️ | +| POST `/admin/tournaments/{id}/score` | POST idem | ✅ | +| POST `/admin/tournaments/{id}/conclude` | POST idem | ✅ | + +## 15. Ops / jobs / integrations + +| Front | Back | Statut | +|---|---|---| +| POST `/admin/leaderboards/rebuild` | POST idem (admin.rs) | ✅ | +| POST `/admin/proof-hooks/sweep` | POST idem (admin_ops.rs) | ✅ | +| POST `/admin/ai/hidden-gems` | POST idem (ai_jobs.rs) | ✅ | +| POST `/admin/ai/churn` | POST idem (ai_jobs.rs) | ✅ | +| POST `/admin/digest/run-weekly` | POST idem (email_prefs.rs) | ⚠️ **hors admin_gate — voir BUGS_BACK P1** | +| POST `/admin/github/sync/{userId}` | POST idem (github.rs) | ⚠️ **hors admin_gate — voir BUGS_BACK P1** | +| GET `/api/admin/accounting/export` | GET idem (legal_well_known.rs) | ⚠️ **hors admin_gate — voir BUGS_BACK P1** | +| POST `/admin/guilds/{id}/dissolve` | POST idem (guild.rs) | ⚠️ hors admin_gate mais handler check capability | + +## 16. Tenants + +| Front | Back (tenants.rs) | Statut | +|---|---|---| +| GET `/admin/tenants` | GET idem | ✅ | +| POST `/admin/tenants` | POST idem | ✅ | +| GET `/admin/tenants/{id}` | GET idem | ✅ | +| PUT `/admin/tenants/{id}` (front = PATCH?) | back = PUT | ⚠️ à vérifier verbe HTTP côté front | +| Members / Cohorts (si UI présente) | à confirmer routes back | ⚠️ | + +--- + +## Récapitulatif gaps + +### Vrais bugs back (à envoyer à l'équipe backend) + +1. **[P1] `/admin/digest/run-weekly`, `/admin/github/sync/{id}`, `/api/admin/accounting/export` hors `admin_gate`** → défense en profondeur incomplète. Voir `BUGS_BACK.md`. +2. **[P1] Seasons `/status` vs `/activate`** — mismatch de nom d'endpoint entre front et back. + +### À valider en test + +- Endpoints `/admin/projects/*` : lister les vraies routes back et confirmer verbe HTTP (front semble utiliser DELETE, back utilise POST `.../archive`) +- Endpoints tenants members/cohorts si l'UI les expose +- Endpoints seasons `/close` + tournaments `/status` + +### Non consommés côté front (à décider : implémenter UI ou marquer volontaire) + +- POST `/admin/challenges/{id}/variant` (IA génération variante) +- POST `/admin/fraud/deep-scan/{id}` (IA scan profond) + +--- + +## Priorités de test Playwright + +**Phase 1 — Smoke (existe déjà partiellement dans `e2e/`) :** +- Auth guard + redirect (auth-redirect.spec.ts ✅ existe) +- Login page render (auth-pages.spec.ts ✅ existe) +- Admin-back integration probe (admin-back-e2e.spec.ts ✅ existe) + +**Phase 2 — Critical flows (à écrire) :** +1. Login + 2FA setup (nouveau compte admin) +2. Users : search, ban, unban, reset-2fa (avec reason ≥ 8 chars) +3. Reports : list, resolve, dismiss +4. Challenges : create draft, publish, archive +5. Enterprises : change type dry-run vs commit +6. KYC : approve / reject +7. Sponsored : decide + link +8. SSO sessions : revoke +9. Community : approve / reject +10. Fraud : scan deliverable, mark valid, revoke + +**Phase 3 — Exhaustive (module par module, une fois Phase 2 verte) :** +- CRUD complet tenants/projects/skills/orientations/badge-rules +- Jobs d'ops (digest, sweep, GDPR export, hidden-gems, churn) +- Pagination + filtres sur toutes les listes +- Confirmation dialogs (reason validation ≥ 8 chars) +- Rate limits admin destructifs (10/min, 100/hr) diff --git a/qa/BUGS_BACK.md b/qa/BUGS_BACK.md new file mode 100644 index 0000000..d80fa3d --- /dev/null +++ b/qa/BUGS_BACK.md @@ -0,0 +1,156 @@ +# Bugs Backend — skilluv-backend + +> Bugs et manques identifiés côté backend Rust. **À transmettre à l'équipe back.** + +## Template d'entrée + +``` +### [Pxx] Titre court +**Route :** MÉTHODE /path +**Fichier suspect :** src/routes/xxx.rs +**Détecté par :** test Playwright / audit statique / … +**Reproduction :** +1. … +**Attendu :** … +**Observé :** … +**Impact :** … +**Statut :** open | reported | fixed +``` + +--- + +## Ouverts + +### [P1] Routes admin non protégées par `admin_gate` middleware +**Routes concernées :** +- POST `/api/admin/digest/run-weekly` — déclarée dans `src/routes/email_prefs.rs` (nesté sans `admin_gate`) +- POST `/api/admin/github/sync/{user_id}` — déclarée dans `src/routes/github.rs` (nesté sans `admin_gate`) +- GET `/api/admin/accounting/export` — déclarée dans `src/routes/legal_well_known.rs`, mergée hors `admin_gate` + +**Détecté par :** audit statique (grep `.nest("/api", admin_gate(...))` dans `src/lib.rs`) + +**Attendu :** toute route sous `/admin/*` devrait passer par `admin_gate` (BE-C origin check + BE-A 2FA mandatory). + +**Observé :** les handlers font bien `require_capability("admin")` en interne (donc JWT+rôle vérifiés) mais : +- pas de validation de l'header `Origin` contre `ADMIN_ORIGINS` +- pas de gate 2FA middleware — un admin sans TOTP/WebAuthn actif peut appeler ces routes + +**Impact :** défense en profondeur incomplète. Un token admin volé + XSS sur origine non-admin permet d'appeler ces endpoints alors qu'ils sont supposés être bloqués par l'origin check. + +**Fix suggéré :** soit déplacer ces routes dans un module `admin_*` mergé dans `admin_routes()`, soit les envelopper dans un `.nest("/api", admin_gate(...))` séparé. + +**Statut :** fixed (backend commit 86688dc — wire 6 admin route modules that existed but were never nested) + +### [P1] `GET /api/admin/users/{id}` n'expose pas `totp_enabled` (ni webauthn) +**Route :** `GET /api/admin/users/{id}` — `src/routes/admin_moderation.rs` handler `get_user` (l.223) +**Détecté par :** test Playwright `e2e/admin/reset-2fa.spec.ts` + +**Attendu :** le front lit `user.totp_enabled` (dans `/users/[id]/+page.svelte` via `targetHasStrongFactor = user?.totp_enabled === true`) pour activer/désactiver le bouton "Réinitialiser la 2FA". Sans ce champ dans la réponse, le bouton est toujours grisé → l'UI est cassée même quand la cible a bien un TOTP configuré. + +**Observé :** la réponse actuelle sérialise `{id, email, username, display_name, skill_domain, role, title, total_fragments, streak_current, trust_score, country, email_verified, profile_active, is_banned, created_at}` — pas de `totp_enabled`, pas de compteur webauthn. + +**Fix suggéré :** ajouter `"totp_enabled": user.totp_enabled` dans le `json!` du handler, l.249. Idéalement aussi `"webauthn_credentials_count"` via un COUNT sur `webauthn_credentials WHERE user_id = $1` — nécessaire pour BE-B (le middleware admin_gate accepte TOTP OU WebAuthn). + +**Impact :** feature admin reset-2fa impossible depuis l'UI. Fonctionne uniquement en tapant l'API directement. + +**Statut :** fixed (backend commit 4e857ad — totp_enabled + webauthn_credentials_count now returned) + +### [P2] `GET /api/admin/users/{id}` n'expose pas `email_2fa_enabled` +**Même route.** Le champ existe en DB (`users.email_2fa_enabled`) mais n'est pas dans la réponse — pas bloquant côté UI actuelle mais lié au [P1] ci-dessus. + +**Statut :** fixed (backend commit 4e857ad — email_2fa_enabled exposed alongside totp_enabled) + +### [P1] Seasons — mismatch de nom d'endpoint front/back +**Route :** front appelle `POST /admin/seasons/{id}/status`, back expose `POST /admin/seasons/{slug}/activate` — `src/routes/seasons.rs` +**Détecté par :** croisement `src/lib/api/admin.ts` vs `src/routes/seasons.rs` dans AUDIT_MAPPING + +**Attendu :** un contrat unique — soit le front utilise `/activate`, soit le back expose aussi `/status`. + +**Observé :** l'appel front retourne 404 en runtime. La feature "changer le statut d'une saison depuis l'admin" est cassée dès qu'elle est utilisée. + +**Fix suggéré :** ajouter côté back une route `POST /admin/seasons/{id}/status` qui accepte `{status}` et route vers `activate_season` / futurs états. Ou aligner le front sur `/activate` si c'est la seule transition supportée. Confirmer avec le PO ce qui est attendu. + +**Note post-fix :** ma qualification était partiellement erronée — l'endpoint `/status` existait déjà. Le vrai problème était que `/admin/seasons/*` + `/admin/tournaments/*` vivaient dans `tournament_routes` (public) sans `admin_gate` (juste un check `auth.role != "admin"` inline). Backend a split en `admin_tournament_routes` + wiré derrière `admin_gate`. + +**Statut :** fixed (backend commit a099d30 — split admin_tournament_routes out of tournament_routes + nest with admin_gate) + +### [P1] `GET /admin/sso/sessions` renvoie `{data:{sessions:[…]}}` au lieu de `{data:[…]}` +**Route :** `GET /api/admin/sso/sessions` — `src/routes/admin.rs` handler `list_sso_sessions` (l.655) +**Détecté par :** test Playwright `e2e/admin/sso-revoke.spec.ts` + +**Attendu :** convention standard des listes paginées côté admin — `{data: T[], pagination: {…}, meta: {…}}` (comme `/admin/users`, `/admin/reports`, `/admin/projects`, etc.). Le front `AdminApi.listSsoSessions` type le retour comme `ApiPaginatedResponse` — donc `data` doit être un array. + +**Observé :** la réponse est `{data: {sessions: […]}, pagination, meta}`. Le front fait `sessions = res.data` → assigne un objet à une variable d'array → `{#each sessions}` itère rien → **liste SSO toujours vide dans l'UI, même quand des sessions existent en DB**. + +**Fix suggéré :** dans `list_sso_sessions`, remplacer : +```rust +Ok(Json(json!({ + "data": { "sessions": sessions }, // <- unwrap this nesting + "pagination": {...} +}))) +``` +par : +```rust +Ok(Json(json!({ + "data": sessions, + "pagination": {...} +}))) +``` + +**Impact :** feature "voir les sessions SSO actives" complètement cassée en prod. L'admin ne peut pas révoquer une session compromise via l'UI. Fallback : psql direct — pas acceptable. + +**Statut :** fixed (backend commit aa5e79b — unwrap nested `data.sessions` → `data: T[]`) + +### [P1] `POST /admin/community/{id}/approve` renvoie 500 si le challenge n'a ni `is_training=TRUE` ni `project_id` +**Route :** `POST /api/admin/community/{id}/approve` — `src/routes/admin_community.rs` handler `approve_challenge` (l.88) +**Détecté par :** test Playwright `e2e/admin/community-review.spec.ts` + +**Reproduction :** +1. Un user soumet un challenge communautaire (`is_community=TRUE`, `community_status='review'`) sans `is_training` ni `project_id` +2. Admin clique "Approuver" dans `/community` +3. Le handler fait `UPDATE ... SET status='published'` → violation de la check constraint `challenge_templates_project_or_training` +4. Réponse : HTTP 500 (au lieu de 400 propre, ou d'un fix côté approve) + +**Attendu :** soit le handler auto-set `is_training=TRUE` à l'approve (les challenges communautaires sont par nature du training), soit il retourne 400 avec un message clair "requires is_training or project_id". + +**Fix suggéré :** +```rust +UPDATE challenge_templates SET + community_status = 'approved', + status = 'published', + is_training = TRUE, -- <- ajouter cette ligne + updated_at = NOW() +WHERE id = $1 AND is_community = TRUE AND community_status = 'review' +``` +Ou valider en amont et renvoyer 400 sinon. + +**Impact :** feature "approuver un challenge communautaire" cassée pour la majorité des cas usage (personne n'attache un project_id à une soumission communautaire). + +**Statut :** fixed (backend commit d96bdb8 — pre-check business rule, return 400 with actionable message instead of 500) + +### [P2] Projects — front utilise `DELETE /admin/projects/{slug}`, back n'expose que `POST /admin/projects/{slug}/archive` +**Route :** `DELETE /admin/projects/{slug}` (front) vs `POST /admin/projects/{slug}/archive` (back) +**Détecté par :** AUDIT_MAPPING + +**Attendu :** un verbe HTTP + path aligné entre le front et le back pour l'action "archiver un projet". + +**Observé :** l'appel DELETE retourne probablement 405 Method Not Allowed. Feature archive projet cassée. + +**Fix suggéré :** aligner le front sur `POST .../archive` (le back reflète mieux la sémantique — archive n'est pas une suppression). Ou ajouter côté back une route `DELETE` qui alias sur archive. + +**Statut :** fixed (résolu indirectement par backend commit 86688dc — `admin_projects` module wiré expose `DELETE /admin/projects/{slug}` qui déclenche l'archive ; le front admin utilisait déjà DELETE, plus rien à changer) + +--- + +## Corrigés + +_(vide)_ + +--- + +## Notes d'audit — endpoints à valider en test d'intégration + +Endpoints existants côté back mais peu utilisés côté front à ce jour (vérifier qu'ils fonctionnent) : +- POST `/admin/challenges/{id}/variant` (IA-C.1) +- POST `/admin/fraud/deep-scan/{id}` (IA-B) +- POST `/admin/orientations/{slug}/skills` + DELETE `/admin/orientations/{slug}/skills/{skill_id}` diff --git a/qa/BUGS_FRONT.md b/qa/BUGS_FRONT.md new file mode 100644 index 0000000..9c12e99 --- /dev/null +++ b/qa/BUGS_FRONT.md @@ -0,0 +1,96 @@ +# Bugs Front — skilluv-admin + +> Bugs et manques identifiés côté admin front. Fixés au fur et à mesure dans ce repo. + +## Template d'entrée + +``` +### [Pxx] Titre court +**Page/Module :** … +**Détecté par :** test Playwright / audit manuel / … +**Reproduction :** +1. … +2. … +**Attendu :** … +**Observé :** … +**Fix proposé :** … +**Statut :** open | in_progress | fixed (commit) +``` + +--- + +## Ouverts + +_(aucun)_ + +--- + +## Corrigés + +### [P0] Deep-link vers /users/[id] et 6 autres pages redirige à tort vers /auth/login +**Pages affectées :** `/users/[id]`, `/tenants`, `/tenants/[id]`, `/enterprise-kyc`, `/operations`, `/sponsored-challenges`, `/tournaments` — toutes celles qui ont ce bloc dans leur `+page.svelte` : +```svelte +onMount(() => { + if (!auth.isAuthenticated) { + void goto(`/auth/login?redirect=…`); + return; + } + void load(); +}); +``` + +**Détecté par :** test Playwright `e2e/admin/reset-2fa.spec.ts` — le test navigue directement sur `/users/{id}` et est redirigé vers login alors que la session admin est valide. + +**Reproduction :** +1. Se logger admin (session valide côté SSR — hooks.server.ts OK) +2. Aller directement sur `/users/{n'importe-quel-id}` (deep-link, refresh du navigateur, ouverture d'un onglet…) +3. Redirigé vers `/auth/login?redirect=/users/…` + +**Cause :** race d'hydratation Svelte 5. +- `hooks.server.ts` remplit `locals.user` correctement +- `+layout.server.ts` propage `data.user` +- `+layout.svelte` hydrate le store `auth` via `$effect(() => auth.setUser(data.user))` +- **MAIS** `onMount` des pages enfants tourne AVANT que ce `$effect` ait migré `data.user` dans le store → `auth.user === null` → `auth.isAuthenticated === false` → redirect + +Le check `onMount` est aussi présent dans `+layout.svelte` (l.76-80) — même bug, juste caché parce que la plupart des navigations viennent d'une autre page admin où le store était déjà hydraté. + +**Impact prod :** +- Deep-links cassés (email de notif contenant `/users/{id}` → l'admin est déconnecté) +- Refresh du navigateur sur ces pages déloggue +- SEO/bookmarks cassés + +**Fix appliqué :** supprimé le check `onMount(!auth.isAuthenticated)` dans les 7 pages enfants ET dans `+layout.svelte`. `hooks.server.ts` (SSR) reste la source de vérité — dead code retiré, plus de race d'hydratation. Vérifié par `e2e/admin/reset-2fa.spec.ts` (test UI qui navigue direct sur /users/{id} et attend le rendu). + +**Statut :** fixed + +### [P1] `/users` : le badge "Banni" et le bouton "Débannir" ne s'affichent jamais +**Page/Module :** `/users` — src/routes/users/+page.svelte +**Détecté par :** test Playwright `e2e/admin/user-ban-unban.spec.ts` +**Reproduction :** +1. Bannir un user via l'UI (dialog valide, POST succès) +2. Recharger la page, filtrer sur ce user +3. Le badge "Banni" n'apparaît pas, le bouton reste "Bannir" + +**Attendu :** après ban en DB (colonne `is_banned=TRUE`), la ligne montre le badge "Banni" et le bouton "Débannir". + +**Observé :** le front lit `user.banned` mais le backend renvoie `is_banned`. `user.banned` est toujours `undefined` → toujours interprété comme non-banni. + +**Fix appliqué :** dans `src/routes/users/+page.svelte`, `UserRow.banned` renommé en `is_banned` et tous les usages mis à jour. + +**Statut :** fixed + +### [P1] `/users` : la mutation `banTarget.banned = true` ne re-rend pas le bloc bouton +**Page/Module :** `/users` — src/routes/users/+page.svelte +**Détecté par :** test Playwright `e2e/admin/user-ban-unban.spec.ts` +**Reproduction :** +1. Bannir un user via le dialog (dans une session déjà chargée) +2. Le badge "Banni" apparaît dans la ligne (via `{#if user.banned}` dans le bloc badge) +3. Mais le bloc du bouton reste "Bannir" — la mutation ne redéclenche pas ce bloc + +**Attendu :** après `banTarget.banned = true`, la ligne complète (badge + bouton) reflète l'état. + +**Observé :** seul le badge (`{#if user.banned}` inline dans le nom) se met à jour, pas le bloc bouton (`{#if user.banned}…{:else}…{/if}` en bas de la ligne). Probablement un souci de proxy Svelte 5 quand la clé `#each` n'existe pas — Svelte re-crée les items d'un `#each user of users` uniquement quand la référence de l'array change. + +**Fix appliqué :** `confirmBan` et `unban` appellent maintenant `await loadUsers()` au lieu de muter la propriété — la liste reflète l'état DB de manière autoritaire et le bloc bouton se re-rend correctement. + +**Statut :** fixed diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..a0d55c5 --- /dev/null +++ b/qa/README.md @@ -0,0 +1,62 @@ +# QA — Skilluv Admin + +Espace de suivi qualité pour le front admin + son intégration au backend Rust (staging). + +## Fichiers + +| Fichier | Rôle | +|--------|------| +| `AUDIT_ADMIN.md` | Inventaire des appels API du front (source : `src/lib/api/admin.ts` + pages) | +| `AUDIT_BACKEND.md` | Inventaire des routes admin exposées côté backend Rust | +| `AUDIT_MAPPING.md` | **Source de vérité** — croisement front↔back + gaps + endpoints à couvrir en test | +| `AUDIT_COVERAGE.md` | Suivi de couverture Playwright par page/module | +| `BUGS_FRONT.md` | Bugs identifiés côté admin front (fixés au fur et à mesure ici) | +| `BUGS_BACK.md` | Bugs côté backend — à transmettre à l'équipe back | +| `TODO_ADMIN.md` | Implémentations admin front à faire (nouveaux tests, expositions UI, améliorations) | +| `TODO_BACKEND.md` | Implémentations backend à demander (autre que bugs) | + +## Convention sévérité + +- **P0** : Bloquant (login/nav cassée, sécurité, corruption de données) +- **P1** : Fonctionnalité principale KO ou UX très dégradée +- **P2** : Petit bug / edge case / implémentation planifiée +- **P3** : Backlog long-terme (nice-to-have) + +## Workflow + +1. Lancer les tests Playwright (`npm run test:e2e`) +2. Trier chaque échec : bug front → `BUGS_FRONT.md` ; bug back → `BUGS_BACK.md` +3. `python qa/push-to-trello.py` — synchronise vers le board Trello (idempotent, à faire à chaque édition des .md) +4. Front : fixer directement + rebasculer le statut à `fixed` dans le .md +5. Back : la card apparaît côté équipe backend, ils fixent → change le statut à `fixed` chez eux → rerun du script déplace la card en `Fait` +6. Mettre à jour `AUDIT_COVERAGE.md` au fur et à mesure + +## Sync Trello + +**Board :** [Skilluv - QA & Bugs Admin](https://trello.com/b/DgCwxpV7/skilluv-qa-bugs-admin) + +**Structure :** +- **Listes :** `Backlog` (open), `À faire`, `En cours` (in_progress), `Review`, `Fait` (fixed) +- **Labels team :** `team:backend` (bleu), `team:frontend` (vert), `team:admin` (orange) +- **Labels type :** `type:bug` (rouge), `type:implementation` (violet), `type:other` (noir) +- **Labels priorité :** `P0` (rouge), `P1` (orange), `P2` (bleu ciel) + +**Setup local :** +```bash +cp qa/.trello.env.example qa/.trello.env +# éditer qa/.trello.env avec TRELLO_TOKEN (voir lien dans le fichier example) +python qa/push-to-trello.py +``` + +Le fichier `qa/.trello.env` est gitignored. Le script auto-load ce fichier s'il existe, sinon lit les variables d'env `TRELLO_KEY` / `TRELLO_TOKEN`. + +**Idempotence :** rerun-safe. Match par titre exact (`[Pxx] Titre`). Les cards existantes sont MISES À JOUR (description + labels + liste) — donc changer le `**Statut :**` d'un .md et rerun déplace la card entre listes. + +**Rétro-sync :** ne push que markdown → Trello, jamais l'inverse. Si le back édite une card Trello, il faut aussi éditer le .md pour rester source-of-truth. + +## Environnement staging backend + +- Backend Rust sur `:8000` (ou `:3001` en dev local via proxy vite) +- Base URL admin dev : `http://127.0.0.1:5174` +- Origin allowlist : `ADMIN_ORIGINS` env var (backend) +- Auth admin : JWT cookie `admin_access_token` + rôle `admin` + 2FA (TOTP ou WebAuthn) obligatoire diff --git a/qa/TODO_ADMIN.md b/qa/TODO_ADMIN.md new file mode 100644 index 0000000..3d48b2e --- /dev/null +++ b/qa/TODO_ADMIN.md @@ -0,0 +1,117 @@ +# TODOs Admin front — skilluv-admin + +> Implémentations à faire côté admin front (nouveaux tests, exposition UI de features back existantes, améliorations UX). + +## Template d'entrée + +``` +### [Pxx] Titre court +**Zone :** module ou page concernée +**Type :** implementation | other +**Contexte :** pourquoi c'est utile +**Détail :** ce qu'il faut faire +**Statut :** fixed | in_progress | fixed (commit) +``` + +--- + +## Ouverts + +### [P2] Phase 3 tests exhaustifs — CRUD complet Skills +**Zone :** `/skills` +**Type :** implementation +**Contexte :** Phase 3 de la stratégie QA — chaque module a besoin d'un test end-to-end couvrant CRUD complet (Create, Read, Update, Delete) via l'UI. +**Détail :** écrire `e2e/admin/skills-crud.spec.ts` couvrant : create via modal, edit (PATCH), copier ID, filtrage par domaine, pagination. Vérifier DB à chaque étape. + +**Statut :** fixed + +### [P2] Phase 3 tests exhaustifs — CRUD complet Projects +**Zone :** `/projects` +**Type :** implementation +**Contexte :** Phase 3. +**Détail :** `e2e/admin/projects-crud.spec.ts` — create (avec filtres flagship/OSS/curated), update, archive, filter par partnership level, pagination. + +**Statut :** fixed + +### [P2] Phase 3 tests exhaustifs — Orientations + Badge rules + Tenants +**Zone :** `/catalog`, `/tenants` +**Type :** implementation +**Contexte :** Phase 3. +**Détail :** 3 specs séparés — orientations (create + attach skill + detach), badge rules (create + edit + deprecate), tenants (create + members + cohorts). + +**Statut :** fixed + +### [P2] Phase 3 tests — Ops jobs safe-triggers +**Zone :** `/operations` +**Type :** implementation +**Contexte :** Phase 3. +**Détail :** `e2e/admin/ops-jobs.spec.ts` — trigger `rebuild-leaderboards`, `digest/run-weekly`, `hidden-gems`, `churn` (dry-run si supporté), vérifier 200 et side-effect (leaderboard rebuilt event etc.). Rate limits admin_destructive à respecter. + +**Statut :** fixed + +### [P2] Phase 3 tests — GDPR export + guild dissolve + reset-2fa (fois back fixé) +**Zone :** `/users/[id]`, `/operations` +**Type :** implementation +**Contexte :** Phase 3 + suivi du fix back sur `totp_enabled` exposé. +**Détail :** GDPR export (POST + vérifier notification/response), dissolve guild, reset-2fa via UI (attendre BUGS_BACK P1 fix). Ajouter à `reset-2fa.spec.ts` : flip du `expect().toBeDisabled()` en `toBeEnabled()` + click through dialog. + +**Statut :** fixed + +### [P3] Exposer côté UI l'endpoint back non-consommé : Challenge AI variant +**Zone :** `/challenges` +**Type :** implementation +**Contexte :** back expose `POST /admin/challenges/{id}/variant` (IA-C.1 — génère une variante harder/easier via IA) mais aucune UI ne l'appelle. +**Détail :** ajouter un bouton "Générer variante" dans la card d'un challenge publié → dialog qui demande `mode: 'harder'|'easier'` → POST + toast + refetch. + +**Statut :** fixed + +### [P3] Exposer côté UI l'endpoint back non-consommé : Fraud deep-scan +**Zone :** `/fraud` +**Type :** implementation +**Contexte :** back expose `POST /admin/fraud/deep-scan/{id}` (IA-B — plagiat profond LLM-assisté) mais aucune UI ne l'appelle. +**Détail :** dans le tab "eval" de la page fraud, ajouter action "Deep scan" à côté de scan-deliverable + llm-evaluate. Affiche le score + le similar_to. + +**Statut :** fixed + +### [P2] Extraire les modales des `+page.svelte` restants +**Zone :** `src/routes/{sponsored-challenges,skills,challenges,projects}/+page.svelte` → 4 nouveaux composants sous `src/lib/components/admin/` + +**Résultat mesuré :** +- sponsored-challenges : 489 → 421 lignes (SponsoredDecideModal, 130 lignes) +- skills : 559 → 277 lignes (SkillFormModal unifié create+edit via discriminated union `mode`, 277 lignes) +- challenges : 411 → 185 lignes (ChallengeFormModal, 253 lignes) +- projects : 602 → 332 lignes (ProjectFormModal, 315 lignes) + +**Total :** ~2000 lignes déplacées vers 4 composants isolés + testables + réutilisables. + +**Pages non extraites (intentionnellement) :** `tournaments` (593), `operations` (591), `fraud` (527) — n'ont que des `` (déjà un composant réutilisable). Leur longueur vient de la logique métier / des tabs, pas des modales. Refactor différent (extract sections/tabs). + +**Statut :** fixed + +### [P2] Migrer les ~37 strings inline restantes vers i18n.t (ar cassé) +**Zone :** `src/routes/sso-sessions/+page.svelte` (18), `src/routes/auth/login/+page.svelte` (10), `src/lib/components/ui/{LevelUpAnimation,MultiSelect,ReplayPlayer,ShareButton}.svelte` (9) +**Type :** implementation +**Contexte :** ces strings utilisent le pattern `i18n.locale === 'fr' ? 'FR' : 'EN'` — elles bypassent complètement `ar.ts`. Un utilisateur admin en arabe voit le fallback anglais partout. Le helper `intlLocale()` a déjà été extrait pour tous les mappings de tags Intl.* (~15 occurrences), reste ces vraies traductions. +**Détail :** pour chaque bloc : +1. Ajouter la clé dans `src/lib/i18n/types.ts` (typing strict) +2. Ajouter les valeurs fr/en/ar dans `fr.ts` / `en.ts` / `ar.ts` +3. Remplacer `i18n.locale === 'fr' ? 'x' : 'y'` par `i18n.t('admin..')` + +Grouper par écran pour limiter le rework. Priorité `sso-sessions` (bug UI déjà tracké côté back — refactor bienvenu quand on y touche). + +**Fix appliqué :** 3 nouveaux sous-namespaces sous `admin` (`admin.sso`, `admin.loginPage`, `admin.levelUp`, `admin.multiSelect`, `admin.replayPlayer`, `admin.shareButton`) — 30+ clés ajoutées en fr/en/ar avec types stricts. Grep `i18n.locale ===` retourne 0 dans src/. + +**Statut :** fixed + +### [P3] CI GitHub Actions — étendre au projet `admin` Playwright +**Zone :** `.github/workflows/ci.yml` — job `e2e-admin` +**Type :** implementation +**Fix appliqué :** commit `ci: add e2e-admin job pulling backend image from GHCR`. Nouveau job pull `ghcr.io/skilluv/skilluv-backend:master`, services postgres/redis/mailpit/minio, bootstrap admin, `npx playwright test --project=admin`. Reste rouge tant que la PR back #33 n'est pas mergée (image pas encore publiée) — comportement voulu. + +**Statut :** fixed + +--- + +## Corrigés + +_(vide)_ diff --git a/qa/TODO_BACKEND.md b/qa/TODO_BACKEND.md new file mode 100644 index 0000000..52d8cf1 --- /dev/null +++ b/qa/TODO_BACKEND.md @@ -0,0 +1,44 @@ +# TODOs Backend — skilluv-backend + +> Implémentations à demander à l'équipe back (autre que fix de bugs — pour ça voir `BUGS_BACK.md`). + +## Template + +``` +### [Pxx] Titre +**Type :** implementation | other +**Contexte :** … +**Détail :** … +**Statut :** open | in_progress | fixed +``` + +--- + +## Ouverts + +### [P2] Ajouter `totp_enabled`, `email_2fa_enabled`, `webauthn_credentials_count` à `GET /admin/users/{id}` +**Type :** implementation +**Contexte :** cross-ref BUGS_BACK P1 (même fix). Le front en a besoin pour activer le bouton reset-2FA, afficher le badge 2FA correct, etc. Sans ces champs, plusieurs UI restent grisées. +**Détail :** enrichir le `json!` du handler `get_user` (l.249 de `src/routes/admin_moderation.rs`) avec les 3 champs + COUNT depuis `webauthn_credentials WHERE user_id = $1`. + +**Statut :** fixed (backend commit 4e857ad — les 3 champs sont exposés) + +### [P3] Aligner tous les payloads liste admin sur `{data: T[], pagination}` (audit convention) +**Type :** other +**Contexte :** le bug SSO (BUGS_BACK P1 `{data:{sessions:[…]}}`) suggère qu'il peut y avoir d'autres endpoints admin qui dérogent à la convention paginée standard. Utile d'auditer tous les `GET /admin/*` pour cette cohérence avant que d'autres UIs cassent silencieusement. +**Détail :** grep `.route("/admin/` + inspecter chaque handler qui renvoie une liste. Convention cible : `{data: T[], pagination: {…}, meta: {…}}`. Fix tout ce qui dévie. + +**Statut :** deferred (backend team — scan rapide n'a rien révélé d'autre que le SSO fix. Follow-up ticket si des surprises apparaissent en E2E) + +### [P3] Documenter les endpoints admin dans OpenAPI (utoipa) +**Type :** implementation +**Contexte :** l'audit initial du back a montré qu'il n'y a pas de doc OpenAPI. Utile pour synchroniser front/back sur les contrats (aurait évité le mismatch `is_banned`/`banned`). +**Détail :** décorer chaque handler admin avec `#[utoipa::path(...)]`, exposer `/api/docs` (déjà partiellement fait via `openapi_routes()`). + +**Statut :** deferred (doublon avec BE-P1-CONTRACT — infrastructure utoipa + Swagger UI déjà wirée backend commit c3ec13c, l'annotation exhaustive des ~86 handlers est le sujet d'un autre PR long-tail) + +--- + +## Corrigés + +_(vide)_ diff --git a/qa/push-to-trello.py b/qa/push-to-trello.py new file mode 100644 index 0000000..a69ed93 --- /dev/null +++ b/qa/push-to-trello.py @@ -0,0 +1,359 @@ +"""Sync qa/BUGS_FRONT.md + qa/BUGS_BACK.md to a Trello board. + +Idempotent: +- board/lists/labels created only if missing (matched by name) +- cards matched by exact title; existing cards are updated (desc + labels + list) + so status transitions (open -> fixed) move the card between lists + +Design goals: +- Single source of truth: the markdown files stay authoritative for the *content* +- Trello mirrors current state for team visibility (back + admin + qa collaborate) +- Rerun-safe: this script is meant to run on every commit that touches the .md files + +Env vars (required): + TRELLO_KEY — the Trello API key + TRELLO_TOKEN — a user token with read+write scope + +Env vars (optional): + TRELLO_BOARD_NAME default: "Skilluv - QA & Bugs Admin" + TRELLO_BOARD_ID shortLink to reuse an existing board (bypasses name lookup) + +Flags: + --dry-run print the diff without touching Trello +""" + +from __future__ import annotations + +import argparse +import io +import os +import re +import sys +import time + +# Windows default is cp1252 which chokes on em-dashes and arrows in Trello +# titles/descriptions. Force UTF-8 so this script runs cleanly under both +# `python` and `py -3` on Windows without needing chcp 65001. +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") +from dataclasses import dataclass, field +from pathlib import Path + +import requests + +HERE = Path(__file__).parent + +# Sources parsed for cards. Each tuple is (path, team, default_type): +# team — team: label (backend | frontend | admin) +# default_type — type: label (bug | implementation | other) +# Titles inside a file must be unique; Trello dedupes by title. +SOURCES = [ + (HERE / "BUGS_FRONT.md", "admin", "bug"), + (HERE / "BUGS_BACK.md", "backend", "bug"), + (HERE / "TODO_ADMIN.md", "admin", "implementation"), + (HERE / "TODO_BACKEND.md", "backend", "implementation"), +] + +BASE = "https://api.trello.com/1" +SLEEP = 0.2 # ~5 req/s, well under Trello's 100/10s cap + +BOARD_NAME_DEFAULT = "Skilluv - QA & Bugs Admin" + +# Workflow lists in display order. +LISTS = ["Backlog", "À faire", "En cours", "Review", "Fait"] + +# Labels: team (color-coded), type (color-coded), priority (color-coded). +LABEL_COLORS: dict[str, str | None] = { + # Team + "team:backend": "blue", + "team:frontend": "green", + "team:admin": "orange", + # Type + "type:bug": "red", + "type:implementation": "purple", + "type:other": "black", + # Priority + "P0": "red", + "P1": "orange", + "P2": "sky", + "P3": None, +} + + +# ── Parsing ──────────────────────────────────────────────────────────── + + +@dataclass +class Card: + """One bug/task, mirrored as a Trello card.""" + + title: str + description: str + team: str # backend | frontend | admin + type: str # bug | implementation | other + priority: str # P0 | P1 | P2 + status: str # open | in_progress | fixed + labels: list[str] = field(default_factory=list) + + def resolved_list(self) -> str: + if self.status == "fixed": + return "Fait" + if self.status == "in_progress": + return "En cours" + return "Backlog" + + def resolved_labels(self) -> list[str]: + return [f"team:{self.team}", f"type:{self.type}", self.priority] + self.labels + + +# Section header : `### [Pn] Titre` (n = 0..9 to cover P0/P1/P2/P3+ backlog levels). +ENTRY_RE = re.compile(r"^### \[(P[0-9])\]\s+(.+?)\s*$", re.MULTILINE) +STATUS_RE = re.compile(r"^\*\*Statut\s*:\*\*\s*(open|in_progress|fixed).*$", re.MULTILINE) + + +TYPE_RE = re.compile(r"^\*\*Type\s*:\*\*\s*(bug|implementation|other)\s*$", re.MULTILINE) + + +def parse_source(path: Path, team: str, default_type: str) -> list[Card]: + """Extract cards from a BUGS_*.md / TODO_*.md file. + + Each `### [Pxx] Title` block until the next `### ` or `---` becomes a card. + - Status defaults to `open` if no `**Statut :**` line is found. + - Type defaults to `default_type` unless the entry has a `**Type :**` line + (allows a single file to mix bugs + implementations if needed). + """ + if not path.exists(): + return [] + + text = path.read_text(encoding="utf-8") + # Strip the template block so its `### [Pxx] Titre court` skeleton doesn't + # get pushed as a card. The template lives inside a fenced code block. + template_re = re.compile(r"## Template.*?```.*?```", re.DOTALL) + text = template_re.sub("", text) + + matches = list(ENTRY_RE.finditer(text)) + cards: list[Card] = [] + for i, m in enumerate(matches): + priority = m.group(1) + title = m.group(2).strip() + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + body = text[start:end].strip() + # Stop at the next `---` (section separator) or `## ` header. + for stopper in (r"\n---\s*\n", r"\n## "): + cut = re.search(stopper, body) + if cut: + body = body[: cut.start()].strip() + + status_m = STATUS_RE.search(body) + status = status_m.group(1) if status_m else "open" + type_m = TYPE_RE.search(body) + card_type = type_m.group(1) if type_m else default_type + + cards.append( + Card( + title=f"[{priority}] {title}", + description=body, + team=team, + type=card_type, + priority=priority, + status=status, + ) + ) + return cards + + +# ── Trello client ────────────────────────────────────────────────────── + + +class Trello: + def __init__(self, key: str, token: str, dry_run: bool = False) -> None: + self.auth = {"key": key, "token": token} + self.dry_run = dry_run + + def _req(self, method: str, path: str, params: dict | None = None, retry: int = 3): + p = {**self.auth, **(params or {})} + if self.dry_run and method != "GET": + print(f" [dry-run] {method} {path} {params or {}}") + return {} + for attempt in range(retry): + r = requests.request(method, f"{BASE}{path}", params=p, timeout=30) + if 200 <= r.status_code < 300: + time.sleep(SLEEP) + return r.json() if r.text else {} + if r.status_code in (429, 500, 502, 503, 504) and attempt < retry - 1: + wait = 2 ** attempt + print(f" ! {r.status_code}, retry in {wait}s", file=sys.stderr) + time.sleep(wait) + continue + raise RuntimeError(f"{method} {path} -> {r.status_code}: {r.text[:200]}") + raise RuntimeError("unreachable") + + # ── boards ───────────────────────────────────────────────────────── + + def find_board(self, name_or_short: str) -> dict | None: + # Try by shortLink first (12-char id). + if re.fullmatch(r"[A-Za-z0-9]{8,12}", name_or_short): + try: + return self._req("GET", f"/boards/{name_or_short}", {"fields": "name,id,url"}) + except RuntimeError: + pass + boards = self._req("GET", "/members/me/boards", {"fields": "name,id,url", "filter": "open"}) + for b in boards: + if b["name"] == name_or_short: + return b + return None + + def create_board(self, name: str) -> dict: + return self._req( + "POST", + "/boards", + {"name": name, "defaultLists": "false", "prefs_permissionLevel": "org"}, + ) + + # ── lists ────────────────────────────────────────────────────────── + + def ensure_lists(self, board_id: str) -> dict[str, str]: + existing = {l["name"]: l["id"] for l in self._req("GET", f"/boards/{board_id}/lists")} + ids: dict[str, str] = {} + for i, name in enumerate(LISTS): + if name in existing: + ids[name] = existing[name] + else: + print(f" + list '{name}'") + r = self._req("POST", "/lists", {"name": name, "idBoard": board_id, "pos": (i + 1) * 65536}) + ids[name] = r.get("id", f"") + return ids + + # ── labels ───────────────────────────────────────────────────────── + + def ensure_labels(self, board_id: str) -> dict[str, str]: + existing = {l["name"]: l["id"] for l in self._req("GET", f"/boards/{board_id}/labels")} + ids: dict[str, str] = {} + for name, color in LABEL_COLORS.items(): + if name in existing: + ids[name] = existing[name] + else: + print(f" + label '{name}' ({color or 'no color'})") + params: dict[str, str] = {"name": name, "idBoard": board_id} + if color: + params["color"] = color + r = self._req("POST", "/labels", params) + ids[name] = r.get("id", f"") + return ids + + # ── cards ────────────────────────────────────────────────────────── + + def all_cards(self, board_id: str) -> dict[str, dict]: + cards = self._req( + "GET", + f"/boards/{board_id}/cards", + {"fields": "name,desc,idList,idLabels"}, + ) + return {c["name"]: c for c in cards} + + def upsert_card( + self, + board_id: str, + existing: dict[str, dict], + list_ids: dict[str, str], + label_ids: dict[str, str], + card: Card, + ) -> str: + list_id = list_ids[card.resolved_list()] + want_label_ids = sorted(label_ids[l] for l in card.resolved_labels() if l in label_ids) + prev = existing.get(card.title) + + if prev is None: + print(f" + card {card.title[:70]}") + r = self._req( + "POST", + "/cards", + { + "idList": list_id, + "name": card.title, + "desc": card.description, + "idLabels": ",".join(want_label_ids), + "pos": "bottom", + }, + ) + return r.get("id", "") + + has_label_ids = sorted(prev.get("idLabels") or []) + needs_update = ( + prev.get("desc") != card.description + or prev.get("idList") != list_id + or has_label_ids != want_label_ids + ) + if needs_update: + print(f" ~ card {card.title[:70]} (list={card.resolved_list()})") + self._req( + "PUT", + f"/cards/{prev['id']}", + { + "desc": card.description, + "idList": list_id, + "idLabels": ",".join(want_label_ids), + }, + ) + else: + print(f" = card {card.title[:70]}") + return prev["id"] + + +# ── Entry point ──────────────────────────────────────────────────────── + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--dry-run", action="store_true", help="print planned changes without hitting Trello") + args = ap.parse_args() + + # Auto-load qa/.trello.env if present (gitignored — see qa/README.md). + envfile = HERE / ".trello.env" + if envfile.exists(): + for line in envfile.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + + key = os.environ.get("TRELLO_KEY") + token = os.environ.get("TRELLO_TOKEN") + if not key or not token: + sys.exit("Missing TRELLO_KEY or TRELLO_TOKEN env var. Aborting.") + + board_ref = os.environ.get("TRELLO_BOARD_ID") or os.environ.get("TRELLO_BOARD_NAME") or BOARD_NAME_DEFAULT + + all_cards: list[Card] = [] + for path, team, default_type in SOURCES: + cards = parse_source(path, team, default_type) + print(f"Parsed {len(cards):>2} card(s) from {path.name}") + all_cards.extend(cards) + + trello = Trello(key, token, dry_run=args.dry_run) + + board = trello.find_board(board_ref) + if board is None: + print(f"Board '{board_ref}' not found — creating it") + board = trello.create_board(BOARD_NAME_DEFAULT if not re.fullmatch(r"[A-Za-z0-9]{8,12}", board_ref) else BOARD_NAME_DEFAULT) + print(f"-> Board '{board.get('name', '?')}' ({board.get('url', '?')})") + board_id = board.get("id", "") + + print("\n== Lists ==") + list_ids = trello.ensure_lists(board_id) + + print("\n== Labels ==") + label_ids = trello.ensure_labels(board_id) + + print("\n== Cards ==") + existing = trello.all_cards(board_id) if not args.dry_run else {} + for c in all_cards: + trello.upsert_card(board_id, existing, list_ids, label_ids, c) + + print(f"\nDone. {len(all_cards)} card(s) reconciled.") + + +if __name__ == "__main__": + main() diff --git a/src/lib/api/admin.ts b/src/lib/api/admin.ts index b4133d1..9bb6123 100644 --- a/src/lib/api/admin.ts +++ b/src/lib/api/admin.ts @@ -73,12 +73,17 @@ interface UserSummary { title: string; total_fragments: number; profile_active: boolean; - banned: boolean; + is_banned: boolean; created_at: string; } interface UserDetail { - user: UserPrivate; + // Backend enriches this beyond the shared UserPrivate shape (Trello + // xHnNZa5G + gWSCzyz0 + RXEWNI6y): admin panel needs the 2FA + passkey + // posture so we can decide reset-2fa eligibility without a psql hop. + // `totp_enabled` + `email_2fa_enabled` are already on UserPrivate ; + // `webauthn_credentials_count` is admin-only, added via intersection. + user: UserPrivate & { webauthn_credentials_count: number }; reports_against: number; total_submissions: number; } @@ -238,6 +243,32 @@ export const adminApi = { ); }, + /** + * IA-B — Deep plagiarism scan. Slower (2-5s IA + Redis queue), stricter + * than the cosine `scanDeliverable` (P14.3). Query params tune the + * comparison pool + threshold. Result is merged into + * `deliverables.verification_signal.deep_plagiarism` (JSONB). + * Rate-limited via admin_destructive. + */ + deepScanDeliverable(deliverableId: string, opts?: { threshold?: number; window_days?: number; pool_cap?: number }) { + const params = new URLSearchParams(); + if (opts?.threshold !== undefined) params.set('threshold', String(opts.threshold)); + if (opts?.window_days !== undefined) params.set('window_days', String(opts.window_days)); + if (opts?.pool_cap !== undefined) params.set('pool_cap', String(opts.pool_cap)); + const qs = params.toString(); + return api.post< + ApiResponse<{ + deliverable_id: string; + deep_plagiarism: { + similarity_score?: number; + verdict?: string; + flagged_at?: string; + comparison_pool_size?: number; + }; + }> + >(`/admin/fraud/deep-scan/${deliverableId}${qs ? `?${qs}` : ''}`); + }, + // --- Reports --- listReports(params?: { status?: ReportStatus; target_type?: ReportTargetType; page?: number; per_page?: number }) { @@ -358,6 +389,19 @@ export const adminApi = { return api.post>(`/admin/challenges/${id}/archive`); }, + /** + * IA-C.1 — Generate a harder/easier variant of an existing challenge. + * Backend delegates to the AI gRPC service; rate-limited by + * admin_destructive (10/min, 100/hr). `target_param` is a free-form hint + * used by the AI prompt (e.g. "increase branching factor"). + */ + generateChallengeVariant(id: string, body: { variant_type: 'harder' | 'easier'; target_param?: string }) { + return api.post>( + `/admin/challenges/${id}/variant`, + body + ); + }, + rebuildLeaderboards() { return api.post>('/admin/leaderboards/rebuild'); }, diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 0741655..86c3be5 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -174,27 +174,50 @@ export const authApi = { return api.post('/auth/totp/backup-codes/regenerate', { code }); }, - totpDisable(code: string) { - return api.post('/auth/totp/disable', { code }); - }, - - enableEmail2fa() { - return api.post('/auth/email-2fa/enable'); - }, - - disableEmail2fa(currentPassword: string) { - // Backend reuses ChangePasswordRequest for the body — new_password is required for parsing - // but ignored by the handler. Send a filler that still satisfies the min-length check. - return api.post('/auth/email-2fa/disable', { - current_password: currentPassword, - new_password: currentPassword + /** + * BE-P0-02 (see skilluv-backend `.trello-push-front.md`). Payload was + * `{ code }`; the backend now requires BOTH the password and the current + * TOTP code to prevent a stolen session from silently dropping 2FA. Errors + * come back as SkilluError with codes `InvalidCredentials` (password) or + * `TotpInvalid` (code) — surface separately in the UI when this is used. + */ + totpDisable(password: string, code: string) { + return api.post('/auth/totp/disable', { password, code }); + }, + + /** + * BE-P0-03 : now requires `{ password }` in the body (before: empty). Rationale: + * symmetry with disable + prevent a stolen session from enabling email 2FA + * without confirming the password. + */ + enableEmail2fa(password: string) { + return api.post('/auth/email-2fa/enable', { password }); + }, + + /** + * BE-P0-04 : dedicated `PasswordConfirmRequest { password }` struct — the + * old `new_password` filler hack is no longer accepted. + */ + disableEmail2fa(password: string) { + return api.post('/auth/email-2fa/disable', { password }); + }, + + /** + * BE-P0-01 : contract fully fixed. `password` mandatory, `totp_code` + * mandatory iff the user has TOTP enabled, `reason` optional (audit trail). + * Response now includes `account_deleted: true`, `scheduled_for` (currently + * the deletion timestamp — reserved for a future 30-day grace period). + */ + deleteAccount(password: string, totpCode: string | undefined, reason?: string) { + return api.delete<{ + data: { account_deleted: boolean; scheduled_for: string; message?: string }; + }>('/auth/account', { + password, + totp_code: totpCode, + reason }); }, - deleteAccount(password: string, totpCode?: string) { - return api.delete('/auth/account', { password, totp_code: totpCode }); - }, - // ─── Sessions / devices ───────────────────────────────────────── listSessions() { return api.get('/auth/sessions'); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 64833c6..19a5480 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,5 +1,6 @@ import type { ApiErrorBody } from '$lib/types'; import { toast } from '$lib/stores/toast.svelte'; +import { backendStatus } from '$lib/stores/backendStatus.svelte'; import { i18n } from '$lib/i18n'; /** @@ -121,14 +122,35 @@ export function createApiClient( const url = `${baseUrl}${path}`; const isAuthEndpoint = path.startsWith('/auth/refresh') || path.startsWith('/auth/login'); - let res = await fire(url, options); + let res: Response; + try { + res = await fire(url, options); + } catch (netErr) { + // A thrown fetch = network failure (backend down, DNS, CORS + // preflight). HTTP 4xx/5xx come back as Response objects, not + // throws — this branch is only network-level. Flip the global + // banner so the user sees an outage indicator instead of a stream + // of opaque "erreur inattendue" toasts. + backendStatus.markDown(); + throw netErr; + } + + // Any successful response means the backend is up. Flip the banner + // off if it was on (the banner component fires the "reconnected" + // toast when it's the one that unstuck things via its own probe). + if (backendStatus.isDown) backendStatus.markUp(); // On 401, try to silently refresh once, then retry the original call. // We skip retry on refresh/login themselves so we never loop. if (res.status === 401 && !isAuthEndpoint) { const refreshed = await tryRefresh(customFetch, baseUrl); if (refreshed) { - res = await fire(url, options); + try { + res = await fire(url, options); + } catch (netErr) { + backendStatus.markDown(); + throw netErr; + } } } diff --git a/src/lib/components/admin/ChallengeFormModal.svelte b/src/lib/components/admin/ChallengeFormModal.svelte new file mode 100644 index 0000000..82d085b --- /dev/null +++ b/src/lib/components/admin/ChallengeFormModal.svelte @@ -0,0 +1,253 @@ + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

{i18n.t('admin.challenges.expectedOutput')}

+ +
+
+

{i18n.t('admin.challenges.testCases')}

+ +

{i18n.t('admin.challenges.testCasesHint')}

+
+ + + {#if !editing} + + {/if} +
+ +
+ + +
+
+
diff --git a/src/lib/components/admin/ChallengeVariantDialog.svelte b/src/lib/components/admin/ChallengeVariantDialog.svelte new file mode 100644 index 0000000..0e84093 --- /dev/null +++ b/src/lib/components/admin/ChallengeVariantDialog.svelte @@ -0,0 +1,89 @@ + + + +
+ {#if source} +
+

+ {i18n.t('admin.challenges.editTitle')} +

+

{source.title}

+

+ {source.skill_domain} · {i18n.t('admin.challenges.difficulty')} {source.difficulty} +

+
+ {/if} + +
+ + + +
+ + +
+ + diff --git a/src/lib/components/admin/EventsTab.svelte b/src/lib/components/admin/EventsTab.svelte index 27f4376..8966628 100644 --- a/src/lib/components/admin/EventsTab.svelte +++ b/src/lib/components/admin/EventsTab.svelte @@ -2,7 +2,7 @@ import { adminApi } from '$api/admin'; import { errorMessage } from '$api/errors'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { BadgeEvent, CreateBadgeEventBody } from '$lib/types'; import Button from '$components/ui/Button.svelte'; import Input from '$components/ui/Input.svelte'; @@ -126,10 +126,11 @@ function fmtDate(iso: string | null): string { if (!iso) return i18n.t('admin.catalog.events.noEnd'); try { - return new Date(iso).toLocaleDateString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US', - { day: '2-digit', month: 'short', year: 'numeric' } - ); + return new Date(iso).toLocaleDateString(intlLocale(), { + day: '2-digit', + month: 'short', + year: 'numeric' + }); } catch { return iso; } diff --git a/src/lib/components/admin/ProjectFormModal.svelte b/src/lib/components/admin/ProjectFormModal.svelte new file mode 100644 index 0000000..1dd2d35 --- /dev/null +++ b/src/lib/components/admin/ProjectFormModal.svelte @@ -0,0 +1,315 @@ + + + + {#snippet children()} +
+ {#if !editing} +
+ + +

Minuscules, chiffres, tirets. Immuable après création.

+
+ {/if} +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + {#if !editing} +
+
+ + +
+
+ + +
+
+ {/if} + +
+ + + + +
+ + {#if form.is_flagship} +
+ + +
+ {/if} + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ {/snippet} +
diff --git a/src/lib/components/admin/SkillFormModal.svelte b/src/lib/components/admin/SkillFormModal.svelte new file mode 100644 index 0000000..b9c08db --- /dev/null +++ b/src/lib/components/admin/SkillFormModal.svelte @@ -0,0 +1,277 @@ + + + +
+ {#if mode.kind === 'create'} + (touched = true)} + error={slugError ?? undefined} + placeholder="react-hooks" + /> + {/if} + (touched = true)} + /> +
+ + +
+
+
+ + {i18n.t('admin.skills.create.domainLabel')} + + +
+ {#if mode.kind === 'edit'} + + {/if} + +
+ + + {#if externalRefsError} +

{externalRefsError}

+ {:else if mode.kind === 'create'} +

{i18n.t('admin.skills.create.externalRefsHint')}

+ {/if} +
+ +
+ + {#snippet actions()} + + + {/snippet} + diff --git a/src/lib/components/admin/SponsoredDecideModal.svelte b/src/lib/components/admin/SponsoredDecideModal.svelte new file mode 100644 index 0000000..11fa05b --- /dev/null +++ b/src/lib/components/admin/SponsoredDecideModal.svelte @@ -0,0 +1,130 @@ + + + +
+ {#if target} +
+

+ {i18n.t('admin.sponsored.requestLabel')} +

+

{target.proposed_title}

+

+ {fmtEur(target.budget_eur_cents)} · {target.duration_days} {i18n.t('admin.sponsored.daysSuffix')} · {target.skill_domain} +

+
+ {/if} + +
+ + +

{i18n.t('admin.sponsored.notesHint')}

+
+ +
+ + +
+
+
diff --git a/src/lib/components/admin/UserBadgesSection.svelte b/src/lib/components/admin/UserBadgesSection.svelte index 5b514ff..46d3b06 100644 --- a/src/lib/components/admin/UserBadgesSection.svelte +++ b/src/lib/components/admin/UserBadgesSection.svelte @@ -2,7 +2,7 @@ import { adminApi } from '$api/admin'; import { errorMessage } from '$api/errors'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { Rank, UserBadgesResponse, UserBadgeItem } from '$lib/types'; import Badge from '$components/ui/Badge.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; @@ -40,10 +40,11 @@ function fmtDate(iso: string): string { try { - return new Date(iso).toLocaleDateString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US', - { day: '2-digit', month: 'short', year: 'numeric' } - ); + return new Date(iso).toLocaleDateString(intlLocale(), { + day: '2-digit', + month: 'short', + year: 'numeric' + }); } catch { return iso; } diff --git a/src/lib/components/admin/UserCapabilitiesSection.svelte b/src/lib/components/admin/UserCapabilitiesSection.svelte index 9ac864f..bdf5add 100644 --- a/src/lib/components/admin/UserCapabilitiesSection.svelte +++ b/src/lib/components/admin/UserCapabilitiesSection.svelte @@ -2,7 +2,7 @@ import { adminApi } from '$api/admin'; import { errorMessage } from '$api/errors'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { Capability, UserCapability } from '$lib/types'; import Button from '$components/ui/Button.svelte'; import Input from '$components/ui/Input.svelte'; @@ -139,10 +139,11 @@ function fmtExpires(iso: string | null): string | null { if (!iso) return null; try { - return new Date(iso).toLocaleDateString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US', - { day: '2-digit', month: 'short', year: 'numeric' } - ); + return new Date(iso).toLocaleDateString(intlLocale(), { + day: '2-digit', + month: 'short', + year: 'numeric' + }); } catch { return iso; } diff --git a/src/lib/components/admin/UserOrientationsSection.svelte b/src/lib/components/admin/UserOrientationsSection.svelte index 15501e3..467b5d5 100644 --- a/src/lib/components/admin/UserOrientationsSection.svelte +++ b/src/lib/components/admin/UserOrientationsSection.svelte @@ -2,7 +2,7 @@ import { adminApi } from '$api/admin'; import { errorMessage } from '$api/errors'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { UserOrientationEntry } from '$lib/types'; import Badge from '$components/ui/Badge.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; @@ -37,10 +37,11 @@ function fmtDate(iso: string): string { try { - return new Date(iso).toLocaleDateString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US', - { day: '2-digit', month: 'short', year: 'numeric' } - ); + return new Date(iso).toLocaleDateString(intlLocale(), { + day: '2-digit', + month: 'short', + year: 'numeric' + }); } catch { return iso; } diff --git a/src/lib/components/admin/UserRankSection.svelte b/src/lib/components/admin/UserRankSection.svelte index 3869248..afac587 100644 --- a/src/lib/components/admin/UserRankSection.svelte +++ b/src/lib/components/admin/UserRankSection.svelte @@ -2,7 +2,7 @@ import { adminApi } from '$api/admin'; import { errorMessage } from '$api/errors'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { Rank, UserRankHistoryEntry } from '$lib/types'; import Badge from '$components/ui/Badge.svelte'; import Button from '$components/ui/Button.svelte'; @@ -112,10 +112,11 @@ function fmtDate(iso: string): string { try { - return new Date(iso).toLocaleDateString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US', - { day: '2-digit', month: 'short', year: 'numeric' } - ); + return new Date(iso).toLocaleDateString(intlLocale(), { + day: '2-digit', + month: 'short', + year: 'numeric' + }); } catch { return iso; } diff --git a/src/lib/components/ui/BackendStatusBanner.svelte b/src/lib/components/ui/BackendStatusBanner.svelte new file mode 100644 index 0000000..885271a --- /dev/null +++ b/src/lib/components/ui/BackendStatusBanner.svelte @@ -0,0 +1,102 @@ + + +{#if backendStatus.isDown} + +{/if} diff --git a/src/lib/components/ui/Input.test.ts b/src/lib/components/ui/Input.test.ts new file mode 100644 index 0000000..050e97c --- /dev/null +++ b/src/lib/components/ui/Input.test.ts @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import Input from './Input.svelte'; + +describe('Input', () => { + it('associates the label to the input via for/id', () => { + render(Input, { label: 'Email address', value: '' }); + const input = screen.getByLabelText('Email address'); + expect(input).toBeInTheDocument(); + expect(input.tagName).toBe('INPUT'); + }); + + it('renders as a password field by default when type=password', () => { + render(Input, { label: 'Password', type: 'password', value: '' }); + const input = screen.getByLabelText('Password') as HTMLInputElement; + expect(input.type).toBe('password'); + }); + + it('exposes a "show password" toggle only for type=password', () => { + const { unmount } = render(Input, { label: 'Password', type: 'password', value: '' }); + expect(screen.getByRole('button', { name: /afficher le mot de passe/i })).toBeInTheDocument(); + unmount(); + + render(Input, { label: 'Email', type: 'email', value: '' }); + expect(screen.queryByRole('button', { name: /afficher le mot de passe/i })).toBeNull(); + }); + + it('toggles the input type between password and text when the eye button is clicked', async () => { + const user = userEvent.setup(); + render(Input, { label: 'Password', type: 'password', value: 'secret' }); + + const input = screen.getByLabelText('Password') as HTMLInputElement; + expect(input.type).toBe('password'); + + const toggle = screen.getByRole('button', { name: /afficher le mot de passe/i }); + await user.click(toggle); + expect(input.type).toBe('text'); + // aria-label flips to the inverse action after the reveal. + expect(screen.getByRole('button', { name: /masquer le mot de passe/i })).toBe(toggle); + + await user.click(toggle); + expect(input.type).toBe('password'); + }); + + it('renders an accessible error message linked via aria-describedby', () => { + render(Input, { label: 'Email', value: '', error: 'Format invalide' }); + const input = screen.getByLabelText('Email'); + const err = screen.getByRole('alert'); + expect(err.textContent).toContain('Format invalide'); + expect(input.getAttribute('aria-describedby')).toBe(err.id); + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + + it('renders a hint only when there is no error (error takes precedence)', () => { + const { unmount } = render(Input, { label: 'Email', value: '', hint: 'Never shared' }); + expect(screen.getByText('Never shared')).toBeInTheDocument(); + unmount(); + + render(Input, { label: 'Email', value: '', hint: 'Never shared', error: 'Format invalide' }); + expect(screen.queryByText('Never shared')).toBeNull(); + expect(screen.getByRole('alert').textContent).toContain('Format invalide'); + }); +}); diff --git a/src/lib/components/ui/LevelUpAnimation.svelte b/src/lib/components/ui/LevelUpAnimation.svelte index 40ab5b3..25d0065 100644 --- a/src/lib/components/ui/LevelUpAnimation.svelte +++ b/src/lib/components/ui/LevelUpAnimation.svelte @@ -45,7 +45,7 @@

- {i18n.locale === 'fr' ? 'Nouveau titre' : 'New title'} + {i18n.t('admin.levelUp.newTitle')}

{i18n.t(`common.titles.${newTitle}`)} @@ -60,7 +60,7 @@

- {i18n.locale === 'fr' ? 'Continue comme ça !' : 'Keep going!'} + {i18n.t('admin.levelUp.keepGoing')}

@@ -68,7 +68,7 @@ class="mt-6 text-sm text-text-muted hover:text-text-primary transition-colors" onclick={onclose} > - {i18n.locale === 'fr' ? 'Continuer' : 'Continue'} + {i18n.t('admin.levelUp.continueBtn')}
diff --git a/src/lib/components/ui/Modal.test.ts b/src/lib/components/ui/Modal.test.ts new file mode 100644 index 0000000..8b1c641 --- /dev/null +++ b/src/lib/components/ui/Modal.test.ts @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { createRawSnippet } from 'svelte'; +import Modal from './Modal.svelte'; + +/** + * Build a text-only snippet the way Svelte 5 expects for `children` / `actions` + * props. `createRawSnippet` returns the value tuple template functions Svelte + * hydrates internally — testing components that take snippets requires this. + */ +function textSnippet(text: string) { + return createRawSnippet(() => ({ + render: () => `${text}` + })); +} + +describe('Modal', () => { + it('renders nothing when open=false', () => { + render(Modal, { + open: false, + onclose: vi.fn(), + children: textSnippet('hidden content') + }); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByTestId('snippet-body')).toBeNull(); + }); + + it('renders a dialog with the title, aria-modal, and body content when open', () => { + render(Modal, { + open: true, + title: 'Ban user', + onclose: vi.fn(), + children: textSnippet('confirm block') + }); + const dlg = screen.getByRole('dialog'); + expect(dlg.getAttribute('aria-modal')).toBe('true'); + expect(dlg.getAttribute('aria-label')).toBe('Ban user'); + expect(screen.getByRole('heading', { name: 'Ban user' })).toBeInTheDocument(); + expect(screen.getByTestId('snippet-body').textContent).toBe('confirm block'); + }); + + it('triggers onclose when the close (X) button is clicked', async () => { + const onclose = vi.fn(); + const user = userEvent.setup(); + render(Modal, { + open: true, + title: 'Ban user', + onclose, + children: textSnippet('body') + }); + await user.click(screen.getByRole('button', { name: /fermer/i })); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('triggers onclose when Escape is pressed', async () => { + const onclose = vi.fn(); + const user = userEvent.setup(); + render(Modal, { + open: true, + title: 'Ban user', + onclose, + children: textSnippet('body') + }); + screen.getByRole('dialog').focus(); + await user.keyboard('{Escape}'); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('triggers onclose when the backdrop (dialog itself) is clicked, but not when clicking inner content', async () => { + const onclose = vi.fn(); + const user = userEvent.setup(); + render(Modal, { + open: true, + title: 'Ban user', + onclose, + children: textSnippet('body') + }); + // Click on the body (inner) — should NOT close. + await user.click(screen.getByTestId('snippet-body')); + expect(onclose).not.toHaveBeenCalled(); + // Click on the backdrop (the dialog element itself). + await user.click(screen.getByRole('dialog')); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('does not render a header (title bar + close button) when title is omitted', () => { + render(Modal, { + open: true, + onclose: vi.fn(), + children: textSnippet('body') + }); + expect(screen.queryByRole('heading')).toBeNull(); + expect(screen.queryByRole('button', { name: /fermer/i })).toBeNull(); + }); + + it('renders the actions snippet in a dedicated footer when provided', () => { + render(Modal, { + open: true, + title: 't', + onclose: vi.fn(), + children: textSnippet('body'), + actions: createRawSnippet(() => ({ + render: () => '' + })) + }); + expect(screen.getByTestId('footer-btn').textContent).toBe('Confirm'); + }); +}); diff --git a/src/lib/components/ui/MultiSelect.svelte b/src/lib/components/ui/MultiSelect.svelte index 8c0cabd..1353c34 100644 --- a/src/lib/components/ui/MultiSelect.svelte +++ b/src/lib/components/ui/MultiSelect.svelte @@ -163,7 +163,7 @@
{/if} diff --git a/src/lib/components/ui/Select.test.ts b/src/lib/components/ui/Select.test.ts new file mode 100644 index 0000000..8723669 --- /dev/null +++ b/src/lib/components/ui/Select.test.ts @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import Select from './Select.svelte'; + +const ITEMS = [ + { value: 'code', label: 'Code' }, + { value: 'design', label: 'Design' }, + { value: 'game', label: 'Game' } +]; + +describe('Select', () => { + it('renders the current value label in the trigger button', () => { + render(Select, { items: ITEMS, value: 'design' }); + expect(screen.getByRole('button', { name: /design/i })).toBeInTheDocument(); + }); + + it('falls back to the placeholder when value has no matching item', () => { + render(Select, { items: ITEMS, value: 'zzz' as any, placeholder: 'Choisir…' }); + expect(screen.getByRole('button', { name: /choisir/i })).toBeInTheDocument(); + }); + + it('opens a listbox on click and marks the current item as aria-selected', async () => { + const user = userEvent.setup(); + render(Select, { items: ITEMS, value: 'game' }); + expect(screen.queryByRole('listbox')).toBeNull(); + + await user.click(screen.getByRole('button', { name: /game/i })); + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options.find((o) => o.getAttribute('aria-selected') === 'true')?.textContent).toContain('Game'); + }); + + it('fires onchange with the new value when an option is clicked', async () => { + const onchange = vi.fn(); + const user = userEvent.setup(); + render(Select, { items: ITEMS, value: 'code', onchange }); + await user.click(screen.getByRole('button', { name: /code/i })); + await user.click(screen.getByRole('option', { name: 'Design' })); + expect(onchange).toHaveBeenCalledTimes(1); + expect(onchange).toHaveBeenCalledWith('design'); + }); + + it('filters items when searchable=true and typing a query', async () => { + const user = userEvent.setup(); + render(Select, { + items: ITEMS, + value: 'code', + searchable: true, + searchPlaceholder: 'Search' + }); + await user.click(screen.getByRole('button', { name: /code/i })); + const search = screen.getByPlaceholderText('Search'); + await user.type(search, 'des'); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0].textContent).toContain('Design'); + }); + + it('does not open when disabled', async () => { + const user = userEvent.setup(); + render(Select, { items: ITEMS, value: 'code', disabled: true }); + await user.click(screen.getByRole('button', { name: /code/i })); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('closes the listbox when Escape is pressed', async () => { + const user = userEvent.setup(); + render(Select, { items: ITEMS, value: 'code' }); + await user.click(screen.getByRole('button', { name: /code/i })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).toBeNull(); + }); +}); diff --git a/src/lib/components/ui/ShareButton.svelte b/src/lib/components/ui/ShareButton.svelte index d0a2a8d..419c276 100644 --- a/src/lib/components/ui/ShareButton.svelte +++ b/src/lib/components/ui/ShareButton.svelte @@ -30,7 +30,7 @@ async function copyLink() { await navigator.clipboard.writeText(fullUrl); - toast.success(i18n.locale === 'fr' ? 'Lien copié !' : 'Link copied!'); + toast.success(i18n.t('admin.shareButton.linkCopied')); showMenu = false; } @@ -52,7 +52,7 @@ - {i18n.locale === 'fr' ? 'Partager' : 'Share'} + {i18n.t('admin.shareButton.share')} {#if showMenu} @@ -70,7 +70,7 @@
{/if} diff --git a/src/lib/i18n/ar.ts b/src/lib/i18n/ar.ts index c45ed61..d8b9c7b 100644 --- a/src/lib/i18n/ar.ts +++ b/src/lib/i18n/ar.ts @@ -1176,6 +1176,82 @@ export const ar: Translations = { kyc_reviewer: 'ترشيح موظفين للتحقق من KYC المؤسسات.', community_curator: '3+ تحديات مجتمعية منشورة (تلقائي).' } + }, + deepScan: { + btn: 'فحص عميق (IA)', + runningToast: 'بدأ الفحص العميق…', + successToast: 'اكتمل الفحص العميق', + resultLabel: 'نتيجة الفحص العميق', + resultScore: 'النتيجة', + resultVerdict: 'الحكم', + resultPool: 'مجموعة المقارنة', + flaggedAt: 'تم الإبلاغ في' + }, + variant: { + btn: 'إنشاء نسخة IA', + dialogTitle: 'إنشاء نسخة', + typeLabel: 'النوع', + typeHarder: 'أصعب', + typeEasier: 'أسهل', + targetParamLabel: 'إرشاد (اختياري)', + targetParamHint: 'نص حر لضبط IA — مثلا « زد عامل التفرع »', + submit: 'إنشاء', + successToast: 'تم إنشاء النسخة كمسودة' + }, + backendStatus: { + banner: 'الخادم غير متاح — إعادة المحاولة خلال {seconds} ث', + bannerNow: 'الخادم غير متاح — جاري المحاولة…', + retryNow: 'إعادة المحاولة الآن', + probing: 'جاري التحقق…', + reconnected: 'تم استعادة الاتصال بالخادم' + }, + sso: { + title: 'جلسات SSO', + headingActive: 'جلسات SSO نشطة', + subtitle: + 'كل الجلسات الموثقة عبر IdP خارجي (login_method=\'sso\'). مفيدة للتدقيق ولإلغاء جلسة عن بعد في حالة الاختراق.', + filterEnterpriseLabel: 'تصفية حسب المؤسسة (UUID)', + filterBtn: 'تصفية', + resetFilterBtn: 'إعادة', + emptyState: 'لا توجد جلسات SSO نشطة.', + colUser: 'المستخدم', + colEnterprise: 'المؤسسة', + colCreated: 'أنشئت', + colLastUsed: 'آخر نشاط', + colActions: 'إجراءات', + revokeBtn: 'إلغاء', + revokedToast: 'تم إلغاء الجلسة', + revokeDialogTitle: 'إلغاء جلسة SSO', + revokeHint: 'اشتباه بالاختراق، جلسة معلقة، إلخ.' + }, + loginPage: { + pageTitle: 'تسجيل دخول المسؤول', + controlPanel: 'لوحة التحكم', + emailOrUsername: 'البريد الإلكتروني أو اسم المستخدم', + password: 'كلمة المرور', + totpCode: 'رمز TOTP', + useBackupCode: 'استخدم رمز نسخ احتياطي', + signInBtn: 'تسجيل الدخول', + accessRestricted: 'هذا الوصول مقتصر على مسؤولي Skilluv.', + notAdminError: 'هذا الحساب ليس لديه صلاحيات المسؤول.', + unexpectedError: 'خطأ غير متوقع.' + }, + levelUp: { + newTitle: 'عنوان جديد', + keepGoing: 'استمر هكذا!', + continueBtn: 'متابعة' + }, + multiSelect: { + remove: 'إزالة', + clearAll: 'إزالة الكل' + }, + replayPlayer: { + originalDuration: 'المدة الأصلية' + }, + shareButton: { + linkCopied: 'تم نسخ الرابط!', + share: 'مشاركة', + copyLink: 'نسخ الرابط' } }, community: { diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 62ebc19..f01bc6f 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -1168,6 +1168,82 @@ export const en: Translations = { kyc_reviewer: 'Staff nomination to validate enterprise KYC.', community_curator: '3+ published community challenges (auto).' } + }, + deepScan: { + btn: 'Deep scan (AI)', + runningToast: 'Deep scan started…', + successToast: 'Deep scan completed', + resultLabel: 'Deep scan result', + resultScore: 'Score', + resultVerdict: 'Verdict', + resultPool: 'Comparison pool', + flaggedAt: 'Flagged at' + }, + variant: { + btn: 'Generate AI variant', + dialogTitle: 'Generate a variant', + typeLabel: 'Type', + typeHarder: 'Harder', + typeEasier: 'Easier', + targetParamLabel: 'Hint (optional)', + targetParamHint: 'Free-text prompt tuning — e.g. "increase branching factor"', + submit: 'Generate', + successToast: 'Variant generated + created as draft' + }, + backendStatus: { + banner: 'Backend unreachable — retrying in {seconds}s', + bannerNow: 'Backend unreachable — probing now…', + retryNow: 'Retry now', + probing: 'Probing…', + reconnected: 'Backend connection restored' + }, + sso: { + title: 'SSO sessions', + headingActive: 'Active SSO sessions', + subtitle: + "All sessions authenticated via an external IdP (login_method='sso'). Useful for auditing and remote-revoking a compromised session.", + filterEnterpriseLabel: 'Filter by enterprise (UUID)', + filterBtn: 'Filter', + resetFilterBtn: 'Reset', + emptyState: 'No active SSO sessions.', + colUser: 'User', + colEnterprise: 'Enterprise', + colCreated: 'Created', + colLastUsed: 'Last used', + colActions: 'Actions', + revokeBtn: 'Revoke', + revokedToast: 'Session revoked', + revokeDialogTitle: 'Revoke SSO session', + revokeHint: 'Suspected compromise, stale session, etc.' + }, + loginPage: { + pageTitle: 'Admin sign in', + controlPanel: 'Control panel', + emailOrUsername: 'Email or username', + password: 'Password', + totpCode: 'TOTP code', + useBackupCode: 'Use a backup code', + signInBtn: 'Sign in', + accessRestricted: 'This access is restricted to Skilluv administrators.', + notAdminError: 'This account does not have admin privileges.', + unexpectedError: 'Unexpected error.' + }, + levelUp: { + newTitle: 'New title', + keepGoing: 'Keep going!', + continueBtn: 'Continue' + }, + multiSelect: { + remove: 'Remove', + clearAll: 'Clear all' + }, + replayPlayer: { + originalDuration: 'Original duration' + }, + shareButton: { + linkCopied: 'Link copied!', + share: 'Share', + copyLink: 'Copy link' } }, community: { diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 2d94818..fb0b96a 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -1168,6 +1168,82 @@ export const fr: Translations = { kyc_reviewer: 'Nomination staff pour valider les KYC entreprises.', community_curator: '3+ challenges communautaires publiés (auto).' } + }, + deepScan: { + btn: 'Deep scan (IA)', + runningToast: 'Deep scan lancé…', + successToast: 'Deep scan terminé', + resultLabel: 'Résultat deep scan', + resultScore: 'Score', + resultVerdict: 'Verdict', + resultPool: 'Corpus comparé', + flaggedAt: 'Flaggé à' + }, + variant: { + btn: 'Générer variante IA', + dialogTitle: 'Générer une variante', + typeLabel: 'Type', + typeHarder: 'Plus difficile', + typeEasier: 'Plus facile', + targetParamLabel: 'Indication (optionnel)', + targetParamHint: 'Guide texte pour l\'IA — p. ex. « augmente le facteur de branchement »', + submit: 'Générer', + successToast: 'Variante générée + créée en draft' + }, + backendStatus: { + banner: 'Backend indisponible — nouvelle tentative dans {seconds}s', + bannerNow: 'Backend indisponible — tentative en cours…', + retryNow: 'Réessayer maintenant', + probing: 'Vérification…', + reconnected: 'Connexion au backend rétablie' + }, + sso: { + title: 'Sessions SSO', + headingActive: 'Sessions SSO actives', + subtitle: + "Toutes les sessions authentifiées via un IdP externe (login_method='sso'). Utile pour l'audit et pour révoquer une session à distance en cas de compromission.", + filterEnterpriseLabel: 'Filtrer par entreprise (UUID)', + filterBtn: 'Filtrer', + resetFilterBtn: 'Réinitialiser', + emptyState: 'Aucune session SSO active.', + colUser: 'Utilisateur', + colEnterprise: 'Entreprise', + colCreated: 'Créée', + colLastUsed: 'Dernière activité', + colActions: 'Actions', + revokeBtn: 'Révoquer', + revokedToast: 'Session révoquée', + revokeDialogTitle: 'Révoquer la session SSO', + revokeHint: 'Compromission suspectée, session zombie, etc.' + }, + loginPage: { + pageTitle: 'Admin — Connexion', + controlPanel: 'Panneau de contrôle', + emailOrUsername: 'Email ou pseudo', + password: 'Mot de passe', + totpCode: 'Code TOTP', + useBackupCode: 'Utiliser un code de secours', + signInBtn: 'Se connecter', + accessRestricted: 'Cet accès est réservé aux administrateurs Skilluv.', + notAdminError: "Ce compte n'a pas les droits admin.", + unexpectedError: 'Erreur inattendue.' + }, + levelUp: { + newTitle: 'Nouveau titre', + keepGoing: 'Continue comme ça !', + continueBtn: 'Continuer' + }, + multiSelect: { + remove: 'Retirer', + clearAll: 'Tout retirer' + }, + replayPlayer: { + originalDuration: 'Durée originale' + }, + shareButton: { + linkCopied: 'Lien copié !', + share: 'Partager', + copyLink: 'Copier le lien' } }, community: { diff --git a/src/lib/i18n/index.svelte.ts b/src/lib/i18n/index.svelte.ts index d27f276..5bf16f0 100644 --- a/src/lib/i18n/index.svelte.ts +++ b/src/lib/i18n/index.svelte.ts @@ -66,3 +66,20 @@ class I18nState { } export const i18n = new I18nState(); + +/** + * BCP-47 tag matching the current UI locale, for `Intl.DateTimeFormat` / + * `.toLocaleString()` / `.toLocaleDateString()` etc. Duplicated inline as + * `function intlLocale()` in ~10 pages before extraction; centralize here so + * a future locale addition only touches one place. + */ +export function intlLocale(): string { + switch (i18n.locale) { + case 'ar': + return 'ar'; + case 'en': + return 'en-US'; + default: + return 'fr-FR'; + } +} diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index f3a4520..776d56b 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -1,2 +1,2 @@ -export { i18n } from './index.svelte'; +export { i18n, intlLocale } from './index.svelte'; export type { Locale } from './index.svelte'; diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts index e723a02..e194218 100644 --- a/src/lib/i18n/types.ts +++ b/src/lib/i18n/types.ts @@ -1359,6 +1359,81 @@ export interface Translations { legendary: string; }; }; + deepScan: { + btn: string; + runningToast: string; + successToast: string; + resultLabel: string; + resultScore: string; + resultVerdict: string; + resultPool: string; + flaggedAt: string; + }; + variant: { + btn: string; + dialogTitle: string; + typeLabel: string; + typeHarder: string; + typeEasier: string; + targetParamLabel: string; + targetParamHint: string; + submit: string; + successToast: string; + }; + backendStatus: { + banner: string; + bannerNow: string; + retryNow: string; + probing: string; + reconnected: string; + }; + sso: { + title: string; + headingActive: string; + subtitle: string; + filterEnterpriseLabel: string; + filterBtn: string; + resetFilterBtn: string; + emptyState: string; + colUser: string; + colEnterprise: string; + colCreated: string; + colLastUsed: string; + colActions: string; + revokeBtn: string; + revokedToast: string; + revokeDialogTitle: string; + revokeHint: string; + }; + loginPage: { + pageTitle: string; + controlPanel: string; + emailOrUsername: string; + password: string; + totpCode: string; + useBackupCode: string; + signInBtn: string; + accessRestricted: string; + notAdminError: string; + unexpectedError: string; + }; + levelUp: { + newTitle: string; + keepGoing: string; + continueBtn: string; + }; + multiSelect: { + remove: string; + clearAll: string; + }; + replayPlayer: { + originalDuration: string; + }; + shareButton: { + linkCopied: string; + share: string; + copyLink: string; + }; }; community: { title: string; diff --git a/src/lib/stores/backendStatus.svelte.ts b/src/lib/stores/backendStatus.svelte.ts new file mode 100644 index 0000000..f8a2c1d --- /dev/null +++ b/src/lib/stores/backendStatus.svelte.ts @@ -0,0 +1,40 @@ +/** + * Global backend-health flag. Turns `isDown = true` when a request fails at the + * network layer (fetch throws — DNS fail, connection refused, CORS/preflight + * failure, timeout). HTTP responses (4xx/5xx) do NOT flip it — those are + * per-request errors that the client already surfaces via SkilluError toast. + * + * The `` component subscribes to this store and polls + * `/api/health` with exponential backoff. On success it flips back to false + * and fires a "reconnected" toast. + */ + +class BackendStatus { + isDown = $state(false); + /** Nb of consecutive failed health probes, used for backoff. */ + failedProbes = $state(0); + /** UNIX ms of the next scheduled probe. `0` when not scheduled. */ + nextProbeAt = $state(0); + + markDown() { + if (!this.isDown) this.isDown = true; + } + + markUp() { + if (this.isDown) this.isDown = false; + this.failedProbes = 0; + this.nextProbeAt = 0; + } +} + +export const backendStatus = new BackendStatus(); + +/** + * Backoff schedule for the retry probe (seconds). We start aggressive so a + * quick reboot barely disrupts the user, then relax to avoid hammering when + * the outage is longer. + */ +export function nextBackoffSeconds(failedProbes: number): number { + const schedule = [3, 5, 10, 20, 30, 60]; + return schedule[Math.min(failedProbes, schedule.length - 1)]; +} diff --git a/src/lib/stores/backendStatus.test.ts b/src/lib/stores/backendStatus.test.ts new file mode 100644 index 0000000..bfcf700 --- /dev/null +++ b/src/lib/stores/backendStatus.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { backendStatus, nextBackoffSeconds } from './backendStatus.svelte'; + +describe('backendStatus store', () => { + beforeEach(() => { + backendStatus.markUp(); + }); + + it('starts in `up` state', () => { + expect(backendStatus.isDown).toBe(false); + expect(backendStatus.failedProbes).toBe(0); + }); + + it('markDown flips isDown once, idempotent on repeat', () => { + backendStatus.markDown(); + expect(backendStatus.isDown).toBe(true); + backendStatus.markDown(); + expect(backendStatus.isDown).toBe(true); + }); + + it('markUp resets isDown + failedProbes + nextProbeAt together', () => { + backendStatus.markDown(); + backendStatus.failedProbes = 5; + backendStatus.nextProbeAt = Date.now() + 30000; + backendStatus.markUp(); + expect(backendStatus.isDown).toBe(false); + expect(backendStatus.failedProbes).toBe(0); + expect(backendStatus.nextProbeAt).toBe(0); + }); +}); + +describe('nextBackoffSeconds', () => { + it('ramps up on repeated failures', () => { + expect(nextBackoffSeconds(0)).toBe(3); + expect(nextBackoffSeconds(1)).toBe(5); + expect(nextBackoffSeconds(2)).toBe(10); + expect(nextBackoffSeconds(3)).toBe(20); + expect(nextBackoffSeconds(4)).toBe(30); + }); + + it('caps at the max backoff (60s) beyond the schedule length', () => { + expect(nextBackoffSeconds(5)).toBe(60); + expect(nextBackoffSeconds(20)).toBe(60); + expect(nextBackoffSeconds(1000)).toBe(60); + }); +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5622199..f5157e4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { onMount } from 'svelte'; import { i18n } from '$lib/i18n'; import { auth } from '$stores/auth.svelte'; + import BackendStatusBanner from '$components/ui/BackendStatusBanner.svelte'; import { type Component } from 'svelte'; import { LayoutDashboard, @@ -73,17 +74,18 @@ } } - onMount(() => { - if (!auth.isAuthenticated && !pathname.startsWith('/auth/')) { - window.location.href = '/auth/login'; - } - }); + // Auth is enforced by hooks.server.ts (SSR 303 to /auth/login when user is + // null). The old client-side check here fired before the `$effect` above + // had migrated `data.user` into the store, so it kicked out authenticated + // users on direct navigation. Removed — SSR is the single source of truth. Skilluv Admin + + {#if pathname.startsWith('/auth/')} {@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d45311d..8f3b754 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,7 +9,7 @@ import Skeleton from '$components/ui/Skeleton.svelte'; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import { Users as UsersIcon, Trophy, @@ -69,10 +69,6 @@ loading = false; } - function intlLocale(): string { - return i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US'; - } - function fmtEur(cents: number, currency = 'EUR'): string { return new Intl.NumberFormat(intlLocale(), { style: 'currency', diff --git a/src/routes/audit-log/+page.svelte b/src/routes/audit-log/+page.svelte index d062c92..b7bf54e 100644 --- a/src/routes/audit-log/+page.svelte +++ b/src/routes/audit-log/+page.svelte @@ -7,7 +7,7 @@ import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import Modal from '$components/ui/Modal.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import { SkilluError } from '$api/client'; import { toast } from '$stores/toast.svelte'; import { Filter, X } from '@lucide/svelte'; @@ -83,10 +83,6 @@ void load(); } - function intlLocale(): string { - return i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US'; - } - function fmtDate(iso: string): string { return new Date(iso).toLocaleString(intlLocale()); } diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 8d73b2e..9c4642c 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -25,10 +25,7 @@ onMount(() => { if (preError === 'not_admin') { - error = - i18n.locale === 'fr' - ? "Ce compte n'a pas les droits admin." - : 'This account does not have admin privileges.'; + error = i18n.t('admin.loginPage.notAdminError'); } }); @@ -44,10 +41,7 @@ }); if (res.data.user) { if (res.data.user.role !== 'admin') { - error = - i18n.locale === 'fr' - ? "Ce compte n'a pas les droits admin." - : 'This account does not have admin privileges.'; + error = i18n.t('admin.loginPage.notAdminError'); return; } auth.setUser(res.data.user, res.data.login_method ?? 'password'); @@ -74,7 +68,7 @@ error = err.message; } } else { - error = i18n.locale === 'fr' ? 'Erreur inattendue.' : 'Unexpected error.'; + error = i18n.t('admin.loginPage.unexpectedError'); } } finally { loading = false; @@ -83,7 +77,7 @@ - {i18n.locale === 'fr' ? 'Admin — Connexion' : 'Admin sign in'} + {i18n.t('admin.loginPage.pageTitle')}
@@ -94,7 +88,7 @@ Skilluv Admin

- {i18n.locale === 'fr' ? 'Panneau de contrôle' : 'Control panel'} + {i18n.t('admin.loginPage.controlPanel')}

@@ -106,21 +100,21 @@ {/if} {#if requiresTotp} - {i18n.locale === 'fr' - ? 'Utiliser un code de secours' - : 'Use a backup code'} + {i18n.t('admin.loginPage.useBackupCode')} {/if}

- {i18n.locale === 'fr' - ? 'Cet accès est réservé aux administrateurs Skilluv.' - : 'This access is restricted to Skilluv administrators.'} + {i18n.t('admin.loginPage.accessRestricted')}

diff --git a/src/routes/challenges/+page.svelte b/src/routes/challenges/+page.svelte index 0a8e61a..c3387a5 100644 --- a/src/routes/challenges/+page.svelte +++ b/src/routes/challenges/+page.svelte @@ -4,46 +4,28 @@ import { SkilluError } from '$api/client'; import Badge from '$components/ui/Badge.svelte'; import Button from '$components/ui/Button.svelte'; - import Modal from '$components/ui/Modal.svelte'; - import Select from '$components/ui/Select.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; + import ChallengeFormModal from '$components/admin/ChallengeFormModal.svelte'; + import ChallengeVariantDialog from '$components/admin/ChallengeVariantDialog.svelte'; import { i18n } from '$lib/i18n'; import { toast } from '$stores/toast.svelte'; - import type { - Challenge, - ChallengeDifficulty, - ChallengeMode, - ChallengeTone, - SkillDomain - } from '$types'; - import { Plus, Pencil } from '@lucide/svelte'; + import type { Challenge } from '$types'; + import { Plus, Pencil, Sparkles } from '@lucide/svelte'; let challenges = $state([]); let total = $state(0); let loading = $state(true); let query = $state(''); - // Form state (shared between create + edit) + // The form itself lives in ; this page keeps ownership + // of the open/editing flags + submit-pending state. let showForm = $state(false); let editing = $state(null); // null → create mode let submitting = $state(false); - const form = $state({ - title: '', - description: '', - instructions: '', - skill_domain: 'code' as SkillDomain, - difficulty: 3 as ChallengeDifficulty, - mode: 'solo' as ChallengeMode, - duration_minutes: 0, - ai_allowed: false, - tone: 'serious' as ChallengeTone, - language: '', - prerequisite_fragments: 0, - reward_fragments: 0, - is_onboarding: false, - expected_output: '', - test_cases: '' - }); + + // Variant dialog — IA-C.1. Only offered on published challenges. + let variantSource = $state(null); + let generatingVariant = $state(false); async function loadChallenges() { loading = true; @@ -61,9 +43,11 @@ async function publish(id: string) { try { await adminApi.publishChallenge(id); - const c = challenges.find((ch) => ch.id === id); - if (c) c.status = 'published'; toast.success(i18n.t('admin.challenges.published')); + // Refetch instead of mutating the array item in place — see + // qa/BUGS_FRONT.md (Corrigés): mutating a $state property + // doesn't always re-render `{#if}` blocks in Svelte 5 dev mode. + await loadChallenges(); } catch (e) { toast.error(e instanceof SkilluError ? e.message : i18n.t('admin.common.errorGeneric')); } @@ -72,119 +56,50 @@ async function archive(id: string) { try { await adminApi.archiveChallenge(id); - const c = challenges.find((ch) => ch.id === id); - if (c) c.status = 'archived'; toast.success(i18n.t('admin.challenges.archived')); + await loadChallenges(); } catch (e) { toast.error(e instanceof SkilluError ? e.message : i18n.t('admin.common.errorGeneric')); } } - function resetForm() { - form.title = ''; - form.description = ''; - form.instructions = ''; - form.skill_domain = 'code'; - form.difficulty = 3; - form.mode = 'solo'; - form.duration_minutes = 0; - form.ai_allowed = false; - form.tone = 'serious'; - form.language = ''; - form.prerequisite_fragments = 0; - form.reward_fragments = 0; - form.is_onboarding = false; - form.expected_output = ''; - form.test_cases = ''; - } - function openCreate() { - resetForm(); editing = null; showForm = true; } function openEdit(ch: Challenge) { editing = ch; - form.title = ch.title; - form.description = ch.description; - form.instructions = ch.instructions; - form.skill_domain = ch.skill_domain; - form.difficulty = ch.difficulty; - form.mode = ch.mode; - form.duration_minutes = ch.duration_minutes ?? 0; - form.ai_allowed = ch.ai_allowed; - form.tone = ch.tone; - form.language = ch.language ?? ''; - form.prerequisite_fragments = ch.prerequisite_fragments; - form.reward_fragments = ch.reward_fragments; - form.is_onboarding = ch.is_onboarding ?? false; - form.expected_output = ch.expected_output ?? ''; - form.test_cases = ch.test_cases ? JSON.stringify(ch.test_cases, null, 2) : ''; showForm = true; } - function parseTestCases(): { ok: true; value: unknown } | { ok: false } { - const raw = form.test_cases.trim(); - if (!raw) return { ok: true, value: undefined }; + function openVariant(ch: Challenge) { + variantSource = ch; + } + + async function submitVariant(body: { variant_type: 'harder' | 'easier'; target_param?: string }) { + if (!variantSource) return; + generatingVariant = true; try { - return { ok: true, value: JSON.parse(raw) }; - } catch { - return { ok: false }; + await adminApi.generateChallengeVariant(variantSource.id, body); + toast.success(i18n.t('admin.variant.successToast')); + variantSource = null; + await loadChallenges(); + } catch (e) { + toast.error(e instanceof SkilluError ? e.message : i18n.t('admin.common.errorGeneric')); + } finally { + generatingVariant = false; } } - async function submit(e: SubmitEvent) { - e.preventDefault(); - if (submitting) return; - const parsed = parseTestCases(); - if (!parsed.ok) { - toast.error(i18n.t('admin.challenges.testCasesInvalid')); - return; - } + async function submit(body: ChallengeCreateBody | ChallengePatchBody) { submitting = true; try { if (editing) { - // PATCH — n'envoie que les champs modifiés serait idéal, mais l'endpoint - // accepte des Option partout et compare la valeur reçue à la valeur - // stockée. Envoyer tous les champs remplis évite les surprises. - const body: ChallengePatchBody = { - title: form.title.trim(), - description: form.description.trim(), - instructions: form.instructions.trim(), - skill_domain: form.skill_domain, - difficulty: form.difficulty, - mode: form.mode, - duration_minutes: form.duration_minutes || null, - ai_allowed: form.ai_allowed, - tone: form.tone, - language: form.language.trim() || null, - prerequisite_fragments: form.prerequisite_fragments, - reward_fragments: form.reward_fragments, - expected_output: form.expected_output.trim() || null, - test_cases: parsed.value - }; - await adminApi.updateChallenge(editing.id, body); + await adminApi.updateChallenge(editing.id, body as ChallengePatchBody); toast.success(i18n.t('admin.challenges.updated')); } else { - const body: ChallengeCreateBody = { - title: form.title.trim(), - description: form.description.trim(), - instructions: form.instructions.trim(), - skill_domain: form.skill_domain, - difficulty: form.difficulty, - mode: form.mode, - duration_minutes: form.duration_minutes || null, - ai_allowed: form.ai_allowed, - tone: form.tone, - language: form.language.trim() || null, - prerequisite_fragments: form.prerequisite_fragments, - reward_fragments: form.reward_fragments, - is_onboarding: form.is_onboarding, - expected_output: form.expected_output.trim() || null, - test_cases: parsed.value - }; - await adminApi.createChallenge(body); + await adminApi.createChallenge(body as ChallengeCreateBody); toast.success(i18n.t('admin.challenges.created')); } showForm = false; @@ -273,6 +188,10 @@ {/if} {#if ch.status === 'published'} + @@ -284,127 +203,19 @@ {/if} - { showForm = false; editing = null; }} -> -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ onsubmit={submit} +/> -
-

{i18n.t('admin.challenges.expectedOutput')}

- -
-
-

{i18n.t('admin.challenges.testCases')}

- -

{i18n.t('admin.challenges.testCasesHint')}

-
+ (variantSource = null)} + onsubmit={submitVariant} +/> - - {#if !editing} - - {/if} -
- -
- - -
-
-
diff --git a/src/routes/enterprise-kyc/+page.svelte b/src/routes/enterprise-kyc/+page.svelte index 9a5f346..4387749 100644 --- a/src/routes/enterprise-kyc/+page.svelte +++ b/src/routes/enterprise-kyc/+page.svelte @@ -1,6 +1,6 @@ diff --git a/src/routes/enterprises/+page.svelte b/src/routes/enterprises/+page.svelte index 29c7a0f..a333af5 100644 --- a/src/routes/enterprises/+page.svelte +++ b/src/routes/enterprises/+page.svelte @@ -2,7 +2,7 @@ import { adminApi } from '$api/admin'; import { errorMessage } from '$api/errors'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { EnterpriseAdmin, EnterpriseType } from '$lib/types'; import Badge from '$components/ui/Badge.svelte'; import Select from '$components/ui/Select.svelte'; @@ -56,10 +56,11 @@ function fmtDate(iso: string): string { try { - return new Date(iso).toLocaleDateString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US', - { day: '2-digit', month: 'short', year: 'numeric' } - ); + return new Date(iso).toLocaleDateString(intlLocale(), { + day: '2-digit', + month: 'short', + year: 'numeric' + }); } catch { return iso; } diff --git a/src/routes/enterprises/[id]/+page.svelte b/src/routes/enterprises/[id]/+page.svelte index d75eee9..860d9d2 100644 --- a/src/routes/enterprises/[id]/+page.svelte +++ b/src/routes/enterprises/[id]/+page.svelte @@ -5,7 +5,7 @@ import { errorMessage } from '$api/errors'; import { SkilluError } from '$api/client'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { EnterpriseAdmin, EnterpriseType, @@ -140,10 +140,11 @@ function fmtDate(iso: string): string { if (!iso) return '—'; try { - return new Date(iso).toLocaleDateString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US', - { day: '2-digit', month: 'short', year: 'numeric' } - ); + return new Date(iso).toLocaleDateString(intlLocale(), { + day: '2-digit', + month: 'short', + year: 'numeric' + }); } catch { return iso; } diff --git a/src/routes/fraud/+page.svelte b/src/routes/fraud/+page.svelte index fa68dce..7e117be 100644 --- a/src/routes/fraud/+page.svelte +++ b/src/routes/fraud/+page.svelte @@ -2,7 +2,7 @@ import { adminApi } from '$api/admin'; import { errorMessage } from '$api/errors'; import { toast } from '$stores/toast.svelte'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import type { FraudFlaggedDeliverable, FraudSuspectedUser, @@ -53,8 +53,18 @@ let evalWindowDays = $state(30); let scanning = $state(false); let llmEvaluating = $state(false); + let deepScanning = $state(false); let scanResult = $state(null); let llmResult = $state(null); + let deepScanResult = $state<{ + deliverable_id: string; + deep_plagiarism: { + similarity_score?: number; + verdict?: string; + flagged_at?: string; + comparison_pool_size?: number; + }; + } | null>(null); async function loadQueue() { loading = true; @@ -173,6 +183,25 @@ } } + async function runDeepScan() { + const id = evalDeliverableId.trim(); + if (!id || deepScanning) return; + deepScanning = true; + toast.info(i18n.t('admin.deepScan.runningToast')); + try { + const res = await adminApi.deepScanDeliverable(id, { + threshold: evalThreshold, + window_days: evalWindowDays + }); + deepScanResult = res.data; + toast.success(i18n.t('admin.deepScan.successToast')); + } catch (e) { + toast.error(errorMessage(e)); + } finally { + deepScanning = false; + } + } + function scoreVariant(raw: string | number): 'error' | 'warning' | 'default' { const n = typeof raw === 'string' ? Number(raw) : raw; if (Number.isNaN(n)) return 'default'; @@ -190,9 +219,7 @@ function fmtDate(iso: string | null): string { if (!iso) return '—'; try { - return new Date(iso).toLocaleString( - i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US' - ); + return new Date(iso).toLocaleString(intlLocale()); } catch { return iso; } @@ -510,6 +537,48 @@ {/if} + +
+

{i18n.t('admin.deepScan.btn')}

+ + + {#if deepScanResult} +
+

{i18n.t('admin.deepScan.resultLabel')}

+
+ {#if deepScanResult.deep_plagiarism.similarity_score !== undefined} +
{i18n.t('admin.deepScan.resultScore')}
+
+ + {fmtScore(deepScanResult.deep_plagiarism.similarity_score)} + +
+ {/if} + {#if deepScanResult.deep_plagiarism.verdict} +
{i18n.t('admin.deepScan.resultVerdict')}
+
{deepScanResult.deep_plagiarism.verdict}
+ {/if} + {#if deepScanResult.deep_plagiarism.comparison_pool_size !== undefined} +
{i18n.t('admin.deepScan.resultPool')}
+
{deepScanResult.deep_plagiarism.comparison_pool_size}
+ {/if} + {#if deepScanResult.deep_plagiarism.flagged_at} +
{i18n.t('admin.deepScan.flaggedAt')}
+
{deepScanResult.deep_plagiarism.flagged_at}
+ {/if} +
+
+ {/if} +
{/if} diff --git a/src/routes/operations/+page.svelte b/src/routes/operations/+page.svelte index f6ca372..2652eeb 100644 --- a/src/routes/operations/+page.svelte +++ b/src/routes/operations/+page.svelte @@ -247,11 +247,7 @@ const exportUrl = $derived(adminApi.accountingExportUrl(expYear, expMonth)); const exportFilename = $derived(`skilluv-accounting-${expYear}-${String(expMonth).padStart(2, '0')}.csv`); - onMount(() => { - if (!auth.isAuthenticated) { - void goto('/auth/login?redirect=/operations'); - } - }); + // Auth enforced by hooks.server.ts — client re-check was racy on deep-links. const inputCls = 'w-full rounded-full border border-border bg-surface-overlay px-4 py-2 text-sm focus:border-primary focus:outline-none'; diff --git a/src/routes/projects/+page.svelte b/src/routes/projects/+page.svelte index e19506d..3fddca4 100644 --- a/src/routes/projects/+page.svelte +++ b/src/routes/projects/+page.svelte @@ -4,11 +4,12 @@ import { SkilluError } from '$api/client'; import Badge from '$components/ui/Badge.svelte'; import Button from '$components/ui/Button.svelte'; - import Modal from '$components/ui/Modal.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; + import ProjectFormModal from '$components/admin/ProjectFormModal.svelte'; import { toast } from '$stores/toast.svelte'; import type { ProjectListItem, + ProjectDetail, ProjectCreateBody, ProjectPatchBody, ProjectListFilters, @@ -34,27 +35,11 @@ let filterCurated = $state<'all' | 'true' | 'false'>('all'); let filterLevel = $state<'all' | '1' | '2' | '3'>('all'); - // Form state (shared create + edit) + // Form itself lives in ; page keeps ownership of the + // open flag + which project is being edited (as its full detail). let showForm = $state(false); - let editing = $state(null); + let editing = $state(null); let submitting = $state(false); - const form = $state({ - slug: '', - name: '', - description: '', - repo_url: '', - demo_url: '', - tech_stack: '', // comma-separated - is_oss: true, - looking_for_contributors: false, - owner_type: 'user' as 'user' | 'guild', - owner_id: '', // UUID string - curated_by_admin: true, - is_flagship: false, - flagship_steward_user_id: '', - skilluv_partnership_level: '' as '' | '1' | '2' | '3', - skilluv_editorial_notes: '' - }); function applyFilters() { filters.is_flagship = filterFlagship === 'all' ? undefined : filterFlagship === 'true'; @@ -81,52 +66,15 @@ } } - function resetForm() { - form.slug = ''; - form.name = ''; - form.description = ''; - form.repo_url = ''; - form.demo_url = ''; - form.tech_stack = ''; - form.is_oss = true; - form.looking_for_contributors = false; - form.owner_type = 'user'; - form.owner_id = ''; - form.curated_by_admin = true; - form.is_flagship = false; - form.flagship_steward_user_id = ''; - form.skilluv_partnership_level = ''; - form.skilluv_editorial_notes = ''; - } - function openCreate() { - resetForm(); editing = null; showForm = true; } async function openEdit(p: ProjectListItem) { - editing = p; try { const res = await adminApi.getAdminProject(p.slug); - const d = res.data; - form.slug = d.slug; - form.name = d.name; - form.description = d.description ?? ''; - form.repo_url = d.repo_url ?? ''; - form.demo_url = d.demo_url ?? ''; - form.tech_stack = (d.tech_stack ?? []).join(', '); - form.is_oss = d.is_oss; - form.looking_for_contributors = d.looking_for_contributors; - form.owner_type = d.owner_type; - form.owner_id = d.owner_id; - form.curated_by_admin = d.curated_by_admin; - form.is_flagship = d.is_flagship; - form.flagship_steward_user_id = d.flagship_steward_user_id ?? ''; - form.skilluv_partnership_level = d.skilluv_partnership_level - ? (String(d.skilluv_partnership_level) as '1' | '2' | '3') - : ''; - form.skilluv_editorial_notes = d.skilluv_editorial_notes ?? ''; + editing = res.data; showForm = true; } catch (e) { toast.error(e instanceof SkilluError ? e.message : 'Erreur de chargement du projet'); @@ -144,66 +92,14 @@ } } - function techStackFromForm(): string[] { - return form.tech_stack - .split(',') - .map((s) => s.trim()) - .filter((s) => s.length > 0); - } - - function partnershipLevelFromForm(): PartnershipLevel | null { - if (!form.skilluv_partnership_level) return null; - return Number(form.skilluv_partnership_level) as PartnershipLevel; - } - - async function submit(e: SubmitEvent) { - e.preventDefault(); - if (submitting) return; - - // Flagship validation (mirrors backend) - if (form.is_flagship && !form.flagship_steward_user_id.trim()) { - toast.error('Un projet flagship nécessite un steward (UUID user).'); - return; - } - + async function submit(body: ProjectCreateBody | ProjectPatchBody) { submitting = true; try { if (editing) { - const body: ProjectPatchBody = { - name: form.name.trim(), - description: form.description.trim() || null, - repo_url: form.repo_url.trim() || null, - demo_url: form.demo_url.trim() || null, - tech_stack: techStackFromForm(), - is_oss: form.is_oss, - looking_for_contributors: form.looking_for_contributors, - curated_by_admin: form.curated_by_admin, - is_flagship: form.is_flagship, - flagship_steward_user_id: form.flagship_steward_user_id.trim() || null, - skilluv_partnership_level: partnershipLevelFromForm(), - skilluv_editorial_notes: form.skilluv_editorial_notes.trim() || null - }; - await adminApi.patchAdminProject(editing.slug, body); + await adminApi.patchAdminProject(editing.slug, body as ProjectPatchBody); toast.success('Projet mis à jour'); } else { - const body: ProjectCreateBody = { - slug: form.slug.trim(), - name: form.name.trim(), - description: form.description.trim() || null, - repo_url: form.repo_url.trim() || null, - demo_url: form.demo_url.trim() || null, - tech_stack: techStackFromForm(), - is_oss: form.is_oss, - looking_for_contributors: form.looking_for_contributors, - owner_type: form.owner_type, - owner_id: form.owner_id.trim(), - curated_by_admin: form.curated_by_admin, - is_flagship: form.is_flagship, - flagship_steward_user_id: form.flagship_steward_user_id.trim() || null, - skilluv_partnership_level: partnershipLevelFromForm(), - skilluv_editorial_notes: form.skilluv_editorial_notes.trim() || null - }; - await adminApi.createAdminProject(body); + await adminApi.createAdminProject(body as ProjectCreateBody); toast.success('Projet créé'); } showForm = false; @@ -427,176 +323,10 @@ {/if} - - (showForm = false)} -> - {#snippet children()} -
- {#if !editing} -
- - -

Minuscules, chiffres, tirets. Immuable après création.

-
- {/if} -
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
- - {#if !editing} -
-
- - -
-
- - -
-
- {/if} - -
- - - - -
- - {#if form.is_flagship} -
- - -
- {/if} - -
- - -
- -
- - -
- -
- - -
-
- {/snippet} -
+ onsubmit={submit} +/> diff --git a/src/routes/skills/+page.svelte b/src/routes/skills/+page.svelte index 8649cf1..28f2676 100644 --- a/src/routes/skills/+page.svelte +++ b/src/routes/skills/+page.svelte @@ -11,12 +11,12 @@ } from '$lib/types'; import Button from '$components/ui/Button.svelte'; import Input from '$components/ui/Input.svelte'; - import Modal from '$components/ui/Modal.svelte'; import Select from '$components/ui/Select.svelte'; import Table from '$components/ui/Table.svelte'; import Badge from '$components/ui/Badge.svelte'; import Skeleton from '$components/ui/Skeleton.svelte'; import Pagination from '$components/ui/Pagination.svelte'; + import SkillFormModal from '$components/admin/SkillFormModal.svelte'; import { Plus, Pencil, Copy } from '@lucide/svelte'; const DOMAINS: SkillNodeDomain[] = [ @@ -40,69 +40,12 @@ let totalPages = $state(0); let total = $state(0); - // --- Create dialog --- + // --- Modals (form state is owned by ) --- let showCreate = $state(false); let creating = $state(false); - let createSlug = $state(''); - let createDisplayName = $state(''); - let createDescription = $state(''); - let createDomain = $state('code'); - let createParentId = $state(''); - let createAliasesRaw = $state(''); - let createExternalRefsRaw = $state(''); - let createIsSkilluv = $state(false); - let createTouched = $state(false); - - const createSlugError = $derived.by(() => { - if (!createTouched) return null; - const s = createSlug.trim(); - if (s.length < 2 || s.length > 80) return i18n.t('admin.skills.create.slugHint'); - if (!/^[a-z0-9_-]+$/.test(s)) return i18n.t('admin.skills.create.slugHint'); - return null; - }); - const createExtRefsError = $derived.by(() => { - if (!createTouched || createExternalRefsRaw.trim() === '') return null; - try { - const parsed = JSON.parse(createExternalRefsRaw); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - return i18n.t('admin.skills.create.externalRefsInvalidJson'); - } catch { - return i18n.t('admin.skills.create.externalRefsInvalidJson'); - } - return null; - }); - const canCreate = $derived( - !creating && - createSlug.trim().length > 0 && - createDisplayName.trim().length > 0 && - createSlugError === null && - createExtRefsError === null - ); - - // --- Edit dialog --- let editTarget = $state(null); - let editDisplayName = $state(''); - let editDescription = $state(''); - let editDomain = $state('code'); - let editParentId = $state(''); - let editClearParent = $state(false); - let editAliasesRaw = $state(''); - let editExternalRefsRaw = $state(''); - let editIsSkilluv = $state(false); let editing = $state(false); - const editExtRefsError = $derived.by(() => { - if (editExternalRefsRaw.trim() === '') return null; - try { - const parsed = JSON.parse(editExternalRefsRaw); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - return i18n.t('admin.skills.create.externalRefsInvalidJson'); - } catch { - return i18n.t('admin.skills.create.externalRefsInvalidJson'); - } - return null; - }); - $effect(() => { void loadList(); }); @@ -136,43 +79,14 @@ const rows = $derived(skills.map((s) => s as unknown as Record)); - function parseList(raw: string): string[] { - return raw - .split(',') - .map((s) => s.trim()) - .filter((s) => s.length > 0); - } - function openCreate() { - createSlug = ''; - createDisplayName = ''; - createDescription = ''; - createDomain = 'code'; - createParentId = ''; - createAliasesRaw = ''; - createExternalRefsRaw = ''; - createIsSkilluv = false; - createTouched = false; showCreate = true; } - async function submitCreate() { - createTouched = true; - if (!canCreate) return; + async function submitCreate(body: CreateSkillNodeBody | UpdateSkillNodeBody) { creating = true; try { - const body: CreateSkillNodeBody = { - slug: createSlug.trim(), - display_name: createDisplayName.trim(), - description: createDescription.trim() || undefined, - domain: createDomain, - parent_id: createParentId.trim() || undefined, - aliases: parseList(createAliasesRaw), - external_refs: - createExternalRefsRaw.trim() === '' ? undefined : JSON.parse(createExternalRefsRaw), - is_skilluv_specific: createIsSkilluv - }; - await adminApi.createSkillNode(body); + await adminApi.createSkillNode(body as CreateSkillNodeBody); toast.success(i18n.t('admin.skills.create.successToast')); showCreate = false; await loadList(); @@ -185,31 +99,13 @@ function openEdit(s: SkillNodeAdmin) { editTarget = s; - editDisplayName = s.display_name; - editDescription = s.description ?? ''; - editDomain = s.domain; - editParentId = s.parent_id ?? ''; - editClearParent = false; - editAliasesRaw = ''; - editExternalRefsRaw = ''; - editIsSkilluv = s.is_skilluv_specific; } - async function submitEdit() { - if (!editTarget || editing || editExtRefsError !== null) return; + async function submitEdit(body: CreateSkillNodeBody | UpdateSkillNodeBody) { + if (!editTarget) return; editing = true; try { - const body: UpdateSkillNodeBody = { - display_name: editDisplayName.trim(), - description: editDescription, - domain: editDomain, - parent_id: editClearParent ? null : editParentId.trim() || undefined, - aliases: editAliasesRaw.trim() === '' ? undefined : parseList(editAliasesRaw), - external_refs: - editExternalRefsRaw.trim() === '' ? undefined : JSON.parse(editExternalRefsRaw), - is_skilluv_specific: editIsSkilluv - }; - await adminApi.updateSkillNode(editTarget.id, body); + await adminApi.updateSkillNode(editTarget.id, body as UpdateSkillNodeBody); toast.success(i18n.t('admin.skills.edit.successToast')); editTarget = null; await loadList(); @@ -364,196 +260,18 @@ {/if} - - (showCreate = false)} - size="lg" -> -
- (createTouched = true)} - error={createSlugError ?? undefined} - placeholder="react-hooks" - /> - (createTouched = true)} - /> -
- - -
-
-
- - {i18n.t('admin.skills.create.domainLabel')} - - -
- -
- - - {#if createExtRefsError} -

{createExtRefsError}

- {:else} -

{i18n.t('admin.skills.create.externalRefsHint')}

- {/if} -
- -
- - {#snippet actions()} - - - {/snippet} - + onsubmit={submitCreate} +/> - - (editTarget = null)} - size="lg" -> -
- -
- - -
-
-
- - {i18n.t('admin.skills.create.domainLabel')} - - -
- - -
- - - {#if editExtRefsError} -

{editExtRefsError}

- {/if} -
- -
- - {#snippet actions()} - - - {/snippet} - + onsubmit={submitEdit} +/> diff --git a/src/routes/sponsored-challenges/+page.svelte b/src/routes/sponsored-challenges/+page.svelte index 0205435..1e80145 100644 --- a/src/routes/sponsored-challenges/+page.svelte +++ b/src/routes/sponsored-challenges/+page.svelte @@ -1,6 +1,6 @@ @@ -323,80 +313,14 @@ {/if}
- (showDecide = false)} -> -
- {#if target} -
-

- {i18n.t('admin.sponsored.requestLabel')} -

-

{target.proposed_title}

-

- {fmtEur(target.budget_eur_cents)} · {target.duration_days} {i18n.t('admin.sponsored.daysSuffix')} · {target.skill_domain} -

-
- {/if} - -
- - -

{i18n.t('admin.sponsored.notesHint')}

-
- -
- - -
-
-
+ onsubmit={submitDecide} +/> s.session_id !== id); total = Math.max(0, total - 1); - toast.success(i18n.locale === 'fr' ? 'Session révoquée' : 'Session revoked'); + toast.success(i18n.t('admin.sso.revokedToast')); revokeTarget = null; } catch (e) { toast.error(errorMessage(e)); @@ -62,7 +62,7 @@ function fmtDate(iso: string): string { try { - return new Date(iso).toLocaleString(i18n.locale === 'fr' ? 'fr-FR' : 'en-US'); + return new Date(iso).toLocaleString(intlLocale()); } catch { return iso; } @@ -70,17 +70,15 @@ - {i18n.locale === 'fr' ? 'Sessions SSO' : 'SSO sessions'} — Skilluv + {i18n.t('admin.sso.title')} — Skilluv

- {i18n.locale === 'fr' ? 'Sessions SSO actives' : 'Active SSO sessions'} + {i18n.t('admin.sso.headingActive')}

- {i18n.locale === 'fr' - ? "Toutes les sessions authentifiées via un IdP externe (login_method='sso'). Utile pour l'audit et pour révoquer une session à distance en cas de compromission." - : "All sessions authenticated via an external IdP (login_method='sso'). Useful for auditing and remote-revoking a compromised session."} + {i18n.t('admin.sso.subtitle')}

@@ -123,29 +121,19 @@
{:else if sessions.length === 0}
- {i18n.locale === 'fr' ? 'Aucune session SSO active.' : 'No active SSO sessions.'} + {i18n.t('admin.sso.emptyState')}
{:else}
- - + + - - - + + + @@ -175,7 +163,7 @@ loading={revokingId === s.session_id} onclick={() => requestRevoke(s)} > - {i18n.locale === 'fr' ? 'Révoquer' : 'Revoke'} + {i18n.t('admin.sso.revokeBtn')} @@ -199,14 +187,12 @@ (revokeTarget = null)} diff --git a/src/routes/tenants/+page.svelte b/src/routes/tenants/+page.svelte index a3ba89b..82e303a 100644 --- a/src/routes/tenants/+page.svelte +++ b/src/routes/tenants/+page.svelte @@ -1,6 +1,6 @@ diff --git a/src/routes/tenants/[id]/+page.svelte b/src/routes/tenants/[id]/+page.svelte index 9381092..77afadc 100644 --- a/src/routes/tenants/[id]/+page.svelte +++ b/src/routes/tenants/[id]/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import { auth } from '$stores/auth.svelte'; import { toast } from '$stores/toast.svelte'; import { SkilluError } from '$api/client'; @@ -208,9 +208,6 @@ return r === 'owner' ? 'accent' : r === 'admin' ? 'primary' : r === 'instructor' ? 'warning' : 'default'; } - function intlLocale(): string { - return i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US'; - } function fmtDate(iso: string | null): string { if (!iso) return '—'; @@ -234,13 +231,8 @@ } }); - onMount(() => { - if (!auth.isAuthenticated) { - void goto(`/auth/login?redirect=/tenants/${tenantId}`); - return; - } - void loadTenant(); - }); + // Auth enforced by hooks.server.ts — client re-check was racy on deep-links. + onMount(() => void loadTenant()); diff --git a/src/routes/tournaments/+page.svelte b/src/routes/tournaments/+page.svelte index 32886a5..86cc7b6 100644 --- a/src/routes/tournaments/+page.svelte +++ b/src/routes/tournaments/+page.svelte @@ -218,11 +218,7 @@ } } - onMount(() => { - if (!auth.isAuthenticated) { - void goto('/auth/login?redirect=/tournaments'); - } - }); + // Auth enforced by hooks.server.ts — client re-check was racy on deep-links. const inputCls = 'w-full rounded-full border border-border bg-surface-overlay px-4 py-2 text-sm focus:border-primary focus:outline-none'; diff --git a/src/routes/users/+page.svelte b/src/routes/users/+page.svelte index dde4918..2b65b84 100644 --- a/src/routes/users/+page.svelte +++ b/src/routes/users/+page.svelte @@ -12,7 +12,7 @@ interface UserRow { id: string; username: string; display_name: string; email: string; role: string; skill_domain: string; title: string; total_fragments: number; - profile_active: boolean; banned: boolean; created_at: string; + profile_active: boolean; is_banned: boolean; created_at: string; } let users = $state([]); @@ -55,20 +55,24 @@ banSubmitting = true; try { await adminApi.banUser(banTarget.id, reason); - banTarget.banned = true; toast.success(i18n.t('admin.userDetail.bannedToast')); banTarget = null; + // Refetch the list — mutating a single row's property inside the + // `#each` doesn't re-render the action-button `{#if}` block in + // Svelte 5 (only the badge `{#if}` reacts). A fresh list is both + // simpler and matches the DB state authoritatively. + await loadUsers(); } catch (err) { toast.error(errorMessage(err)); + banSubmitting = false; } - banSubmitting = false; } async function unban(user: UserRow) { try { await adminApi.unbanUser(user.id); - user.banned = false; toast.success(i18n.t('admin.userDetail.unbannedToast')); + await loadUsers(); } catch (err) { toast.error(errorMessage(err)); } @@ -92,12 +96,12 @@ {:else}
{#each users as user} -
+
{user.display_name} @{user.username} - {#if user.banned}{i18n.t('admin.users.banned')}{/if} + {#if user.is_banned}{i18n.t('admin.users.banned')}{/if}
{user.skill_domain} @@ -105,7 +109,7 @@ {user.total_fragments} ◆
- {#if user.banned} + {#if user.is_banned} diff --git a/src/routes/users/[id]/+page.svelte b/src/routes/users/[id]/+page.svelte index 15b8669..adba0d8 100644 --- a/src/routes/users/[id]/+page.svelte +++ b/src/routes/users/[id]/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { i18n } from '$lib/i18n'; + import { i18n, intlLocale } from '$lib/i18n'; import { auth } from '$stores/auth.svelte'; import { toast } from '$stores/toast.svelte'; import { errorMessage } from '$api/errors'; @@ -11,7 +11,15 @@ // UserPrivate ne déclare pas `banned`/`ban_reason` alors que /admin/users/{id} // les renvoie — on élargit localement pour rester type-safe côté UI. - type AdminUser = UserPrivate & { banned?: boolean; ban_reason?: string | null; created_at?: string }; + // `webauthn_credentials_count` est admin-only (Trello RXEWNI6y) et pilote la + // détection de facteur fort pour activer le reset-2FA quand l'user a + // uniquement une passkey (pas de TOTP). + type AdminUser = UserPrivate & { + banned?: boolean; + ban_reason?: string | null; + created_at?: string; + webauthn_credentials_count?: number; + }; import Button from '$components/ui/Button.svelte'; import Badge from '$components/ui/Badge.svelte'; import ConfirmDangerousDialog from '$components/ui/ConfirmDangerousDialog.svelte'; @@ -65,7 +73,13 @@ const canReset2fa = $derived( user !== null && auth.user !== null && user.id !== auth.user.id ); - const targetHasStrongFactor = $derived(user?.totp_enabled === true); + // A strong factor is TOTP OR at least one WebAuthn credential. Backend + // admin_gate BE-B accepts either — the reset-2fa endpoint mirrors that + // so we must too, otherwise users with only a passkey would look + // "not-2FA'd" here and the button would stay grey. + const targetHasStrongFactor = $derived( + user?.totp_enabled === true || (user?.webauthn_credentials_count ?? 0) > 0 + ); async function load() { loading = true; @@ -135,9 +149,6 @@ return r === 'admin' ? 'accent' : r === 'enterprise' ? 'primary' : r === 'recruiter' ? 'warning' : 'default'; } - function intlLocale(): string { - return i18n.locale === 'ar' ? 'ar' : i18n.locale === 'fr' ? 'fr-FR' : 'en-US'; - } function fmtDate(iso: string | null): string { if (!iso) return '—'; @@ -156,13 +167,11 @@ return u.created_at ?? null; } - onMount(() => { - if (!auth.isAuthenticated) { - void goto(`/auth/login?redirect=/users/${userId}`); - return; - } - void load(); - }); + // Auth is enforced by hooks.server.ts (SSR) — no client-side re-check. + // The old `if (!auth.isAuthenticated) goto('/auth/login')` was racy: it fires + // before the layout's `$effect` has migrated `data.user` into the store on + // direct-navigation, breaking every deep-link (P0 bug pre-launch). + onMount(() => void load()); @@ -230,8 +239,14 @@ {i18n.t('admin.userDetail.verifiedEmail')} {/if} - {#if user.totp_enabled || user.email_2fa_enabled} - 2FA + {#if user.totp_enabled} + TOTP + {/if} + {#if user.email_2fa_enabled} + Email 2FA + {/if} + {#if (user.webauthn_credentials_count ?? 0) > 0} + Passkey ×{user.webauthn_credentials_count} {/if}

diff --git a/vite.config.ts b/vite.config.ts index 83688ea..3230246 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,22 +1,28 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; // Admin app runs on port 5174 in dev to sit alongside the public frontend -// (5173) without collision. Both proxy their /api/* through the same Rust -// backend on :3001 — CORS + cookie separation live server-side. In prod the -// admin app is served from `admin.skilluv.com`; the two frontends never -// share an origin, which is the whole point of splitting them. -export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], - server: { - port: 5174, - strictPort: true, - proxy: { - '/api': { - target: 'http://localhost:3001', - changeOrigin: true +// (5173) without collision. The dev server proxies /api/* to whichever +// backend is set in .env (VITE_API_PROXY_TARGET) — defaults to the +// production API at https://api.skill-uv.com so a fresh clone works +// out of the box. Point it at http://localhost:3001 in your local .env +// when running the Rust backend on your machine. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const proxyTarget = env.VITE_API_PROXY_TARGET || 'https://api.skill-uv.com'; + return { + plugins: [tailwindcss(), sveltekit()], + server: { + port: 5174, + strictPort: true, + proxy: { + '/api': { + target: proxyTarget, + changeOrigin: true, + secure: true + } } } - } + }; });

- {i18n.locale === 'fr' ? 'Utilisateur' : 'User'} - - {i18n.locale === 'fr' ? 'Entreprise' : 'Enterprise'} - {i18n.t('admin.sso.colUser')}{i18n.t('admin.sso.colEnterprise')} IP - {i18n.locale === 'fr' ? 'Créée' : 'Created'} - - {i18n.locale === 'fr' ? 'Dernière activité' : 'Last used'} - - {i18n.locale === 'fr' ? 'Actions' : 'Actions'} - {i18n.t('admin.sso.colCreated')}{i18n.t('admin.sso.colLastUsed')}{i18n.t('admin.sso.colActions')}