feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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}',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)]))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user