feat: change email, export my data, delete account (with data anonymization)

This commit is contained in:
Florian Sylvain committed 2026-10-30 20:19:07 +01:00
1 parent bb191c389a
commit 10a66cd979
123 files changed
+10789 -76

No files matched your search

@@ -269,6 +269,12 @@ describe('The catalogue is complete in both languages', () => {
'user.register',
'user.verify',
'user.update_profile',
'user.request_email_change',
'user.resend_email_change',
'user.cancel_email_change',
'user.confirm_email_change',
'user.export_data',
'user.delete_account',
'invite.create',
'invite.cancel',
'invite.renew',
@@ -301,6 +307,48 @@ describe('The catalogue is complete in both languages', () => {
})
})
describe('email change sentences', () => {
const target = { type: 'user', id: '7', name: 'marta', exists: true }
it.each([
[
'user.request_email_change',
'Requested an email change for marta',
'Failed to request an email change for marta',
],
[
'user.resend_email_change',
'Resent the email change link for marta',
'Failed to resend the email change link for marta',
],
[
'user.cancel_email_change',
'Cancelled the email change for marta',
'Failed to cancel the email change for marta',
],
[
'user.confirm_email_change',
'Confirmed the email change for marta',
'Failed to confirm the email change for marta',
],
[
'user.export_data',
'Exported the personal data of marta',
'Failed to export the personal data of marta',
],
[
'user.delete_account',
'Deleted the account of marta',
'Failed to delete the account of marta',
],
])('writes %s as a sentence, success and failure', (action, success, failed) => {
expect(sentenceText(item({ action, category: 'users', target }))).toBe(success)
expect(sentenceText(item({ action, category: 'users', target, outcome: 'failed' }))).toBe(
failed,
)
})
})
describe('role and ownership sentences', () => {
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
@@ -531,6 +579,17 @@ describe('Roles sync: the six role events in EN and FR', () => {
])
})
it('writes user.delete_account in English and in French, success and failed', () => {
const target = userTarget('marta')
const deleted = event({ action: 'user.delete_account', category: 'users', target })
expect(both(deleted)).toEqual(['Deleted the account of marta', 'Compte supprimé : marta'])
expect(both({ ...deleted, outcome: 'failed' })).toEqual([
'Failed to delete the account of marta',
'Échec de la suppression du compte : marta',
])
})
it('writes user.remove with the success and the failed sentence', () => {
const target = userTarget('Ines Duarte')
const removed = event({ action: 'user.remove', category: 'users', target })
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { filenameFromDisposition } from '@/lib/contentDisposition'
describe('filenameFromDisposition', () => {
it('reads a quoted filename', () => {
expect(
filenameFromDisposition('attachment; filename="renewcms-export-marta-2026-10-29.zip"'),
).toBe('renewcms-export-marta-2026-10-29.zip')
})
it('reads a bare filename', () => {
expect(filenameFromDisposition('attachment; filename=export.zip')).toBe('export.zip')
})
it('unescapes quoted characters and ignores letter case and spacing', () => {
expect(filenameFromDisposition('attachment; FILENAME = "a\\"b.zip"')).toBe('a"b.zip')
})
it('prefers the quoted filename when a bare one follows', () => {
expect(filenameFromDisposition('attachment; filename="one.zip"; filename=two.zip')).toBe(
'one.zip',
)
})
it('stops a bare filename at the next parameter', () => {
expect(filenameFromDisposition('attachment; filename=export.zip; size=3')).toBe(
'export.zip',
)
})
it('returns nothing without a header or a filename', () => {
expect(filenameFromDisposition(null)).toBeUndefined()
expect(filenameFromDisposition('attachment')).toBeUndefined()
})
})
+22
View File
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { namesWithDeletedFallback } from '@/lib/deletedUser'
afterEach(() => setLocale('en'))
describe('namesWithDeletedFallback', () => {
const found = [{ id: 2, display_name: 'Jonas Weber' }]
it('names an id that the lookup did not return Deleted user', () => {
expect(namesWithDeletedFallback([2, 7], found)).toEqual({
2: 'Jonas Weber',
7: 'Deleted user',
})
})
it('speaks French when the language is French', () => {
setLocale('fr')
expect(namesWithDeletedFallback([7], [])).toEqual({ 7: 'Utilisateur supprimé' })
})
})
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadBlob } from '@/lib/downloadBlob'
describe('downloadBlob', () => {
const createObjectURL = vi.fn(() => 'blob:export')
const revokeObjectURL = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
createObjectURL.mockClear()
revokeObjectURL.mockClear()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('clicks an anchor carrying the file name and removes it', () => {
const clicked: HTMLAnchorElement[] = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
clicked.push(this)
})
downloadBlob(new Blob(['zip']), 'export.zip')
expect(clicked).toHaveLength(1)
expect(clicked[0]!.download).toBe('export.zip')
expect(clicked[0]!.href).toBe('blob:export')
expect(document.body.contains(clicked[0]!)).toBe(false)
})
it('revokes the object URL only after a delay so the download is not cancelled', () => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
downloadBlob(new Blob(['zip']), 'export.zip')
expect(revokeObjectURL).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:export')
})
})
+6
View File
@@ -0,0 +1,6 @@
import { ref } from 'vue'
export const DELETE_WORD = 'DELETE'
export const ACCOUNT_DELETED_REASON = 'account-deleted'
export const accountDeletedRedirect = ref(false)
+24
View File
@@ -99,6 +99,30 @@ const CATALOGUE: Record<string, Template> = {
success: 'Updated the profile of {0}',
failed: 'Failed to update the profile of {0}',
},
'user.request_email_change': {
success: 'Requested an email change for {0}',
failed: 'Failed to request an email change for {0}',
},
'user.resend_email_change': {
success: 'Resent the email change link for {0}',
failed: 'Failed to resend the email change link for {0}',
},
'user.cancel_email_change': {
success: 'Cancelled the email change for {0}',
failed: 'Failed to cancel the email change for {0}',
},
'user.confirm_email_change': {
success: 'Confirmed the email change for {0}',
failed: 'Failed to confirm the email change for {0}',
},
'user.export_data': {
success: 'Exported the personal data of {0}',
failed: 'Failed to export the personal data of {0}',
},
'user.delete_account': {
success: 'Deleted the account of {0}',
failed: 'Failed to delete the account of {0}',
},
'invite.create': { success: 'Invited {0}', failed: 'Failed to invite {0}' },
'invite.cancel': {
success: 'Cancelled the invite for {0}',
+6
View File
@@ -20,6 +20,9 @@ export const KNOWN_ERROR_CODES = [
'user_not_found',
'email_has_account',
'invite_pending',
'wrong_password',
'last_admin',
'account_not_deletable',
] as const
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
@@ -28,6 +31,7 @@ export interface ApiErrorDetails {
code?: ApiErrorCode
permission?: string
escalationKey?: string
lastRole?: string
}
const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
@@ -50,6 +54,7 @@ export class ApiError extends Error {
code?: ApiErrorCode
permission?: string
escalationKey?: string
lastRole?: string
constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) {
super(`Request failed with status ${status}`)
@@ -59,5 +64,6 @@ export class ApiError extends Error {
this.code = details.code
this.permission = details.permission
this.escalationKey = details.escalationKey
this.lastRole = details.lastRole
}
}
+13
View File
@@ -0,0 +1,13 @@
const QUOTED_FILENAME = /filename\s*=\s*"((?:[^"\\]|\\.)*)"/i
const BARE_FILENAME = /filename\s*=\s*([^;\s]+)/i
export function filenameFromDisposition(header: string | null): string | undefined {
if (!header) {
return undefined
}
const quoted = QUOTED_FILENAME.exec(header)
if (quoted) {
return quoted[1]!.replace(/\\(.)/g, '$1')
}
return BARE_FILENAME.exec(header)?.[1]
}
+9
View File
@@ -0,0 +1,9 @@
import { t } from '@/i18n'
import type { UserName } from '@/lib/contentTypes'
export const DELETED_USER_NAME = 'Deleted user'
export function namesWithDeletedFallback(ids: number[], found: UserName[]) {
const known = new Map(found.map((user) => [user.id, user.display_name]))
return Object.fromEntries(ids.map((id) => [id, known.get(id) ?? t(DELETED_USER_NAME)]))
}
+12
View File
@@ -0,0 +1,12 @@
const REVOKE_DELAY_MS = 1000
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)
}
+7
View File
@@ -0,0 +1,7 @@
export const NEW_EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
export const WRONG_PASSWORD_BODY = 'wrong_password'
export const RATE_LIMIT_FALLBACK_SECONDS = 300
export function isSameEmail(candidate: string, current: string | undefined) {
return candidate.trim().toLowerCase() === (current ?? '').trim().toLowerCase()
}