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
@@ -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': {
|
||||
|
||||
Reference in new issue
Block a user