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

+98
View File
@@ -827,6 +827,101 @@ function referenceWithoutGroup(name: string, markdown: string) {
return group && board ? [board[0], board[1]!, `"${group}"`] : null
}
const accountBoard = 'RenewCMS Screens Account.dc.html'
const authBoard = 'RenewCMS Screens Auth.dc.html'
function accountScreen(): Screen {
const sources: Source[] = [
{
board: accountBoard,
group: 'Change email',
states: [
'Default',
'Form open',
'Invalid format',
'Same as current',
'Password missing',
'Wrong password (403)',
'Sending',
'Pending',
'Pending: resending',
'Pending: resent',
'Pending: resend rate limited (429)',
'Cancelled',
'Rate limited (429)',
'Server or network error',
],
alias: {},
},
{
board: authBoard,
group: 'Verify new email',
states: [
'Confirming',
'Success',
'Invalid or expired (403)',
'Address unavailable (409)',
'No session (401)',
'No session, retrying',
'Missing code',
'Server error',
],
alias: {},
},
{
board: accountBoard,
group: 'Your data',
states: [
'Direct (recommended): default',
'Direct: preparing',
'Direct: started',
'Direct: rate limited (429)',
'Direct: error',
'Alternative, emailed link: default',
'Alternative: requested',
'Alternative: already in progress',
'Alternative: error',
],
alias: {},
},
{
board: accountBoard,
group: 'Delete account',
states: [
'Danger zone',
'Confirm: empty',
'Confirm: typing',
'Confirm: ready',
'Delete: Wrong password (403)',
'Deleting',
'Delete: Rate limited (429)',
'Delete: Server error',
'Blocked: last administrator',
'Blocked: cannot be deleted',
],
alias: {
'Delete: Wrong password (403)': 'Wrong password (403)',
'Delete: Rate limited (429)': 'Rate limited (429)',
'Delete: Server error': 'Server error',
},
},
{
board: authBoard,
group: 'Account deleted',
states: ['Account deleted'],
alias: {},
},
]
const [first] = sources
return {
name: 'account',
board: first!.board,
group: first!.group,
states: sources.flatMap((source) => source.states),
sources,
}
}
function rolesScreen(): Screen {
const sources = extraSources.roles!
const [first] = sources
@@ -846,6 +941,9 @@ async function readScreen(name: string): Promise<Screen> {
if (name === 'roles') {
return rolesScreen()
}
if (name === 'account') {
return accountScreen()
}
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = VISUAL_REFERENCE.exec(markdown) ?? referenceWithoutGroup(name, markdown)
if (!reference) {
+428
View File
@@ -8541,6 +8541,433 @@ function withApprovedExceptions(
)
}
const accountOldEmail = 'marta@renew.example'
const accountNewEmail = 'marta.alves@atelier-renew.example'
const accountPassword = 'limewash2026'
const boardEndInset = 44
interface AccountScroll {
desktop: number
mobile: number
end?: boolean
}
const accountScroll: AccountScroll = { desktop: 300, mobile: 330 }
const dataScroll = { desktop: 780, mobile: 1100, end: true }
const dataExportPath = 'GET /v1/user/me/export'
const dataExportFilename = 'renewcms-export-marta-2026-10-29.zip'
const emailedLinkException = {
maxPercent: { desktop: 12, mobile: 36 },
reason: 'out of scope: emailed-link variant',
approvedBy: 'user (floriansylvainpro@gmail.com), accepted in /slice for account_export_data',
}
function accountSession(pendingEmail?: string): ApiMock {
return {
status: 200,
body: {
...(profileSession(profileDisplayName).body as object),
...(pendingEmail ? { pending_email: pendingEmail } : {}),
},
}
}
async function scrollAccountCard(page: Page, scroll: AccountScroll = accountScroll) {
const mobile = (page.viewportSize()?.width ?? 0) < 1024
const offset = mobile ? scroll.mobile : scroll.desktop
const toEnd = mobile && scroll.end === true
await page.evaluate(
([top, end, endInset]) => {
document.querySelectorAll<HTMLElement>('[data-slot="body"]').forEach((element) => {
element.scrollTop = end
? element.scrollHeight - element.clientHeight - endInset
: top
})
window.scrollTo(
0,
end ? document.documentElement.scrollHeight - window.innerHeight - endInset : top,
)
},
[offset, toEnd, boardEndInset] as const,
)
await page.mouse.move(0, 0)
}
async function openEmailForm(page: Page) {
await page.getByRole('button', { name: 'Change email', exact: true }).click()
}
async function fillEmailForm(page: Page, email: string, password: string) {
await page.locator('input[type="email"]').fill(email)
if (password) {
await page.locator('input[autocomplete="current-password"]').fill(password)
}
}
async function submitEmailForm(page: Page) {
await page.getByRole('button', { name: 'Send verification link', exact: true }).click()
}
function accountState(
options: {
pending?: boolean
api?: Record<string, ApiMock>
steps?: (page: Page) => Promise<void>
scroll?: AccountScroll
} = {},
): VisualState {
return {
path: '/profile',
api: {
'GET /v1/auth/me': accountSession(options.pending ? accountNewEmail : undefined),
...options.api,
},
steps: async (page) => {
await page.getByRole('heading', { name: 'Account', exact: true }).waitFor()
await options.steps?.(page)
await scrollAccountCard(page, options.scroll)
},
}
}
function accountSendState(
response: ApiMock,
email: string,
password: string,
waitFor: string,
): VisualState {
return accountState({
api: { 'POST /v1/auth/email/change': response },
steps: async (page) => {
await openEmailForm(page)
await fillEmailForm(page, email, password)
await submitEmailForm(page)
await page.getByText(waitFor).first().waitFor()
},
})
}
function dataExportState(
response: ApiMock | undefined,
steps?: (page: Page) => Promise<void>,
): VisualState {
return accountState({
scroll: dataScroll,
api: response ? { [dataExportPath]: response } : undefined,
steps,
})
}
async function downloadMyData(page: Page) {
await page.getByRole('button', { name: 'Download my data', exact: true }).click()
}
const dataExportArchive: ApiMock = {
status: 200,
headers: { 'Content-Disposition': `attachment; filename="${dataExportFilename}"` },
body: 'zip',
}
const accountPendingBody: ApiMock = { status: 200, body: { pending_email: accountNewEmail } }
function confirmLanding(response: ApiMock, path = '/account/email/confirm?c=abc123'): VisualState {
return { path, api: { 'POST /v1/auth/email/confirm': response } }
}
const dangerScroll = { desktop: 1000, mobile: 1500, end: true }
const deletePassword = 'limewash26'
const deletePath = 'DELETE /v1/user/me'
function deleteDialog(page: Page) {
return page.getByRole('alertdialog')
}
async function openDeleteDialog(page: Page) {
await page.getByRole('button', { name: 'Delete my account', exact: true }).click()
await deleteDialog(page).waitFor()
}
async function fillDeleteForm(page: Page, password: string, word: string) {
const dialog = deleteDialog(page)
if (password) {
await dialog.locator('input[type="password"]').fill(password)
}
await dialog.locator('input[autocomplete="off"]').fill(word)
}
async function confirmDelete(page: Page) {
await deleteDialog(page).getByRole('button', { name: 'Delete my account', exact: true }).click()
}
function deleteAccountState(
steps?: (page: Page) => Promise<void>,
api?: Record<string, ApiMock>,
): VisualState {
return accountState({
scroll: dangerScroll,
api,
steps: async (page) => {
await scrollAccountCard(page, dangerScroll)
await steps?.(page)
},
})
}
function deleteRefusedState(response: ApiMock, waitFor: string): VisualState {
return deleteAccountState(
async (page) => {
await openDeleteDialog(page)
await fillDeleteForm(page, deletePassword, 'DELETE')
await confirmDelete(page)
await page.getByText(waitFor).first().waitFor()
},
{ [deletePath]: response },
)
}
const lastAdminRefusal: ApiMock = {
status: 409,
body: 'last_admin',
headers: { 'X-Last-Role': 'Owner' },
}
const accountStates: Record<string, VisualState> = {
Default: accountState(),
'Form open': accountState({ steps: openEmailForm }),
'Invalid format': accountState({
steps: async (page) => {
await openEmailForm(page)
await fillEmailForm(page, 'marta.alves@atelier', accountPassword)
await page.locator('input[type="email"]').blur()
await page.getByText('Enter a full email address, like name@example.com.').waitFor()
},
}),
'Same as current': accountState({
steps: async (page) => {
await openEmailForm(page)
await fillEmailForm(page, accountOldEmail, accountPassword)
await submitEmailForm(page)
await page.getByText("That's already your email address.").waitFor()
},
}),
'Password missing': accountState({
steps: async (page) => {
await openEmailForm(page)
await fillEmailForm(page, accountNewEmail, '')
await submitEmailForm(page)
await page.getByText('Enter your current password.').waitFor()
},
}),
'Wrong password (403)': accountSendState(
{ status: 403, body: 'wrong_password' },
accountNewEmail,
accountPassword,
"That password isn't correct. Try again.",
),
Sending: accountState({
api: { 'POST /v1/auth/email/change': { status: 200, hang: true } },
steps: async (page) => {
await openEmailForm(page)
await fillEmailForm(page, accountNewEmail, accountPassword)
await submitEmailForm(page)
await page.getByRole('button', { name: 'Sending…', exact: true }).waitFor()
},
}),
Pending: accountState({
api: { 'POST /v1/auth/email/change': accountPendingBody },
steps: async (page) => {
await openEmailForm(page)
await fillEmailForm(page, accountNewEmail, accountPassword)
await submitEmailForm(page)
await page.getByText('Waiting for verification').waitFor()
await page.getByText('Verification link sent').first().waitFor()
},
}),
'Pending: resending': accountState({
pending: true,
api: { 'POST /v1/auth/email/resend': { status: 200, hang: true } },
steps: async (page) => {
await page.getByRole('button', { name: 'Resend link', exact: true }).click()
await page.getByRole('button', { name: 'Sending…', exact: true }).waitFor()
},
}),
'Pending: resent': accountState({
pending: true,
api: { 'POST /v1/auth/email/resend': accountPendingBody },
steps: async (page) => {
await page.getByRole('button', { name: 'Resend link', exact: true }).click()
await page.getByText('Link sent again').first().waitFor()
},
}),
'Pending: resend rate limited (429)': accountState({
pending: true,
api: {
'POST /v1/auth/email/resend': { status: 429, headers: { 'Retry-After': '270' } },
},
steps: async (page) => {
await page.getByRole('button', { name: 'Resend link', exact: true }).click()
await page.getByRole('button', { name: /^Resend in / }).waitFor()
},
}),
Cancelled: accountState({
pending: true,
api: { 'DELETE /v1/auth/email/change': { status: 204 } },
steps: async (page) => {
await page.getByRole('button', { name: 'Cancel change', exact: true }).click()
await page.getByText('Email change cancelled').first().waitFor()
},
}),
'Rate limited (429)': accountSendState(
{ status: 429, headers: { 'Retry-After': '900' } },
accountNewEmail,
accountPassword,
'Too many requests. Try again in a few minutes.',
),
'Server or network error': accountSendState(
{ status: 500 },
accountNewEmail,
accountPassword,
'Something went wrong on our side. Nothing was changed. Try again in a moment.',
),
Confirming: confirmLanding({ status: 200, hang: true }),
Success: {
...confirmLanding({ status: 200, body: { email: accountNewEmail } }),
paused: true,
api: {
'POST /v1/auth/email/confirm': { status: 200, body: { email: accountNewEmail } },
'GET /v1/auth/me': profileSession(profileDisplayName),
},
},
'Invalid or expired (403)': confirmLanding({ status: 403, body: 'invalid_email_change_code' }),
'Address unavailable (409)': confirmLanding({ status: 409, body: 'email_unavailable' }),
'No session (401)': confirmLanding({ status: 401 }),
'No session, retrying': confirmLanding({ status: 200, hang: true }),
'Missing code': { path: '/account/email/confirm' },
'Server error': confirmLanding({ status: 500 }),
'Danger zone': deleteAccountState(),
'Confirm: empty': deleteAccountState(openDeleteDialog),
'Confirm: typing': deleteAccountState(async (page) => {
await openDeleteDialog(page)
await fillDeleteForm(page, deletePassword, 'DEL')
}),
'Confirm: ready': deleteAccountState(async (page) => {
await openDeleteDialog(page)
await fillDeleteForm(page, deletePassword, 'DELETE')
}),
'Delete: Wrong password (403)': deleteRefusedState(
{ status: 403, body: 'wrong_password' },
"That password isn't correct. Try again.",
),
Deleting: deleteAccountState(
async (page) => {
await openDeleteDialog(page)
await fillDeleteForm(page, deletePassword, 'DELETE')
await confirmDelete(page)
await deleteDialog(page)
.getByRole('button', { name: 'Deleting…', exact: true })
.waitFor()
},
{ [deletePath]: { status: 204, hang: true } },
),
'Delete: Rate limited (429)': deleteRefusedState(
{ status: 429, headers: { 'Retry-After': '900' } },
'Too many requests. Try again in a few minutes.',
),
'Delete: Server error': deleteRefusedState(
{ status: 500 },
"Couldn't delete your account. Nothing was deleted. Try again in a moment.",
),
'Blocked: last administrator': deleteRefusedState(
lastAdminRefusal,
"You can't delete your account yet",
),
'Blocked: cannot be deleted': deleteRefusedState(
{ status: 409, body: 'account_not_deletable' },
"This account can't be deleted",
),
'Account deleted': {
path: '/login?reason=account-deleted',
api: { 'GET /v1/auth/registration': registrationClosed },
},
'Direct (recommended): default': dataExportState(undefined),
'Direct: preparing': dataExportState({ status: 200, hang: true }, async (page) => {
await downloadMyData(page)
await page.getByRole('button', { name: 'Preparing download…', exact: true }).waitFor()
}),
'Direct: started': dataExportState(dataExportArchive, async (page) => {
await downloadMyData(page)
await page.getByText(`${dataExportFilename} is in your downloads.`).waitFor()
}),
'Direct: rate limited (429)': { path: '/profile', skip: 'clock-sensitive' },
'Direct: error': dataExportState({ status: 500 }, async (page) => {
await downloadMyData(page)
await page.getByText("Couldn't prepare your data").waitFor()
}),
'Alternative, emailed link: default': {
...dataExportState(undefined),
exception: emailedLinkException,
},
'Alternative: requested': { ...dataExportState(undefined), exception: emailedLinkException },
'Alternative: already in progress': {
...dataExportState(undefined),
exception: emailedLinkException,
},
'Alternative: error': { ...dataExportState(undefined), exception: emailedLinkException },
}
const mobileApprover = 'user (floriansylvainpro@gmail.com), approved in /slice for account_delete'
const desktopBudget = 10
const desktopPastPageEndBudget = 15
const bothViewportsPastPageEndReason =
'mobile and desktop: board frame scrolled past the end of the page content, the real page stops at its bottom'
const pastPageEndReason =
'mobile: board frame scrolled past the end of the page content, the real page stops at its bottom'
const warningCopyReason =
'; the dialog sheet also sits about 40px lower because the board draws a longer warning text'
const roleCardReason =
'mobile: the app has an extra Your role card (app behaviour) that pushes the content below the Account card down by about 194px'
const mobileExceptions: Record<string, [number, string]> = {
Default: [17, roleCardReason],
'Pending: resending': [
13,
roleCardReason + '; the Cancel change button is disabled while resending',
],
Cancelled: [13, roleCardReason],
'Direct: preparing': [21, pastPageEndReason],
'Direct: error': [37, pastPageEndReason],
'Danger zone': [29, pastPageEndReason],
'Confirm: empty': [16, pastPageEndReason + warningCopyReason],
'Confirm: typing': [16, pastPageEndReason + warningCopyReason],
'Confirm: ready': [17, pastPageEndReason + warningCopyReason],
'Delete: Wrong password (403)': [21, pastPageEndReason + warningCopyReason],
Deleting: [16, pastPageEndReason + warningCopyReason],
'Delete: Rate limited (429)': [23, pastPageEndReason + warningCopyReason],
'Delete: Server error': [23, pastPageEndReason + warningCopyReason],
'Blocked: last administrator': [23, pastPageEndReason],
'Blocked: cannot be deleted': [24, pastPageEndReason],
}
for (const [name, [mobile, reason]] of Object.entries(mobileExceptions)) {
accountStates[name] = {
...accountStates[name]!,
exception: {
maxPercent: { desktop: desktopBudget, mobile },
reason,
approvedBy: mobileApprover,
},
}
}
accountStates['Direct: error'] = {
...accountStates['Direct: error']!,
exception: {
maxPercent: { desktop: desktopPastPageEndBudget, mobile: 37 },
reason: bothViewportsPastPageEndReason,
approvedBy: mobileApprover,
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
schema: {
@@ -9976,6 +10403,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/register/validate',
},
},
account: accountStates,
'forgot-password': {
Default: { path: '/register/reset/request' },
'Validation error': {