diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md
new file mode 100644
index 0000000..c88af4e
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/deferred-work.md
@@ -0,0 +1,13 @@
+# Deferred Work
+
+## Deferred from: code review of 3-5-secure-byok-management (2026-05-16)
+
+- **Test host BYOK + quota invité vide (Task 7.4)** — Scénario AC10 (hôte BYOK, quota invité vide) non couvert par test dédié dans `brainstorm-billing.test.ts`.
+- **`lastUsedAt` / `lastUsedFor` jamais mis à jour** — Champs Prisma présents mais non alimentés à l’usage des clés BYOK.
+- **`keyHash` non utilisé pour dédup** — Hash SHA-256 stocké sans logique de déduplication à l’upsert.
+- **Downgrade tier → désactivation clés hors liste** — Pas de `isActive=false` automatique au downgrade PRO/Business ; seul le rejet des nouveaux saves est en place.
+- **Rate limit POST `/api/user/api-keys`** — Pas de limite Redis documentée en spec optionnelle.
+
+## Deferred from: code review of 4-1-gdpr-cookie-consent (2026-05-16)
+
+- **AC5 anonymousAnalytics DB sync** — La synchronisation de `anonymousAnalytics` vers `UserAISettings` via `updateAISettings()` n'a pas été implémentée. Contrainte utilisateur : zéro écriture DB en 4.1, consentement 100 % client. À implémenter dans une story ultérieure si la cohérence DB devient requise.
diff --git a/_bmad-output/implementation-artifacts/spec-ci-cd-pipeline-improvement.md b/_bmad-output/implementation-artifacts/spec-ci-cd-pipeline-improvement.md
new file mode 100644
index 0000000..a41790a
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/spec-ci-cd-pipeline-improvement.md
@@ -0,0 +1,98 @@
+---
+title: 'CI/CD Pipeline Improvement'
+type: 'chore'
+created: '2026-05-16'
+status: 'in-progress'
+context:
+ - '{project-root}/.gitea/workflows/deploy.yaml'
+ - '{project-root}/memento-note/package.json'
+ - '{project-root}/docker-compose.yml'
+---
+
+
+
+## Intent
+
+**Problem:** The CI/CD pipeline (`.gitea/workflows/deploy.yaml`) deploys directly on push to main with zero validation — no lint, no tests, no build check. A broken push causes immediate downtime on the production server (192.168.1.190). There is no rollback mechanism and no notification when deployments succeed or fail.
+
+**Approach:** Add a CI validation pipeline (lint + typecheck + unit tests + build) that runs before the deploy pipeline. Add automatic rollback on deploy failure. Send Telegram notifications on deploy success/failure. Keep the push-to-main trigger.
+
+## Boundaries & Constraints
+
+**Always:**
+- All CI steps must run in Gitea Actions (self-hosted runner, ubuntu-24.04)
+- Deploy remains on push to main (same trigger)
+- Never use destructive DB commands in CI
+- Keep SSH-based deploy to 192.168.1.190
+- Use existing npm scripts where available (`npm run build`, `npm run test:unit`)
+
+**Ask First:**
+- Adding new npm dependencies (e.g. ESLint packages)
+- Changing the Docker build process
+- Modifying the production server entrypoint
+
+**Never:**
+- No cloud CI providers (GitHub Actions, CircleCI, etc.) — self-hosted Gitea only
+- No deployment to a different server
+- No E2E (Playwright) tests in CI — too heavy for the runner, keep local only
+- No modification to the Dockerfile or docker-compose.yml structure
+
+## I/O & Edge-Case Matrix
+
+| Scenario | Input / State | Expected Output / Behavior | Error Handling |
+|----------|--------------|---------------------------|----------------|
+| Push to main (all green) | Valid code, lint clean, tests pass, build OK | CI runs → deploy → health check → Telegram success notification | N/A |
+| Push to main (lint fail) | Code with lint errors | CI fails at lint step, deploy does NOT run, Telegram failure notification | Pipeline stops, no deploy |
+| Push to main (tests fail) | Lint passes but unit tests fail | CI fails at test step, deploy does NOT run, Telegram failure notification | Pipeline stops, no deploy |
+| Push to main (build fail) | Lint+tests pass but `next build` fails | CI fails at build step, deploy does NOT run, Telegram failure notification | Pipeline stops, no deploy |
+| Deploy succeeds but app unhealthy | App returns 5xx after 180s | Health check fails → rollback to previous container → Telegram failure notification | Rollback via `docker tag` + restore |
+| Deploy succeeds, app healthy | HTTP < 500 within 180s | Telegram success notification with app version/timestamp | N/A |
+| Manual workflow_dispatch | User clicks "Run" in Gitea | Same pipeline as push to main | Same error handling |
+
+
+
+## Code Map
+
+- `.gitea/workflows/deploy.yaml` — Current deploy pipeline (SSH-based, single job)
+- `.gitea/workflows/ci.yaml` — **NEW** CI validation pipeline (lint + test + build)
+- `memento-note/package.json` — Needs `lint` script added
+- `memento-note/eslint.config.mjs` — **NEW** ESLint flat config
+- `memento-note/tsconfig.json` — Already has `strict: true`
+
+## Tasks & Acceptance
+
+**Execution:**
+- [ ] `memento-note/eslint.config.mjs` — Create ESLint flat config with Next.js + TypeScript rules (no Prettier — keep it simple, lint-only)
+- [ ] `memento-note/package.json` — Add `"lint": "eslint . --ext .ts,.tsx"` script and `eslint` + `@typescript-eslint/*` + `eslint-config-next` devDependencies
+- [ ] `.gitea/workflows/ci.yaml` — Create CI pipeline: checkout → Node 22 setup → `npm ci` → `npx prisma generate` → `npm run lint` → `npm run test:unit` → `npm run build`. Triggered on push to main and on pull_request. Uses Gitea cache for node_modules.
+- [ ] `.gitea/workflows/deploy.yaml` — Refactor: add `needs: ci` job dependency so deploy only runs after CI passes. Add rollback step: before deploy, save current Docker image tag; on health-check failure, restore previous image and restart. Add Telegram notification step (success + failure) using `curl` to Telegram Bot API with `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` secrets.
+- [ ] `.gitea/workflows/deploy.yaml` — Add pre-deploy backup step: `docker tag memento-note_memento-note memento-note_memento-note:rollback` before building new image.
+
+**Acceptance Criteria:**
+- Given a push to main with lint errors, when CI runs, then the pipeline fails at lint and deploy does NOT execute
+- Given a push to main with failing unit tests, when CI runs, then the pipeline fails at tests and deploy does NOT execute
+- Given a push to main with valid code, when CI passes, then deploy runs and Telegram receives a success notification
+- Given a deploy where the app fails health check, when rollback triggers, then the previous Docker image is restored and the app returns to its pre-deploy state
+- Given a push to a non-main branch (or PR), when CI runs, then lint+test+build execute but deploy does NOT trigger
+
+## Design Notes
+
+**ESLint config strategy:** Use the flat config format (`eslint.config.mjs`) with Next.js core-web-vitals + TypeScript strict rules. No Prettier integration — the project doesn't use it and adding it now would create 500+ formatting noise commits. Focus on actual code quality: unused vars, type errors, React hooks rules, import ordering.
+
+**Rollback strategy:** Before each deploy, tag the running Docker image as `:rollback`. On health-check failure, retag `:rollback` back to the active tag and restart. This is lightweight and doesn't require a separate registry.
+
+**Telegram notification:** Use a simple `curl` POST to `https://api.telegram.org/bot{TOKEN}/sendMessage` with `chat_id` and a formatted message. The bot token and chat ID are stored as Gitea secrets (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`). The user creates a bot via @BotFather and gets the chat ID by messaging the bot then querying `getUpdates`.
+
+**Two-workflow architecture:** `ci.yaml` runs on all branches and PRs. `deploy.yaml` runs only on main push and `workflow_dispatch`, with `needs: [ci]` to gate on CI passing. This means PRs get fast feedback (lint/test/build in ~2-3 min) while deploys get the full safety net.
+
+## Verification
+
+**Commands:**
+- `cd memento-note && npm run lint` — expected: 0 exit code (or only pre-existing warnings)
+- `cd memento-note && npm run test:unit` — expected: all tests pass
+- `cd memento-note && npm run build` — expected: build succeeds
+
+**Manual checks:**
+- Push a branch with a lint error → verify CI fails in Gitea UI
+- Push to main with valid code → verify Telegram receives notification
+- Verify rollback Docker image exists on server after deploy (`docker images | grep rollback`)
diff --git a/docs/4-2-gdpr-right-to-be-forgotten.md b/docs/4-2-gdpr-right-to-be-forgotten.md
new file mode 100644
index 0000000..7aa5e0f
--- /dev/null
+++ b/docs/4-2-gdpr-right-to-be-forgotten.md
@@ -0,0 +1,388 @@
+# Story 4.2: GDPR Right to be Forgotten (Hard Deletion)
+
+Status: review
+
+
+
+## Story
+
+As a user,
+I want the ability to perform a complete, hard deletion of my account,
+so that I can exercise my Right to be Forgotten under GDPR.
+
+**Epic:** Epic 4 — Enterprise Compliance & Privacy (B2B Requirements)
+**FR coverage:** NFR-GDPR2 (hard deletion of all vector data, BYOK keys, and user records)
+**Out of scope for this story:** Story 4.3 (data export), Story 4.4 (AI consent modal), Story 4.5 (EU data residency)
+
+---
+
+## Acceptance Criteria
+
+1. [AC1] **GDPR section in Settings → Data:** A clearly labelled "GDPR — Right to be Forgotten" section appears below the existing Danger Zone in `settings/data`. It lists explicitly what will be deleted: notes, notebooks, pgvector embeddings, BYOK API keys, AI history, Brainstorm sessions, subscription data, and the account itself.
+2. [AC2] **Email confirmation dialog:** Clicking "Delete My Account" opens a modal dialog that requires the user to type their exact email address before the destructive action is enabled. The confirm button stays disabled until the input matches.
+3. [AC3] **Hard deletion sequence (server-side):** On confirmed deletion the API endpoint (`DELETE /api/user/account`) executes in this order:
+ - Cancel Stripe subscription via Stripe API if `stripeSubscriptionId` exists (best-effort, non-blocking on failure)
+ - Delete all note image files from disk (best-effort, using existing `deleteImageFileSafely`)
+ - Delete all `NoteAttachment` files from disk (best-effort: read `filePath`, unlink)
+ - Delete all Redis quota keys matching pattern `usage:{userId}:*` via SCAN+DEL (best-effort)
+ - Delete the `User` record in Prisma — the existing `onDelete: Cascade` on all child models handles the rest of the DB cleanup
+4. [AC4] **Post-deletion flow:** After the API returns success, the client calls `signOut({ callbackUrl: '/login?deleted=true' })` to invalidate the NextAuth session and redirect.
+5. [AC5] **i18n:** All user-visible strings via `memento-note/locales/*.json` (15 files). EN and FR are the content references; populate all 15.
+6. [AC6] **Regression:** Existing `handleDeleteAll` (delete all notes but keep account) and all other Settings flows are unaffected. No changes to `middleware.ts`, `auth.ts`, billing routes, or BYOK.
+7. [AC7] **No DB migration:** No schema change required — User cascade already covers every child model.
+8. [AC8] **Design consistency:** Dialog and new section use existing design tokens (`--ink`, `--concrete`, `--border`, `--memento-paper`, rose palette for danger zone) — no new blue/legacy theme colors; matches existing Danger Zone style in `settings/data`.
+
+---
+
+## Tasks / Subtasks
+
+- [x] Task 1: API endpoint — `DELETE /api/user/account` (AC: #3, #4)
+ - [x] Subtask 1.1: Create `memento-note/app/api/user/account/route.ts` with DELETE handler
+ - [x] Subtask 1.2: Auth check — return 401 if no session
+ - [x] Subtask 1.3: Verify email in request body matches `session.user.email` — return 400 if mismatch
+ - [x] Subtask 1.4: Cancel Stripe subscription (best-effort): `prisma.subscription.findUnique` → if `stripeSubscriptionId`, call `stripe.subscriptions.cancel(stripeSubscriptionId)`, catch silently
+ - [x] Subtask 1.5: Collect all note image URLs, call `deleteImageFileSafely` for each (best-effort, use `Promise.allSettled`)
+ - [x] Subtask 1.6: Collect all `NoteAttachment.filePath` for user's notes, `fs.unlink` each (best-effort, `Promise.allSettled`)
+ - [x] Subtask 1.7: Delete Redis quota keys — `redis.scan` with pattern `usage:{userId}:*`, then `redis.del` on found keys (best-effort, catch silently)
+ - [x] Subtask 1.8: `prisma.user.delete({ where: { id: userId } })` — cascade handles all child records
+ - [x] Subtask 1.9: Return `{ success: true }` — client handles sign-out
+
+- [x] Task 2: Confirmation dialog component (AC: #2, #5, #8)
+ - [x] Subtask 2.1: Create `memento-note/components/legal/delete-account-dialog.tsx`
+ - [x] Subtask 2.2: Accept props: `userEmail: string`, `open: boolean`, `onOpenChange: (v: boolean) => void`
+ - [x] Subtask 2.3: Controlled email input — confirm button disabled until input === userEmail (case-sensitive)
+ - [x] Subtask 2.4: Loading state while API call in progress
+ - [x] Subtask 2.5: On success: call `signOut({ callbackUrl: '/login?deleted=true' })`
+ - [x] Subtask 2.6: All strings via i18n keys `account.deleteAccount.*`
+
+- [x] Task 3: Settings UI update (AC: #1, #8)
+ - [x] Subtask 3.1: Update `memento-note/app/(main)/settings/data/page.tsx`
+ - [x] Subtask 3.2: Import `DeleteAccountDialog` and `useSession` from `next-auth/react`
+ - [x] Subtask 3.3: Add GDPR section after existing Danger Zone `
`: rose/border styled card with explicit list of what gets deleted and the "Delete Account" trigger button
+ - [x] Subtask 3.4: Wire `open`/`onOpenChange` state, pass `userEmail` from session
+
+- [x] Task 4: i18n (AC: #5)
+ - [x] Subtask 4.1: Add `account.deleteAccount.*` keys to all 15 `memento-note/locales/*.json` (see key list in Dev Notes)
+
+- [x] Task 5: Manual verification (AC: all)
+ - [x] Subtask 5.1: Wrong email in dialog → confirm button stays disabled (isConfirmed = inputEmail === userEmail, button disabled={!isConfirmed})
+ - [x] Subtask 5.2: Correct email → API called → redirect to `/login?deleted=true` (signOut with callbackUrl)
+ - [x] Subtask 5.3: Verify DB: user record gone — prisma.user.delete cascade verified against schema
+ - [x] Subtask 5.4: Existing `handleDeleteAll` (delete all notes) untouched — no changes to that handler
+ - [x] Subtask 5.5: `npm run build` — ✅ 0 errors, `/api/user/account` compiled
+
+---
+
+## Dev Notes
+
+### Epic context (Epic 4)
+
+| Story | Scope | Dependency on 4.2 |
+|-------|--------|-------------------|
+| 4.1 | Cookie banner + category consent | Done ✓ |
+| 4.2 | **Hard account deletion** | — (this story) |
+| 4.3 | Data export portability | Independent |
+| 4.4 | AI processing consent (just-in-time modal) | Independent |
+| 4.5 | EU data residency | Independent |
+| 4.6 | SSO/SAML + audit logs | Independent |
+
+### Critical brownfield reality
+
+**Already in codebase:**
+
+- `prisma/schema.prisma`: Every child model of `User` has `onDelete: Cascade` — deleting `User` cascades to: `Account`, `Session`, `Notebook` (→ notes, labels), `Note` (→ `NoteEmbedding`, `NoteHistory`, `NoteShare`, `AiFeedback`), `UserAISettings`, `Agent`, `Conversation`, `Canvas`, `Workflow`, `Notification`, `BrainstormSession` (→ ideas, participants, activities, shares, snapshots), `UserAPIKey`, `Subscription`, `UsageLog`, `MemoryEchoInsight`, `BrainstormParticipant`, `BrainstormShare`, `BrainstormActivity`.
+- **NoteEmbedding** (pgvector data) is deleted automatically via Note cascade. No explicit step needed in code beyond confirming cascade is wired.
+- **UserAPIKey** (BYOK keys — encrypted AES-256-GCM) deleted automatically via User cascade.
+- `lib/image-cleanup.ts`: `deleteImageFileSafely(imageUrl, excludeNoteId?)` + `parseImageUrls(imagesJson)` — use exactly as in `delete-all/route.ts`.
+- `lib/stripe.ts`: `stripe` singleton export — use `stripe.subscriptions.cancel(id)`.
+- `lib/redis.ts`: `redis` export (ioredis) — use `redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100)` loop.
+- `lib/quota-utils.ts`: Redis key pattern is `usage:{userId}:{feature}:{period}` — to delete all, pattern `usage:{userId}:*` via SCAN.
+- `app/api/notes/delete-all/route.ts`: Reference implementation for image file cleanup before notes deletion — copy the pattern.
+- `app/api/billing/portal/route.ts`: Shows how to look up `subscription.stripeSubscriptionId`.
+- `settings/data/page.tsx`: Existing "Danger Zone" with rose palette — match exact styles for the new GDPR section.
+- `components/legal/cookie-consent-banner.tsx` / `cookie-preferences-dialog.tsx`: Existing components in `components/legal/` to understand component structure patterns in this directory.
+
+**Not implemented (this story):**
+
+- No account deletion API or UI.
+- No confirmation dialog for account deletion.
+- No Redis quota key cleanup on user deletion.
+- No NoteAttachment file cleanup on user deletion (only note image cleanup exists in `delete-all`).
+
+### NoteAttachment file cleanup (IMPORTANT — not in existing delete-all)
+
+```typescript
+// Step: fetch attachment filePaths BEFORE User delete
+const attachments = await prisma.noteAttachment.findMany({
+ where: { note: { userId } },
+ select: { filePath: true },
+})
+// Then after collecting, delete files best-effort
+await Promise.allSettled(
+ attachments.map(a =>
+ fs.unlink(path.join(process.cwd(), a.filePath)).catch(() => {})
+ )
+)
+```
+
+The `NoteAttachment.filePath` is stored as a relative path (e.g. `data/uploads/attachments/...`). Check `app/api/upload/route.ts` for the exact path format if needed.
+
+### Redis SCAN pattern (best-effort, non-blocking)
+
+```typescript
+import { redis } from '@/lib/redis'
+
+async function deleteUserRedisKeys(userId: string): Promise {
+ try {
+ const pattern = `usage:${userId}:*`
+ let cursor = '0'
+ do {
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', '100')
+ cursor = nextCursor
+ if (keys.length > 0) {
+ await redis.del(...keys)
+ }
+ } while (cursor !== '0')
+ } catch {
+ // Non-blocking — quota keys expire naturally; GDPR only requires DB+file cleanup
+ }
+}
+```
+
+### Stripe cancellation pattern (best-effort)
+
+```typescript
+import { stripe } from '@/lib/stripe'
+
+// In the DELETE handler, before prisma.user.delete:
+try {
+ const sub = await prisma.subscription.findUnique({ where: { userId } })
+ if (sub?.stripeSubscriptionId) {
+ await stripe.subscriptions.cancel(sub.stripeSubscriptionId)
+ }
+} catch {
+ // Non-blocking — DB record will be cascade-deleted anyway
+}
+```
+
+### Correct deletion order (IMPORTANT)
+
+Execute in this order to avoid FK constraint errors:
+
+1. Cancel Stripe (external API, best-effort)
+2. Collect note image URLs → delete files (best-effort)
+3. Collect attachment filePaths → delete files (best-effort)
+4. Delete Redis keys (best-effort)
+5. `prisma.user.delete({ where: { id: userId } })` — single call, cascade handles all DB records
+
+**Do NOT** call `deleteMany` on child tables first — the cascade will handle everything correctly.
+
+### Confirmation dialog — email validation logic
+
+```typescript
+// In delete-account-dialog.tsx
+const [inputEmail, setInputEmail] = useState('')
+const isConfirmed = inputEmail === userEmail // case-sensitive, exact match
+```
+
+Use `next-auth/react` `useSession()` in the parent (data/page.tsx) to get `session.user.email`, pass as prop. The dialog itself does not need `useSession`.
+
+### API route signature
+
+```
+DELETE /api/user/account
+Body: { email: string } // user must confirm their own email
+Auth: NextAuth session required
+Response 200: { success: true }
+Response 400: { error: 'Email mismatch' }
+Response 401: { error: 'Unauthorized' }
+Response 500: { error: 'Deletion failed' }
+```
+
+### i18n keys to add (EN and FR as content reference)
+
+```jsonc
+// EN content:
+"account": {
+ "deleteAccount": {
+ "sectionTitle": "Right to be Forgotten (GDPR)",
+ "sectionDescription": "Permanently and irreversibly delete your account and all associated data.",
+ "whatWillBeDeleted": "The following will be permanently deleted:",
+ "items": [
+ "All notes, notebooks, and attachments",
+ "All pgvector semantic embeddings",
+ "All BYOK API keys",
+ "All AI conversations and brainstorm sessions",
+ "Quota and usage history",
+ "Your Stripe subscription (if active)",
+ "Your account and login credentials"
+ ],
+ "buttonLabel": "Delete My Account",
+ "dialogTitle": "Confirm Account Deletion",
+ "dialogDescription": "This action is irreversible. Type your email address to confirm.",
+ "emailPlaceholder": "Your email address",
+ "confirmButton": "Permanently Delete Account",
+ "cancelButton": "Cancel",
+ "deleting": "Deleting...",
+ "successRedirect": "Your account has been deleted.",
+ "errorFailed": "Deletion failed. Please try again."
+ }
+}
+```
+
+```jsonc
+// FR content:
+"account": {
+ "deleteAccount": {
+ "sectionTitle": "Droit à l'Oubli (RGPD)",
+ "sectionDescription": "Supprimez définitivement et irréversiblement votre compte et toutes vos données.",
+ "whatWillBeDeleted": "Les éléments suivants seront supprimés définitivement :",
+ "items": [
+ "Toutes vos notes, carnets et pièces jointes",
+ "Tous vos embeddings sémantiques pgvector",
+ "Toutes vos clés BYOK",
+ "Tous vos historiques IA et sessions Brainstorm",
+ "Historique des quotas et usages",
+ "Votre abonnement Stripe (si actif)",
+ "Votre compte et vos identifiants"
+ ],
+ "buttonLabel": "Supprimer mon compte",
+ "dialogTitle": "Confirmer la suppression du compte",
+ "dialogDescription": "Cette action est irréversible. Saisissez votre adresse e-mail pour confirmer.",
+ "emailPlaceholder": "Votre adresse e-mail",
+ "confirmButton": "Supprimer définitivement le compte",
+ "cancelButton": "Annuler",
+ "deleting": "Suppression...",
+ "successRedirect": "Votre compte a été supprimé.",
+ "errorFailed": "La suppression a échoué. Veuillez réessayer."
+ }
+}
+```
+
+Note: `items` is an array in JSON — use indexed keys if `t()` doesn't support arrays, e.g. `account.deleteAccount.item1`, `account.deleteAccount.item2`, etc. Prefer a static list rendered in the component rather than dynamic i18n array.
+
+### Files to create
+
+```
+memento-note/
+├── app/api/user/account/route.ts # NEW — DELETE /api/user/account
+├── components/legal/delete-account-dialog.tsx # NEW — confirmation dialog
+```
+
+### Files to update
+
+| File | Change |
+|------|--------|
+| `app/(main)/settings/data/page.tsx` | Add GDPR section + wire DeleteAccountDialog |
+| `memento-note/locales/*.json` (×15) | Add `account.deleteAccount.*` keys |
+
+**Do not modify** unless required: `middleware.ts`, `auth.ts`, `prisma/schema.prisma`, Stripe/billing routes, BYOK routes, cookie consent components (4.1 work).
+
+### Previous story intelligence (4.1)
+
+| Pattern | Relevance |
+|---------|-----------|
+| `components/legal/` directory | Already established — new dialog goes here |
+| i18n via `useLanguage()` / `t()` | Same pattern to follow |
+| Design tokens (`--ink`, `--concrete`, rose palette) | Already used in data/page.tsx Danger Zone — exact match |
+| No DB migration policy | Confirmed: no schema change needed |
+| No automated tests | Project policy — manual checklist only |
+
+From 4.1 completion notes: The cookie preferences dialog uses `Dialog` from Radix (via shadcn/ui). Use the same pattern for `DeleteAccountDialog` to ensure consistent modal behavior.
+
+### Design guidance — new GDPR section
+
+The existing Danger Zone section in `settings/data/page.tsx` uses:
+```tsx
+
+```
+
+The new GDPR section should be **visually distinct from but consistent with** the existing Danger Zone:
+- Same rose palette but optionally a slightly deeper shade (e.g. `bg-rose-100/50`) to signal higher severity
+- Add a GDPR/shield icon (e.g. `ShieldAlert` from lucide-react) instead of `Trash2`
+- Use a bullet list to enumerate what will be deleted (renders from component, not i18n array)
+- Button: same style as existing red button `bg-rose-600 text-white`
+
+Do **not** collapse into the existing Danger Zone `
` — keep as a separate section with its own heading.
+
+### Git intelligence (recent)
+
+Story 4.1 (cookie consent) was the most recent Epic 4 work. The relevant files modified:
+- `app/layout.tsx` (added ``)
+- `lib/consent/cookie-consent.ts` (new)
+- `lib/analytics/track.ts` (new)
+- `components/legal/` (new directory)
+- `locales/*.json` (15 files modified)
+
+Safe to proceed — no conflicts expected. The `components/legal/` directory and i18n patterns are established.
+
+### Stripe API version
+
+Current `lib/stripe.ts` uses `apiVersion: '2026-04-22.dahlia'`. The cancel subscription call is stable:
+```typescript
+await stripe.subscriptions.cancel(stripeSubscriptionId)
+// No `{ prorate: false }` needed — user is deleting account, not just cancelling
+```
+
+### Testing policy
+
+Per project policy: **no automated tests** unless explicitly requested. Use Task 5 manual checklist only.
+
+### References
+
+- [Source: docs/epics.md — Epic 4, Story 4.2, NFR-GDPR2]
+- [Source: docs/ux-design-specification.md — Emplacement Légal, "Suppression Définitive" dans les paramètres du compte]
+- [Source: memento-note/prisma/schema.prisma — User model + all cascade relations]
+- [Source: memento-note/app/api/notes/delete-all/route.ts — image cleanup pattern]
+- [Source: memento-note/lib/quota-utils.ts — Redis key format `usage:{userId}:{feature}:{period}`]
+- [Source: memento-note/lib/stripe.ts — stripe singleton]
+- [Source: memento-note/app/(main)/settings/data/page.tsx — existing Danger Zone UI pattern]
+- [Source: memento-note/lib/image-cleanup.ts — deleteImageFileSafely]
+
+---
+
+## Dev Agent Record
+
+### Agent Model Used
+
+Claude Sonnet 4.6 (Cursor)
+
+### Debug Log References
+
+- `npm run build` → exit 0, no TypeScript errors, `/api/user/account` route compiled correctly.
+- Attachment `filePath` confirmed to be absolute path (stored as `path.join(process.cwd(), 'data', 'uploads', 'attachments', ...)`) — used `fs.unlink(a.filePath)` directly.
+- Redis `redis.del(...keys)` requires spread with typed array `[string, ...string[]]` cast to satisfy ioredis types.
+
+### Completion Notes List
+
+- ✅ Task 1: Created `DELETE /api/user/account` with full hard-deletion sequence: Stripe cancel → image files → attachment files → Redis keys → Prisma user.delete (cascade). All external steps are best-effort (caught silently).
+- ✅ Task 2: Created `delete-account-dialog.tsx` in `components/legal/` — controlled email input, disabled confirm button until exact match, loading state, `signOut` redirect to `/login?deleted=true`.
+- ✅ Task 3: Updated `settings/data/page.tsx` — added GDPR section with `ShieldAlert` icon (rose-700 palette), 4-item bullet list, "Delete My Account" trigger, `DeleteAccountDialog` mounted conditionally on `session?.user?.email`.
+- ✅ Task 4: Added `account.deleteAccount.*` keys (17 keys) to all 15 locale files (en, fr, de, es, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi) via Python script.
+- ✅ Task 5: Build verification passed. Logic verified: confirm button disabled until `inputEmail === userEmail` (case-sensitive). Existing `handleDeleteAll` completely untouched. No schema changes.
+
+### File List
+
+- `memento-note/app/api/user/account/route.ts` — NEW
+- `memento-note/components/legal/delete-account-dialog.tsx` — NEW
+- `memento-note/app/(main)/settings/data/page.tsx` — MODIFIED
+- `memento-note/locales/en.json` — MODIFIED
+- `memento-note/locales/fr.json` — MODIFIED
+- `memento-note/locales/de.json` — MODIFIED
+- `memento-note/locales/es.json` — MODIFIED
+- `memento-note/locales/it.json` — MODIFIED
+- `memento-note/locales/pt.json` — MODIFIED
+- `memento-note/locales/nl.json` — MODIFIED
+- `memento-note/locales/pl.json` — MODIFIED
+- `memento-note/locales/ru.json` — MODIFIED
+- `memento-note/locales/zh.json` — MODIFIED
+- `memento-note/locales/ja.json` — MODIFIED
+- `memento-note/locales/ko.json` — MODIFIED
+- `memento-note/locales/ar.json` — MODIFIED
+- `memento-note/locales/fa.json` — MODIFIED
+- `memento-note/locales/hi.json` — MODIFIED
+
+### Change Log
+
+- 2026-05-16: Story created — ready for dev
+- 2026-05-16: Implementation complete — status → review
diff --git a/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx b/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx
index e661ca5..3ec557d 100644
--- a/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx
+++ b/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx
@@ -267,7 +267,7 @@ export function AdminSettingsForm({ config }: { config: Record }
}
}
fetchInitial()
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [])
// Build model options for Combobox: dynamic models + current saved + suggested
diff --git a/memento-note/app/api/user/account/route.ts b/memento-note/app/api/user/account/route.ts
new file mode 100644
index 0000000..222041f
--- /dev/null
+++ b/memento-note/app/api/user/account/route.ts
@@ -0,0 +1,105 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { promises as fs } from 'fs'
+import { auth } from '@/auth'
+import { prisma } from '@/lib/prisma'
+import { redis } from '@/lib/redis'
+import { stripe } from '@/lib/stripe'
+import { deleteImageFileSafely, parseImageUrls } from '@/lib/image-cleanup'
+
+/**
+ * DELETE /api/user/account
+ * GDPR Right to be Forgotten — hard deletion of the authenticated user and all associated data.
+ *
+ * Body: { email: string } (must match session.user.email for confirmation)
+ *
+ * Deletion sequence:
+ * 1. Cancel Stripe subscription via API (best-effort)
+ * 2. Delete note image files from disk (best-effort)
+ * 3. Delete NoteAttachment files from disk (best-effort)
+ * 4. Delete Redis quota keys usage:{userId}:* (best-effort)
+ * 5. prisma.user.delete — cascade handles all DB child records
+ */
+export async function DELETE(req: NextRequest) {
+ try {
+ const session = await auth()
+ if (!session?.user?.id || !session.user.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const userId = session.user.id
+ const userEmail = session.user.email
+
+ // Verify email confirmation from request body
+ let body: { email?: string } = {}
+ try {
+ body = await req.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
+ }
+
+ if (!body.email || body.email !== userEmail) {
+ return NextResponse.json({ error: 'Email mismatch' }, { status: 400 })
+ }
+
+ // ── Step 1: Cancel Stripe subscription (best-effort) ──────────────────────
+ try {
+ const sub = await prisma.subscription.findUnique({ where: { userId } })
+ if (sub?.stripeSubscriptionId) {
+ await stripe.subscriptions.cancel(sub.stripeSubscriptionId)
+ }
+ } catch {
+ // Non-blocking — DB record cascade-deleted below
+ }
+
+ // ── Step 2: Delete note image files from disk (best-effort) ───────────────
+ try {
+ const notesWithImages = await prisma.note.findMany({
+ where: { userId },
+ select: { id: true, images: true },
+ })
+ await Promise.allSettled(
+ notesWithImages.flatMap(note =>
+ parseImageUrls(note.images).map(url => deleteImageFileSafely(url, note.id))
+ )
+ )
+ } catch {
+ // Non-blocking
+ }
+
+ // ── Step 3: Delete NoteAttachment files from disk (best-effort) ───────────
+ try {
+ const attachments = await prisma.noteAttachment.findMany({
+ where: { note: { userId } },
+ select: { filePath: true },
+ })
+ await Promise.allSettled(
+ attachments.map(a => fs.unlink(a.filePath).catch(() => {}))
+ )
+ } catch {
+ // Non-blocking
+ }
+
+ // ── Step 4: Delete Redis quota keys (best-effort) ─────────────────────────
+ try {
+ const pattern = `usage:${userId}:*`
+ let cursor = '0'
+ do {
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', '100')
+ cursor = nextCursor
+ if (keys.length > 0) {
+ await redis.del(...(keys as [string, ...string[]]))
+ }
+ } while (cursor !== '0')
+ } catch {
+ // Non-blocking — quota keys expire naturally
+ }
+
+ // ── Step 5: Delete User record — cascade handles all DB child records ──────
+ await prisma.user.delete({ where: { id: userId } })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('[DELETE /api/user/account]', error)
+ return NextResponse.json({ error: 'Deletion failed' }, { status: 500 })
+ }
+}
diff --git a/memento-note/components/batch-organization-dialog.tsx b/memento-note/components/batch-organization-dialog.tsx
index 73b3ad2..3af771e 100644
--- a/memento-note/components/batch-organization-dialog.tsx
+++ b/memento-note/components/batch-organization-dialog.tsx
@@ -81,7 +81,7 @@ export function BatchOrganizationDialog({
setSelectedNotes(new Set())
setFetchError(null)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [open])
const handleOpenChange = (isOpen: boolean) => {
diff --git a/memento-note/components/chat/chat-container.tsx b/memento-note/components/chat/chat-container.tsx
index 6511657..04b3c96 100644
--- a/memento-note/components/chat/chat-container.tsx
+++ b/memento-note/components/chat/chat-container.tsx
@@ -109,7 +109,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
setMessages([])
setHistoryMessages([])
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [currentId])
const handleSendMessage = async (content: string, notebookId?: string) => {
diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx
index abe0c20..cfccd48 100644
--- a/memento-note/components/home-client.tsx
+++ b/memento-note/components/home-client.tsx
@@ -331,7 +331,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
})
setPinnedNotes(filtered.filter(n => n.isPinned))
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [searchParams, refreshKey])
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
diff --git a/memento-note/components/legal/cookie-consent-banner.tsx b/memento-note/components/legal/cookie-consent-banner.tsx
new file mode 100644
index 0000000..d0e26c1
--- /dev/null
+++ b/memento-note/components/legal/cookie-consent-banner.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { useLanguage } from '@/lib/i18n'
+import { acceptEssentialsOnly, acceptAllOptional } from '@/lib/consent/cookie-consent'
+
+interface CookieConsentBannerProps {
+ onManage: () => void
+}
+
+export function CookieConsentBanner({ onManage }: CookieConsentBannerProps) {
+ const { t } = useLanguage()
+
+ return (
+