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

+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed } from 'vue'
import type { LastRole } from '@/composables/useAccountDeletion'
const props = defineProps<{
open: boolean
lastRole: LastRole | null
}>()
const emit = defineEmits<{ back: []; users: [] }>()
const title = computed(() =>
props.lastRole ? t("You can't delete your account yet") : t("This account can't be deleted"),
)
const description = computed(() => {
if (props.lastRole === 'Owner') {
return t(
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.',
)
}
if (props.lastRole === 'Admin') {
return t(
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.',
)
}
return t(
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again.",
)
})
</script>
<template>
<UModal
:open="open"
:title="title"
:description="description"
:dismissible="false"
:close="false"
:content="{ role: 'alertdialog' }"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'p-0 sm:p-0',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center text-sm text-highlighted sm:h-9 sm:w-auto sm:px-3"
@click="emit('back')"
>
{{ t('Back to profile') }}
</UButton>
<UButton
v-if="lastRole"
block
class="h-11 justify-center text-sm sm:h-9 sm:w-auto sm:px-3"
@click="emit('users')"
>
{{ t('Go to Users') }}
</UButton>
</div>
</template>
</UModal>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import BlockedDialog from '@/components/BlockedDialog.vue'
import DeleteAccountDialog from '@/components/DeleteAccountDialog.vue'
import { useAccountDeletion } from '@/composables/useAccountDeletion'
const router = useRouter()
const deletion = useAccountDeletion()
const dialogOpen = ref(false)
function closeAll() {
dialogOpen.value = false
deletion.reset()
}
function goToUsers() {
closeAll()
void router.push('/users')
}
</script>
<template>
<UCard
data-testid="danger-zone"
:ui="{
root: 'rounded-none bg-default shadow-none ring-0 border border-error divide-y-0',
body: 'flex flex-col gap-3.5 p-4 sm:p-4 lg:flex-row lg:items-center lg:justify-between',
}"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 class="flex items-center gap-2 text-base/5 font-semibold text-error">
<UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0" />
{{ t('Danger zone') }}
</h2>
<p class="text-pretty text-muted">
{{ t("Delete your account and your personal data. This can't be undone.") }}
</p>
</div>
<UButton
color="error"
icon="i-lucide-trash-2"
class="h-11 shrink-0 justify-center px-3.5 lg:h-9"
@click="dialogOpen = true"
>
{{ t('Delete my account') }}
</UButton>
</UCard>
<DeleteAccountDialog
:open="dialogOpen && !deletion.blocked.value"
:deletion="deletion"
@update:open="dialogOpen = $event"
/>
<BlockedDialog
:open="deletion.blocked.value"
:last-role="deletion.state.value === 'blocked' ? deletion.lastRole.value : null"
@back="closeAll"
@users="goToUsers"
/>
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import ProfileCard from '@/components/ProfileCard.vue'
import { useDataExport } from '@/composables/useDataExport'
const exporter = useDataExport()
const contents = [
{
icon: 'i-lucide-user',
get title() {
return t('Profile')
},
get text() {
return t('Username, display name, email, role and author profile.')
},
},
{
icon: 'i-lucide-file-text',
get title() {
return t('Authored content')
},
get text() {
return t('Your articles and entries.')
},
},
{
icon: 'i-lucide-image',
get title() {
return t('Uploaded media')
},
get text() {
return t('References to the files you uploaded.')
},
},
{
icon: 'i-lucide-history',
get title() {
return t('Account activity')
},
get text() {
return t('Your log-ins and the actions recorded under your name.')
},
},
]
const notice = computed(() => {
if (exporter.state.value === 'limited') {
return {
color: 'info',
icon: 'i-lucide-timer',
title: t('You can export once every 15 minutes'),
description:
exporter.minutesLeft.value === 1
? t('Your last export was at {0}. Try again in 1 minute.', [
exporter.lastExportTime.value,
])
: t('Your last export was at {0}. Try again in {1} minutes.', [
exporter.lastExportTime.value,
exporter.minutesLeft.value,
]),
} as const
}
if (exporter.state.value === 'error') {
return {
color: 'error',
icon: 'i-lucide-circle-x',
title: t("Couldn't prepare your data"),
description: t('Nothing was changed. Try again in a moment.'),
} as const
}
return null
})
const buttonLabel = computed(() => {
if (exporter.state.value === 'preparing') {
return t('Preparing download…')
}
if (exporter.state.value === 'limited') {
return t('Available in {0}', [exporter.countdown.value])
}
return t('Download my data')
})
const showHint = computed(() => exporter.state.value === 'idle' || exporter.state.value === 'error')
</script>
<template>
<ProfileCard
:title="t('Your data')"
:description="
t(
'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.',
)
"
>
<ul class="grid list-none gap-x-4 gap-y-3 p-0 lg:grid-cols-2">
<li v-for="item in contents" :key="item.icon" class="flex min-w-0 gap-2.5">
<UIcon :name="item.icon" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="flex min-w-0 flex-col">
<span class="font-medium text-highlighted">{{ item.title }}</span>
<span class="text-pretty text-muted">{{ item.text }}</span>
</div>
</li>
</ul>
<UAlert
v-if="notice"
data-testid="data-export-notice"
role="status"
variant="soft"
:color="notice.color"
:icon="notice.icon"
:title="notice.title"
:description="notice.description"
:close="false"
/>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
<UButton
icon="i-lucide-download"
:loading="exporter.busy.value"
:disabled="exporter.blocked.value"
class="h-11 justify-center px-3.5 lg:h-9"
@click="exporter.start"
>
{{ buttonLabel }}
</UButton>
<span class="min-w-0 flex-1 text-pretty text-xs/[18px] text-muted">
{{ showHint ? t('Ready in a few seconds.') : '' }}
</span>
</div>
</ProfileCard>
</template>
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useDataExport } from '@/composables/useDataExport'
import type { AccountDeletion } from '@/composables/useAccountDeletion'
import { DELETE_WORD } from '@/lib/accountDeletion'
const props = defineProps<{ deletion: AccountDeletion }>()
const open = defineModel<boolean>('open', { default: false })
const exporter = useDataExport()
const passwordField = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
const passwordFocused = ref(false)
const wordFocused = ref(false)
const KEPT_CONTENT_TEXT =
'They stay on the site, credited to "Deleted user". Your name is removed from them.'
const consequences = [
'Your username, display name and email address',
'Your author profile and API keys',
"Your sessions: you'll be logged out everywhere",
] as const
const inputUi = { base: 'h-11 text-lg md:h-10 md:text-base' }
const errorUi = { error: 'mt-1.5 text-xs/5' }
const password = computed({
get: () => props.deletion.password.value,
set: (value: string) => props.deletion.typePassword(value),
})
const word = computed({
get: () => props.deletion.word.value,
set: (value: string) => props.deletion.typeWord(value),
})
const deleting = computed(() => props.deletion.deleting.value)
const passwordError = computed(() =>
props.deletion.state.value === 'wrongPassword'
? t("That password isn't correct. Try again.")
: '',
)
const notice = computed(() => {
if (props.deletion.state.value === 'limited') {
return t('Too many requests. Try again in a few minutes.')
}
if (props.deletion.state.value === 'error') {
return t("Couldn't delete your account. Nothing was deleted. Try again in a moment.")
}
return ''
})
const confirmLabel = computed(() => (deleting.value ? t('Deleting…') : t('Delete my account')))
watch(
() => props.deletion.state.value,
async (state) => {
if (state !== 'wrongPassword') {
return
}
await nextTick()
passwordField.value?.inputRef?.focus()
},
)
function onOpenChange(value: boolean) {
if (!value && deleting.value) {
return
}
if (!value) {
props.deletion.reset()
}
open.value = value
}
function onSubmit() {
void props.deletion.submit()
}
function onExport() {
void exporter.start()
}
</script>
<template>
<UModal
:open="open"
:title="t('Delete your account?')"
:description="t('This permanently deletes your account. It can\'t be undone.')"
:dismissible="!deleting"
:close="deleting ? false : { color: 'neutral', variant: 'ghost', class: 'text-muted' }"
:content="{ role: 'alertdialog', 'aria-busy': deleting ? 'true' : undefined }"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
close: 'top-5 end-6 size-11 justify-center p-0 sm:size-7',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: SHEET_FOOTER_CLASS,
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="notice"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
:description="notice"
/>
<ul class="flex list-none flex-col gap-1.5 p-0">
<li
v-for="item in consequences"
:key="item"
class="flex items-start gap-2 text-toned"
>
<UIcon name="i-lucide-x" class="mt-0.75 size-3.5 shrink-0 text-error" />
<span class="text-pretty">{{ t(item) }}</span>
</li>
</ul>
<UAlert
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-info"
:title="t('Your articles and media stay')"
:description="t(KEPT_CONTENT_TEXT)"
:ui="{
root: 'gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'mt-0.5 text-toned',
}"
/>
<UFormField
:label="t('Current password')"
name="current-password"
:error="passwordError || undefined"
:ui="errorUi"
>
<template v-if="passwordError" #error>
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ passwordError }}
</span>
</template>
<UInput
ref="passwordField"
v-model="password"
type="password"
autocomplete="current-password"
:disabled="deleting"
:color="passwordError ? 'error' : 'primary'"
:highlight="passwordFocused || !!passwordError"
:ui="inputUi"
class="w-full"
@focus="passwordFocused = true"
@blur="passwordFocused = false"
@keydown.enter.prevent="onSubmit"
/>
</UFormField>
<UFormField :label="t('Type DELETE to confirm')" name="confirmation">
<UInput
v-model="word"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:placeholder="DELETE_WORD"
:disabled="deleting"
color="primary"
:highlight="wordFocused"
:ui="inputUi"
class="w-full"
@focus="wordFocused = true"
@blur="wordFocused = false"
@keydown.enter.prevent="onSubmit"
/>
</UFormField>
<ULink
as="button"
type="button"
class="flex min-h-11 items-center gap-1.5 self-start font-medium text-highlighted underline underline-offset-3 sm:min-h-7"
:disabled="exporter.blocked.value"
@click="onExport"
>
<UIcon name="i-lucide-download" class="size-3.5" />
{{ t('Export my data first') }}
</ULink>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-9 sm:w-auto sm:px-3"
:disabled="deleting"
@click="onOpenChange(false)"
>
{{ t('Keep my account') }}
</UButton>
<UButton
color="error"
block
:loading="deleting"
:disabled="!deletion.canSubmit.value"
class="h-11 justify-center text-sm sm:h-9 sm:w-auto sm:px-3"
@click="onSubmit"
>
{{ confirmLabel }}
</UButton>
</div>
</template>
</UModal>
</template>
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
emailError: MessageKey | ''
passwordError: MessageKey | ''
notice: MessageKey | ''
sending: boolean
sendDisabled: boolean
focusRequest: { field: 'email' | 'password'; count: number }
}>()
const email = defineModel<string>('email', { required: true })
const password = defineModel<string>('password', { required: true })
const emit = defineEmits<{
submit: []
cancel: []
blurEmail: []
}>()
const emailRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
const passwordRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
const buttonClass = 'h-11 justify-center px-3 lg:h-9'
async function focusRequested() {
await nextTick()
await nextTick()
const target = props.focusRequest.field === 'email' ? emailRef.value : passwordRef.value
target?.inputRef?.focus()
}
onMounted(focusRequested)
watch(() => props.focusRequest, focusRequested)
</script>
<template>
<UForm
:state="{ email, password }"
class="flex flex-col gap-3.5 border border-default bg-muted p-4"
@submit="emit('submit')"
>
<UAlert
v-if="notice"
data-testid="email-change-notice"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:description="t(notice)"
:close="false"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
/>
<UFormField :label="t('New email address')" name="email" :error="!!emailError">
<UInput
ref="emailRef"
v-model="email"
type="email"
inputmode="email"
autocomplete="off"
:placeholder="t('name@example.com')"
:highlight="!!emailError"
:disabled="sending"
class="w-full"
:ui="inputUi"
@blur="emit('blurEmail')"
/>
<span
v-if="emailError"
data-testid="email-change-email-error"
class="mt-1.5 flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ t(emailError) }}
</span>
</UFormField>
<UFormField :label="t('Current password')" name="password" :error="!!passwordError">
<UInput
ref="passwordRef"
v-model="password"
type="password"
autocomplete="current-password"
:highlight="!!passwordError"
:disabled="sending"
class="w-full"
:ui="inputUi"
/>
<span
v-if="passwordError"
data-testid="email-change-password-error"
class="mt-1.5 flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ t(passwordError) }}
</span>
</UFormField>
<p class="text-xs/[18px] text-pretty text-muted">
{{
t(
"We'll send a verification link to the new address. Your email stays the same until you open it.",
)
}}
</p>
<div class="flex flex-col-reverse gap-2 lg:flex-row lg:justify-end">
<UButton
type="button"
color="neutral"
variant="outline"
:disabled="sending"
:class="[buttonClass, 'border border-accented bg-default text-highlighted ring-0']"
@click="emit('cancel')"
>
{{ t('Cancel') }}
</UButton>
<UButton type="submit" :loading="sending" :disabled="sendDisabled" :class="buttonClass">
{{ sending ? t('Sending…') : t('Send verification link') }}
</UButton>
</div>
</UForm>
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
defineProps<{
pendingEmail: string
currentEmail: string
resendState: 'idle' | 'sending' | 'limited'
resendCountdown: string
note: MessageKey | ''
busy: boolean
}>()
const emit = defineEmits<{
resend: []
cancelChange: []
}>()
const buttonClass =
'h-11 justify-center border border-accented bg-default text-highlighted ring-0 lg:h-9'
</script>
<template>
<UAlert
data-testid="pending-email-notice"
role="status"
color="info"
variant="soft"
icon="i-lucide-mail"
:title="t('Waiting for verification')"
:close="false"
:ui="{ root: 'gap-2.5 px-3.5 py-3', title: 'font-semibold', description: 'mt-0.5' }"
>
<template #description>
<div class="flex flex-col gap-3">
<span class="text-pretty break-words text-toned">
{{
t(
'We sent a link to {0}. Until you open it, you keep logging in with {1}.',
[pendingEmail, currentEmail],
)
}}
</span>
<div class="-ms-6.5 flex flex-col gap-2 lg:ms-0 lg:flex-row">
<UButton
color="neutral"
variant="outline"
:loading="resendState === 'sending'"
:disabled="resendState !== 'idle' || busy"
:class="buttonClass"
@click="emit('resend')"
>
{{
resendState === 'sending'
? t('Sending…')
: resendState === 'limited'
? t('Resend in {0}', [resendCountdown])
: t('Resend link')
}}
</UButton>
<UButton
color="neutral"
variant="outline"
:disabled="resendState === 'sending' || busy"
:class="buttonClass"
@click="emit('cancelChange')"
>
{{ t('Cancel change') }}
</UButton>
</div>
<span
v-if="note"
data-testid="pending-email-note"
class="flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ t(note) }}
</span>
</div>
</template>
</UAlert>
</template>
+2 -2
View File
@@ -7,7 +7,7 @@ defineProps<{
<template>
<section class="border border-default bg-default">
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
<div class="flex flex-col gap-3.5 p-4">
<div class="flex flex-col gap-0.5">
<h2 class="text-base/5 font-semibold text-highlighted">
{{ title }}
@@ -18,7 +18,7 @@ defineProps<{
</div>
<div
v-if="$slots.footer"
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3 lg:px-5"
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3"
>
<slot name="footer" />
</div>
+122
View File
@@ -0,0 +1,122 @@
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ApiError, authRedirectPending, redirectToLogin } from '@/composables/useApi'
import { ACCOUNT_DELETED_REASON, DELETE_WORD, accountDeletedRedirect } from '@/lib/accountDeletion'
import { useProfile } from '@/composables/useProfile'
import { useSessionStore } from '@/stores/session'
export type AccountDeletionState =
| 'idle'
| 'deleting'
| 'wrongPassword'
| 'limited'
| 'error'
| 'blocked'
| 'protected'
export type LastRole = 'Owner' | 'Admin'
function lastRoleOf(error: ApiError): LastRole {
return error.lastRole === 'Owner' ? 'Owner' : 'Admin'
}
export function useAccountDeletion() {
const router = useRouter()
const session = useSessionStore()
const { deleteAccount } = useProfile()
const password = ref('')
const word = ref('')
const state = ref<AccountDeletionState>('idle')
const lastRole = ref<LastRole>('Admin')
const deleting = computed(() => state.value === 'deleting')
const blocked = computed(() => state.value === 'blocked' || state.value === 'protected')
const ready = computed(() => password.value !== '' && word.value === DELETE_WORD)
const canSubmit = computed(() => ready.value && !deleting.value)
function typeWord(value: string) {
word.value = value
}
function typePassword(value: string) {
password.value = value
if (state.value === 'wrongPassword') {
state.value = 'idle'
}
}
function reset() {
password.value = ''
word.value = ''
state.value = 'idle'
}
function refuse(error: unknown) {
if (!(error instanceof ApiError)) {
state.value = 'error'
return
}
if (error.status === 401) {
state.value = 'idle'
redirectToLogin()
} else if (error.status === 403 && error.code === 'wrong_password') {
password.value = ''
state.value = 'wrongPassword'
} else if (error.status === 409 && error.code === 'last_admin') {
lastRole.value = lastRoleOf(error)
state.value = 'blocked'
} else if (error.status === 409 && error.code === 'account_not_deletable') {
state.value = 'protected'
} else if (error.status === 429) {
state.value = 'limited'
} else {
state.value = 'error'
}
}
async function leave() {
authRedirectPending.value = true
accountDeletedRedirect.value = true
session.clear()
try {
await router.push({ path: '/login', query: { reason: ACCOUNT_DELETED_REASON } })
} catch {
return
} finally {
authRedirectPending.value = false
}
}
async function submit() {
if (!canSubmit.value) {
return false
}
state.value = 'deleting'
try {
await deleteAccount(password.value, word.value)
} catch (error) {
refuse(error)
return false
}
await leave()
return true
}
return {
password,
word,
state,
lastRole,
deleting,
blocked,
ready,
canSubmit,
typePassword,
typeWord,
submit,
reset,
}
}
export type AccountDeletion = ReturnType<typeof useAccountDeletion>
-11
View File
@@ -82,14 +82,3 @@ export function useActivity() {
listPeople,
}
}
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()
URL.revokeObjectURL(url)
}
+2 -1
View File
@@ -2,7 +2,8 @@ import { computed, onBeforeUnmount, ref, toValue, type MaybeRefOrGetter } from '
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError, isAbort } from '@/composables/useApi'
import { downloadBlob, useActivity } from '@/composables/useActivity'
import { useActivity } from '@/composables/useActivity'
import { downloadBlob } from '@/lib/downloadBlob'
import { useSessionStore } from '@/stores/session'
import {
allActivityFileName,
+26 -1
View File
@@ -1,7 +1,9 @@
import { ref } from 'vue'
import { accountDeletedRedirect } from '@/lib/accountDeletion'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError'
import { filenameFromDisposition } from '@/lib/contentDisposition'
import { missingPermissionCopy } from '@/lib/permissionCopy'
import { useLocalizedToast } from '@/i18n/presentation'
import { parseRoleAssignedBy, type Access, type Grant, type RoleRef } from '@/lib/permissions'
@@ -20,6 +22,11 @@ export class MissingTotalError extends Error {
}
}
export interface DownloadedFile {
blob: Blob
filename: string | undefined
}
export interface PagedResponse<T> {
body: T
total: number
@@ -36,6 +43,7 @@ export interface MeResult {
verified: boolean
username?: string
email?: string
pendingEmail?: string
displayName: string
access?: Access
author?: MeAuthor
@@ -98,6 +106,9 @@ if (typeof window !== 'undefined') {
}
export function redirectToLogin() {
if (authRedirectPending.value || accountDeletedRedirect.value) {
return
}
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
authRedirectPending.value = true
@@ -195,6 +206,7 @@ export function useApi() {
const body = await readErrorBody(response)
throw new ApiError(response.status, undefined, body, {
code: errorCodeOf(body) ?? knownErrorCode(response.headers.get('X-Error-Code')),
lastRole: headerOrUndefined(response, 'X-Last-Role'),
})
}
@@ -228,12 +240,24 @@ export function useApi() {
return response.blob()
}
async function requestFile(
path: string,
options: RequestOptions = {},
): Promise<DownloadedFile> {
const response = await send(path, options)
return {
blob: await response.blob(),
filename: filenameFromDisposition(response.headers.get('Content-Disposition')),
}
}
async function getMe(): Promise<MeResult> {
const body = await request<{
id?: number
is_verified: boolean
username?: string
email?: string
pending_email?: string | null
display_name?: string | null
access?: { role: RoleRef; permissions: Grant[]; role_assigned_by?: unknown }
author?: MeAuthor | null
@@ -243,6 +267,7 @@ export function useApi() {
verified: Boolean(body.is_verified),
username: body.username,
email: body.email,
...(body.pending_email ? { pendingEmail: body.pending_email } : {}),
displayName: body.display_name ?? '',
...(body.access
? {
@@ -257,5 +282,5 @@ export function useApi() {
}
}
return { getMe, request, requestPaged, requestBlob }
return { getMe, request, requestPaged, requestBlob, requestFile }
}
+98
View File
@@ -0,0 +1,98 @@
import { computed, ref, watch } from 'vue'
import { locale, t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { downloadBlob } from '@/lib/downloadBlob'
import { useProfile } from '@/composables/useProfile'
import { useRetryAfter } from '@/composables/useRetryAfter'
export type DataExportState = 'idle' | 'preparing' | 'limited' | 'error'
const EXPORT_WINDOW_SECONDS = 900
const FALLBACK_FILENAME = 'renewcms-export.zip'
const SECONDS_PER_MINUTE = 60
const MILLISECONDS_PER_SECOND = 1000
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.status === 403)
}
function clockTime(moment: Date) {
return new Intl.DateTimeFormat(locale.value, {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(moment)
}
export function useDataExport() {
const toast = useLocalizedToast()
const { downloadMyData } = useProfile()
const limit = useRetryAfter()
const state = ref<DataExportState>('idle')
const lastExportTime = ref('')
const minutesLeft = ref(1)
const busy = computed(() => state.value === 'preparing')
const blocked = computed(() => state.value === 'preparing' || state.value === 'limited')
watch(limit.remaining, (remaining) => {
if (remaining === 0 && state.value === 'limited') {
state.value = 'idle'
}
})
function announceStarted(filename: string) {
toast.add(() => ({
id: 'data-export-started',
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Download started')
},
get description() {
return t('{0} is in your downloads.', [filename])
},
}))
}
function enterLimited(retryAfter: number | undefined) {
const seconds = retryAfter || EXPORT_WINDOW_SECONDS
const elapsed = Math.max(0, EXPORT_WINDOW_SECONDS - seconds)
lastExportTime.value = clockTime(new Date(Date.now() - elapsed * MILLISECONDS_PER_SECOND))
minutesLeft.value = Math.max(1, Math.ceil(seconds / SECONDS_PER_MINUTE))
limit.start(seconds)
state.value = 'limited'
}
async function start() {
if (blocked.value) {
return
}
state.value = 'preparing'
try {
const { blob, filename } = await downloadMyData()
const name = filename ?? FALLBACK_FILENAME
downloadBlob(blob, name)
announceStarted(name)
state.value = 'idle'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
enterLimited(error.retryAfter)
return
}
state.value = isHandledGlobally(error) ? 'idle' : 'error'
}
}
return {
state,
busy,
blocked,
lastExportTime,
minutesLeft,
countdown: limit.formatted,
start,
}
}
+290
View File
@@ -0,0 +1,290 @@
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { computed, ref, watch } from 'vue'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useProfile } from '@/composables/useProfile'
import { useRetryAfter } from '@/composables/useRetryAfter'
import {
NEW_EMAIL_PATTERN,
RATE_LIMIT_FALLBACK_SECONDS,
WRONG_PASSWORD_BODY,
isSameEmail,
} from '@/lib/emailChange'
import { useSessionStore } from '@/stores/session'
const GENERIC_FAILURE_NOTICE =
'Something went wrong on our side. Nothing was changed. Try again in a moment.'
type FocusField = 'email' | 'password'
type ResendState = 'idle' | 'sending' | 'limited'
export function useEmailChange() {
const session = useSessionStore()
const toast = useLocalizedToast()
const profile = useProfile()
const sendLimit = useRetryAfter()
const resendLimit = useRetryAfter()
const formOpen = ref(false)
const newEmail = ref('')
const password = ref('')
const emailError = ref<MessageKey | ''>('')
const passwordError = ref<MessageKey | ''>('')
const notice = ref<MessageKey | ''>('')
const sending = ref(false)
const sendLimited = ref(false)
const focusRequest = ref<{ field: FocusField; count: number }>({ field: 'email', count: 0 })
const resendState = ref<ResendState>('idle')
const pendingNote = ref<MessageKey | ''>('')
const cancelling = ref(false)
const pendingEmail = computed(() => session.session?.pendingEmail)
const currentEmail = computed(() => session.session?.email)
const showChangeLink = computed(() => !formOpen.value && !pendingEmail.value)
function requestFocus(field: FocusField) {
focusRequest.value = { field, count: focusRequest.value.count + 1 }
}
function openForm() {
formOpen.value = true
requestFocus('email')
}
function resetForm() {
formOpen.value = false
newEmail.value = ''
password.value = ''
emailError.value = ''
passwordError.value = ''
notice.value = ''
sending.value = false
sendLimited.value = false
}
function validateFormat() {
return NEW_EMAIL_PATTERN.test(newEmail.value.trim())
? ''
: 'Enter a full email address, like name@example.com.'
}
function validateEmail() {
const formatError = validateFormat()
if (formatError) {
return formatError
}
if (isSameEmail(newEmail.value, currentEmail.value)) {
return "That's already your email address."
}
return ''
}
function checkEmailOnBlur() {
if (newEmail.value.trim()) {
emailError.value = validateFormat()
}
}
function validateForm() {
emailError.value = validateEmail()
passwordError.value = password.value ? '' : 'Enter your current password.'
if (emailError.value) {
requestFocus('email')
} else if (passwordError.value) {
requestFocus('password')
}
return !emailError.value && !passwordError.value
}
watch(newEmail, (value) => {
if (value) {
emailError.value = ''
}
})
watch(password, (value) => {
if (value) {
passwordError.value = ''
}
})
watch(sendLimit.remaining, (value) => {
if (value === 0 && sendLimited.value) {
sendLimited.value = false
notice.value = ''
}
})
watch(resendLimit.remaining, (value) => {
if (value === 0 && resendState.value === 'limited') {
resendState.value = 'idle'
pendingNote.value = ''
}
})
function announceSent(address: string) {
toast.add(() => ({
id: 'email-change-sent',
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Verification link sent')
},
get description() {
return t('Check {0}. The link works once, within 2 hours.', [address])
},
}))
}
function announceResent() {
toast.add(() => ({
id: 'email-change-resent',
color: 'info',
icon: 'i-lucide-info',
get title() {
return t('Link sent again')
},
get description() {
return t('The previous link no longer works.')
},
}))
}
function announceCancelled(address: string | undefined) {
toast.add(() => ({
id: 'email-change-cancelled',
color: 'info',
icon: 'i-lucide-info',
get title() {
return t('Email change cancelled')
},
get description() {
return t('You keep logging in with {0}.', [address])
},
}))
}
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && error.status === 401
}
function showSendFailure(error: unknown) {
if (isHandledGlobally(error)) {
return
}
if (
error instanceof ApiError &&
error.status === 403 &&
error.body === WRONG_PASSWORD_BODY
) {
passwordError.value = "That password isn't correct. Try again."
password.value = ''
requestFocus('password')
return
}
if (error instanceof ApiError && error.status === 429) {
sendLimited.value = true
notice.value = 'Too many requests. Try again in a few minutes.'
sendLimit.start(error.retryAfter || RATE_LIMIT_FALLBACK_SECONDS)
return
}
notice.value = GENERIC_FAILURE_NOTICE
}
async function submit() {
if (sending.value || sendLimited.value || !validateForm()) {
return
}
sending.value = true
notice.value = ''
const address = newEmail.value.trim()
try {
const result = await profile.requestEmailChange(address, password.value)
session.setPendingEmail(result.pending_email)
resetForm()
announceSent(result.pending_email)
} catch (error) {
showSendFailure(error)
} finally {
sending.value = false
}
}
async function resend() {
if (resendState.value !== 'idle' || cancelling.value) {
return
}
resendState.value = 'sending'
pendingNote.value = ''
try {
const result = await profile.resendEmailChange()
session.setPendingEmail(result.pending_email)
resendState.value = 'idle'
announceResent()
} catch (error) {
resendState.value = 'idle'
showResendFailure(error)
}
}
function showResendFailure(error: unknown) {
if (isHandledGlobally(error)) {
return
}
if (error instanceof ApiError && error.status === 429) {
resendState.value = 'limited'
pendingNote.value = 'Too many requests. Try again in a few minutes.'
resendLimit.start(error.retryAfter || RATE_LIMIT_FALLBACK_SECONDS)
return
}
if (error instanceof ApiError && error.status === 404) {
session.setPendingEmail(undefined)
return
}
pendingNote.value = GENERIC_FAILURE_NOTICE
}
async function cancelChange() {
if (cancelling.value || resendState.value === 'sending') {
return
}
cancelling.value = true
pendingNote.value = ''
try {
await profile.cancelEmailChange()
session.setPendingEmail(undefined)
resendLimit.start(0)
resendState.value = 'idle'
announceCancelled(currentEmail.value)
} catch (error) {
if (!isHandledGlobally(error)) {
pendingNote.value = GENERIC_FAILURE_NOTICE
}
} finally {
cancelling.value = false
}
}
return {
formOpen,
newEmail,
password,
emailError,
passwordError,
notice,
sending,
sendLimited,
focusRequest,
resendState,
resendCountdown: resendLimit.formatted,
pendingNote,
cancelling,
pendingEmail,
showChangeLink,
openForm,
resetForm,
checkEmailOnBlur,
submit,
resend,
cancelChange,
}
}
+4 -6
View File
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router'
import { ApiError } from '@/composables/useApi'
import { useEntries } from '@/composables/useEntries'
import { useUserLookup } from '@/composables/useUserLookup'
import { namesWithDeletedFallback } from '@/lib/deletedUser'
import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes'
import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery'
import {
@@ -35,12 +36,9 @@ export function useEntryList(typeKey: string) {
if (ids.length === 0) {
return
}
const found = await lookupUsers(ids).catch(() => [])
if (current === lookupToken) {
names.value = {
...names.value,
...Object.fromEntries(found.map((user) => [user.id, user.display_name])),
}
const found = await lookupUsers(ids).catch(() => null)
if (found && current === lookupToken) {
names.value = { ...names.value, ...namesWithDeletedFallback(ids, found) }
}
}
+2 -4
View File
@@ -1,5 +1,6 @@
import { ref, watch } from 'vue'
import { useUserLookup } from '@/composables/useUserLookup'
import { namesWithDeletedFallback } from '@/lib/deletedUser'
import type { ContentEntry } from '@/lib/contentTypes'
function authorIds(entry: ContentEntry | null) {
@@ -19,10 +20,7 @@ export function useEntryNames(entry: () => ContentEntry | null) {
wanted.forEach((id) => requested.add(id))
try {
const users = await lookupUsers(wanted)
names.value = {
...names.value,
...Object.fromEntries(users.map((user) => [user.id, user.display_name])),
}
names.value = { ...names.value, ...namesWithDeletedFallback(wanted, users) }
} catch {
wanted.forEach((id) => requested.delete(id))
}
+50 -2
View File
@@ -3,7 +3,7 @@ import { useApi } from '@/composables/useApi'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useProfile() {
const { request } = useApi()
const { request, requestFile } = useApi()
function updateDisplayName(displayName: string) {
return request<{ display_name?: string | null }>('/v1/user/me', {
@@ -21,5 +21,53 @@ export function useProfile() {
})
}
return { updateDisplayName, sendPasswordReset }
function requestEmailChange(newEmail: string, currentPassword: string) {
return request<{ pending_email: string }>('/v1/auth/email/change', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ new_email: newEmail, current_password: currentPassword }),
})
}
function resendEmailChange() {
return request<{ pending_email: string }>('/v1/auth/email/resend', { method: 'POST' })
}
function cancelEmailChange() {
return request<void>('/v1/auth/email/change', { method: 'DELETE' })
}
function confirmEmailChange(code: string) {
return request<{ email: string }>('/v1/auth/email/confirm', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ code }),
onUnauthorized: 'passthrough',
onForbidden: 'passthrough',
})
}
function downloadMyData() {
return requestFile('/v1/user/me/export')
}
function deleteAccount(currentPassword: string, confirmation: string) {
return request<void>('/v1/user/me', {
method: 'DELETE',
headers: JSON_HEADERS,
body: JSON.stringify({ current_password: currentPassword, confirmation }),
onUnauthorized: 'passthrough',
})
}
return {
deleteAccount,
downloadMyData,
updateDisplayName,
sendPasswordReset,
requestEmailChange,
resendEmailChange,
cancelEmailChange,
confirmEmailChange,
}
}
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { createPinia } from 'pinia'
import router from '@/router'
import BlockedDialog from '@/components/BlockedDialog.vue'
import { setLocale } from '@/i18n'
import { messages, type MessageKey } from '@/i18n/messages'
const KEYS: MessageKey[] = [
'Danger zone',
"Delete your account and your personal data. This can't be undone.",
'Delete my account',
'Delete your account?',
"This permanently deletes your account. It can't be undone.",
'Your username, display name and email address',
'Your author profile and API keys',
"Your sessions: you'll be logged out everywhere",
'Your articles and media stay',
'They stay on the site, credited to "Deleted user". Your name is removed from them.',
'Deleted user',
'Export my data first',
'Current password',
'Type DELETE to confirm',
'Keep my account',
'Deleting…',
"That password isn't correct. Try again.",
'Too many requests. Try again in a few minutes.',
"Couldn't delete your account. Nothing was deleted. Try again in a moment.",
"You can't delete your account yet",
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.',
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.',
'Back to profile',
'Go to Users',
"This account can't be deleted",
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again.",
'Your account has been deleted',
'Your articles and media were kept without your name.',
'Deleted the account of {0}',
'Failed to delete the account of {0}',
]
function placeholders(text: string) {
return (text.match(/\{\d+\}/g) ?? []).sort()
}
afterEach(() => setLocale('en'))
describe('account deletion catalogue', () => {
it.each(KEYS)('has a French value with the same placeholders for %s', (key) => {
const french = messages[key]
expect(french.trim()).not.toBe('')
expect(placeholders(french)).toEqual(placeholders(key))
})
it('keeps the confirmation word DELETE literal in the French label', () => {
expect(messages['Type DELETE to confirm']).toContain('DELETE')
})
it('renders the blocked dialog in French and back in English', async () => {
const mountBlocked = () =>
mount(BlockedDialog, {
props: { open: true, lastRole: 'Owner' },
attachTo: document.body,
global: { plugins: [router, uiPlugin, createPinia()] },
})
setLocale('fr')
const french = mountBlocked()
await french.vm.$nextTick()
expect(document.body.textContent).toContain(
'Vous ne pouvez pas encore supprimer votre compte',
)
expect(document.body.textContent).toContain('Retour au profil')
expect(document.body.textContent).not.toContain('Back to profile')
french.unmount()
document.body.innerHTML = ''
setLocale('en')
const english = mountBlocked()
await english.vm.$nextTick()
expect(document.body.textContent).toContain("You can't delete your account yet")
english.unmount()
document.body.innerHTML = ''
})
})
+116
View File
@@ -1212,6 +1212,21 @@ export const messages = {
'Registered the account {0}': 'Compte créé : {0}',
'Verified the account {0}': 'Compte vérifié : {0}',
'Updated the profile of {0}': 'Profil mis à jour : {0}',
'Requested an email change for {0}': 'Changement d’e-mail demandé : {0}',
'Failed to request an email change for {0}': 'Échec de la demande de changement d’e-mail : {0}',
'Resent the email change link for {0}': 'Lien de changement d’e-mail renvoyé : {0}',
'Failed to resend the email change link for {0}':
'Échec du renvoi du lien de changement d’e-mail : {0}',
'Cancelled the email change for {0}': 'Changement d’e-mail annulé : {0}',
'Failed to cancel the email change for {0}':
'Échec de l’annulation du changement d’e-mail : {0}',
'Confirmed the email change for {0}': 'Changement d’e-mail confirmé : {0}',
'Failed to confirm the email change for {0}':
'Échec de la confirmation du changement d’e-mail : {0}',
'Exported the personal data of {0}': 'Données personnelles exportées : {0}',
'Failed to export the personal data of {0}': 'Échec de l’export des données personnelles : {0}',
'Deleted the account of {0}': 'Compte supprimé : {0}',
'Failed to delete the account of {0}': 'Échec de la suppression du compte : {0}',
'Invited {0}': 'Invitation envoyée à {0}',
'Cancelled the invite for {0}': 'Invitation annulée : {0}',
'Renewed the invite for {0}': 'Invitation renouvelée : {0}',
@@ -3303,6 +3318,107 @@ export const messages = {
"You haven't created {0} yet": 'Vous n’avez pas encore créé d’entrée dans {0}',
"Your role shows only entries you created, so other people's {0} aren't listed here. Use New entry to add one.":
'Votre rôle n’affiche que les entrées que vous avez créées, donc les entrées des autres personnes dans {0} ne sont pas listées ici. Utilisez Nouvelle entrée pour en ajouter une.',
'Change email': 'Changer l’e-mail',
'New email address': 'Nouvelle adresse e-mail',
'name@example.com': 'nom@exemple.fr',
'Current password': 'Mot de passe actuel',
"We'll send a verification link to the new address. Your email stays the same until you open it.":
'Nous enverrons un lien de vérification à la nouvelle adresse. Votre e-mail reste inchangé tant que vous n’ouvrez pas le lien.',
'Send verification link': 'Envoyer le lien de vérification',
"That's already your email address.": 'C’est déjà votre adresse e-mail.',
'Enter your current password.': 'Saisissez votre mot de passe actuel.',
"That password isn't correct. Try again.": 'Ce mot de passe n’est pas correct. Réessayez.',
'Too many requests. Try again in a few minutes.':
'Trop de demandes. Réessayez dans quelques minutes.',
'Waiting for verification': 'En attente de vérification',
'We sent a link to {0}. Until you open it, you keep logging in with {1}.':
'Nous avons envoyé un lien à {0}. Tant que vous ne l’ouvrez pas, vous continuez à vous connecter avec {1}.',
'Resend link': 'Renvoyer le lien',
'Resend in {0}': 'Renvoyer dans {0}',
'Cancel change': 'Annuler le changement',
'Verification link sent': 'Lien de vérification envoyé',
'Check {0}. The link works once, within 2 hours.':
'Consultez {0}. Le lien ne fonctionne qu’une fois, dans les 2 heures.',
'Link sent again': 'Lien renvoyé',
'The previous link no longer works.': 'Le lien précédent ne fonctionne plus.',
'Email change cancelled': 'Changement d’e-mail annulé',
'You keep logging in with {0}.': 'Vous continuez à vous connecter avec {0}.',
'Confirming your new email…': 'Confirmation de votre nouvelle adresse e-mail…',
'Your email address has changed': 'Votre adresse e-mail a changé',
'Your account now uses {0}. Use it the next time you log in.':
'Votre compte utilise maintenant {0}. Utilisez-la lors de votre prochaine connexion.',
'Go to profile': 'Aller au profil',
'Email address changed': 'Adresse e-mail modifiée',
'Your account now uses {0}.': 'Votre compte utilise maintenant {0}.',
"This link doesn't work": 'Ce lien ne fonctionne pas',
'It may have expired (links last 2 hours), been cancelled or already been used.':
'Il a peut-être expiré (les liens durent 2 heures), été annulé ou déjà été utilisé.',
'Request a new link from your profile.': 'Demandez un nouveau lien depuis votre profil.',
"We couldn't change your email address": 'Nous n’avons pas pu changer votre adresse e-mail',
"This address can't be used for your account. Go back to your profile and try a different one.":
'Cette adresse ne peut pas être utilisée pour votre compte. Retournez à votre profil et essayez-en une autre.',
'Log in to confirm your new email':
'Connectez-vous pour confirmer votre nouvelle adresse e-mail',
"You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.":
'Vous avez ouvert ce lien là où vous n’êtes pas connecté. Connectez-vous ici et nous confirmerons le changement juste après.',
'Log in and confirm': 'Se connecter et confirmer',
'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.':
'Le code de confirmation manque. Rouvrez le lien de votre e-mail ou copiez l’adresse complète dans votre navigateur.',
"Couldn't confirm the change": 'Impossible de confirmer le changement',
'Your data': 'Vos données',
'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.':
'Téléchargez une copie des données personnelles que RenewCMS conserve à votre sujet, sous forme de fichiers JSON dans un ZIP.',
'Username, display name, email, role and author profile.':
'Nom d’utilisateur, nom d’affichage, e-mail, rôle et profil d’auteur.',
'Authored content': 'Contenus rédigés',
'Your articles and entries.': 'Vos articles et vos entrées.',
'Uploaded media': 'Médias téléversés',
'References to the files you uploaded.': 'Références des fichiers que vous avez téléversés.',
'Account activity': 'Activité du compte',
'Your log-ins and the actions recorded under your name.':
'Vos connexions et les actions enregistrées à votre nom.',
'Download my data': 'Télécharger mes données',
'Ready in a few seconds.': 'Prêt en quelques secondes.',
'{0} is in your downloads.': '{0} se trouve dans vos téléchargements.',
'You can export once every 15 minutes': 'Vous pouvez exporter une fois toutes les 15 minutes',
'Your last export was at {0}. Try again in {1} minutes.':
'Votre dernier export date de {0}. Réessayez dans {1} minutes.',
'Your last export was at {0}. Try again in 1 minute.':
'Votre dernier export date de {0}. Réessayez dans 1 minute.',
'Available in {0}': 'Disponible dans {0}',
"Couldn't prepare your data": 'Impossible de préparer vos données',
'Danger zone': 'Zone de danger',
"Delete your account and your personal data. This can't be undone.":
'Supprimez votre compte et vos données personnelles. Cette action est irréversible.',
'Delete my account': 'Supprimer mon compte',
'Delete your account?': 'Supprimer votre compte ?',
"This permanently deletes your account. It can't be undone.":
'Cette action supprime définitivement votre compte. Elle est irréversible.',
'Your username, display name and email address':
'Votre nom d’utilisateur, votre nom d’affichage et votre adresse e-mail',
'Your author profile and API keys': 'Votre profil d’auteur et vos clés d’API',
"Your sessions: you'll be logged out everywhere":
'Vos sessions : vous serez déconnecté partout',
'Your articles and media stay': 'Vos articles et vos médias restent',
'They stay on the site, credited to "Deleted user". Your name is removed from them.':
'Ils restent sur le site, attribués à « Utilisateur supprimé ». Votre nom en est retiré.',
'Deleted user': 'Utilisateur supprimé',
'Export my data first': 'Exporter mes données d’abord',
'Type DELETE to confirm': 'Saisissez DELETE pour confirmer',
"Couldn't delete your account. Nothing was deleted. Try again in a moment.":
'Impossible de supprimer votre compte. Rien n’a été supprimé. Réessayez dans un instant.',
"You can't delete your account yet": 'Vous ne pouvez pas encore supprimer votre compte',
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.':
'Vous êtes le dernier administrateur. RenewCMS en a besoin d’au moins un. Promouvez d’abord un autre utilisateur, puis revenez supprimer votre compte.',
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.':
'Vous êtes le dernier propriétaire. RenewCMS en a besoin d’au moins un. Transférez d’abord la propriété ou promouvez un autre utilisateur, puis revenez supprimer votre compte.',
'Back to profile': 'Retour au profil',
"This account can't be deleted": 'Ce compte ne peut pas être supprimé',
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again.":
'RenewCMS ne peut pas supprimer ce compte pour le moment. Demandez de l’aide à un administrateur, puis réessayez.',
'Your account has been deleted': 'Votre compte a été supprimé',
'Your articles and media were kept without your name.':
'Vos articles et vos médias ont été conservés sans votre nom.',
} as const
export type MessageKey = keyof typeof messages
@@ -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()
}
+5
View File
@@ -59,6 +59,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/VerifyView.vue'),
meta: { auth: 'public' },
},
{
path: '/account/email/confirm',
component: () => import('@/views/EmailConfirmView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/request',
component: () => import('@/views/ResetRequestView.vue'),
+15
View File
@@ -13,6 +13,7 @@ export interface Session {
verified: boolean
username?: string
email?: string
pendingEmail?: string
displayName: string
access?: Access
author?: MeAuthor
@@ -105,6 +106,18 @@ export const useSessionStore = defineStore('session', () => {
}
}
function setPendingEmail(pendingEmail: string | undefined) {
if (session.value) {
session.value = { ...session.value, pendingEmail }
}
}
function setEmail(email: string) {
if (session.value) {
session.value = { ...session.value, email, pendingEmail: undefined }
}
}
return {
status,
session,
@@ -116,6 +129,8 @@ export const useSessionStore = defineStore('session', () => {
clear,
setAuthor,
setDisplayName,
setPendingEmail,
setEmail,
}
})
+454
View File
@@ -0,0 +1,454 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedToast } from '@/i18n/presentation'
import AuthShell from '@/components/AuthShell.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useProfile } from '@/composables/useProfile'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
import { useSessionStore } from '@/stores/session'
type ConfirmState =
| 'confirming'
| 'noSessionRetrying'
| 'success'
| 'invalid'
| 'unavailable'
| 'noSession'
| 'missing'
| 'error'
interface AlertState {
tone: 'error' | 'info'
icon: string
title: string
description: string
}
const wrongCredentialsAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("That username and password don't match")
},
get description() {
return t('Check both and try again. Passwords are case-sensitive.')
},
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
get title() {
return t('Too many attempts')
},
get description() {
return t('For your security, wait a moment before trying again.')
},
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't reach RenewCMS")
},
get description() {
return t('Check your connection and try again.')
},
}
const route = useRoute()
const router = useRouter()
const api = useApi()
const profile = useProfile()
const session = useSessionStore()
const toast = useLocalizedToast()
const retryAfter = useRetryAfter()
const state = ref<ConfirmState>('missing')
const confirmedEmail = ref('')
const username = ref('')
const password = ref('')
const passwordShown = ref(false)
const loginSubmitting = ref(false)
const loginAlert = ref<AlertState | null>(null)
const shellTitle = computed(() =>
state.value === 'noSession' ? t('Log in to confirm your new email') : '',
)
const shellDescription = computed(() =>
state.value === 'noSession'
? t(
"You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.",
)
: undefined,
)
const usernameError = ref('')
const passwordError = ref('')
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
return t('Logging in…')
}
if (retryAfter.active.value) {
return t('Try again in {0}', [retryAfter.formatted.value])
}
return t('Log in and confirm')
})
const confirmationCode = typeof route.query.c === 'string' ? route.query.c : ''
async function focusPassword() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
}
function announceChanged(email: string) {
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Email address changed')
},
get description() {
return t('Your account now uses {0}.', [email])
},
}))
}
async function performConfirm(code: string) {
try {
const result = await profile.confirmEmailChange(code)
confirmedEmail.value = result.email
state.value = 'success'
session.setEmail(result.email)
announceChanged(result.email)
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
await router.push('/profile')
} catch (error) {
state.value = stateForFailure(error)
}
}
function stateForFailure(error: unknown): ConfirmState {
if (error instanceof ApiError && error.status === 401) {
return 'noSession'
}
if (error instanceof ApiError && error.status === 403) {
return 'invalid'
}
if (error instanceof ApiError && error.status === 409) {
return 'unavailable'
}
return 'error'
}
async function startConfirming(code: string) {
state.value = 'confirming'
await performConfirm(code)
}
async function retryConfirming(code: string) {
state.value = 'noSessionRetrying'
await performConfirm(code)
}
function handleLoginError(error: unknown) {
if (error instanceof ApiError && error.status === 403) {
loginAlert.value = wrongCredentialsAlert
password.value = ''
focusPassword()
return
}
if (error instanceof ApiError && error.status === 429) {
loginAlert.value = tooManyAttemptsAlert
retryAfter.start(error.retryAfter ?? 0)
return
}
loginAlert.value = serverErrorAlert
}
function validateLogin() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
async function onLoginSubmit() {
if (loginSubmitDisabled.value) {
return
}
if (!validateLogin()) {
return
}
loginAlert.value = null
loginSubmitting.value = true
try {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
await retryConfirming(confirmationCode)
} catch (error) {
handleLoginError(error)
} finally {
loginSubmitting.value = false
}
}
function goToProfile() {
router.push('/profile')
}
function goToLogin() {
router.push('/login')
}
const profileActions = [
{
get label() {
return t('Go to profile')
},
onClick: goToProfile,
},
]
const retryActions = [
{
get label() {
return t('Try again')
},
onClick: () => startConfirming(confirmationCode),
},
]
const missingCodeActions = [
{
get label() {
return t('Go to log in')
},
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
onMounted(async () => {
if (!confirmationCode) {
state.value = 'missing'
return
}
await router.replace({ path: route.path })
await startConfirming(confirmationCode)
})
</script>
<template>
<AuthShell :title="shellTitle" :description="shellDescription">
<template v-if="state === 'noSession' && loginAlert" #notice>
<UAlert
:color="loginAlert.tone"
:icon="loginAlert.icon"
:title="loginAlert.title"
:description="loginAlert.description"
variant="soft"
:close="false"
:role="loginAlert.tone === 'error' ? 'alert' : 'status'"
/>
</template>
<template #default="{ controlSize, controlClass, inputClass }">
<div
v-if="state === 'confirming' || state === 'noSessionRetrying'"
role="status"
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">
{{ t('Confirming your new email…') }}
</p>
<p class="text-base text-muted">
{{ t('This only takes a moment.') }}
</p>
</div>
<ResultPanel
v-else-if="state === 'success'"
tone="success"
icon="i-lucide-mail-check"
:title="t('Your email address has changed')"
:description="
t('Your account now uses {0}. Use it the next time you log in.', [
confirmedEmail,
])
"
:actions="profileActions"
/>
<ResultPanel
v-else-if="state === 'invalid'"
tone="error"
icon="i-lucide-link-2-off"
:title="t('This link doesn\'t work')"
:description="
t(
'It may have expired (links last 2 hours), been cancelled or already been used.',
)
"
:actions="profileActions"
>
<p class="text-sm text-pretty text-muted">
{{ t('Request a new link from your profile.') }}
</p>
</ResultPanel>
<ResultPanel
v-else-if="state === 'unavailable'"
tone="error"
icon="i-lucide-mail-x"
:title="t('We couldn\'t change your email address')"
:description="
t(
'This address can\'t be used for your account. Go back to your profile and try a different one.',
)
"
:actions="profileActions"
/>
<ResultPanel
v-else-if="state === 'missing'"
tone="error"
icon="i-lucide-link-2-off"
:title="t('This link is incomplete')"
:description="
t(
'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.',
)
"
:actions="missingCodeActions"
/>
<ResultPanel
v-else-if="state === 'error'"
tone="error"
icon="i-lucide-circle-x"
:title="t('Couldn\'t confirm the change')"
:description="
t(
'Something went wrong on our side. Nothing was changed. Try again in a moment.',
)
"
:actions="retryActions"
/>
<UForm
v-else-if="state === 'noSession'"
:state="{ username, password }"
class="flex flex-col gap-4.5"
@submit="onLoginSubmit"
>
<UFormField
:label="t('Username')"
name="username"
:error="translate(usernameError) || undefined"
:size="controlSize"
>
<template v-if="usernameError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ t(error) }}
</span>
</template>
<UInput
v-model="username"
:highlight="!!usernameError"
:placeholder="t('Your username')"
autocomplete="username"
:disabled="loginSubmitDisabled"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('Password')"
name="password"
:error="translate(passwordError) || undefined"
:size="controlSize"
>
<template v-if="passwordError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ t(error) }}
</span>
</template>
<UInput
v-model="password"
:highlight="!!passwordError"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="loginSubmitDisabled"
variant="outline"
color="primary"
:size="controlSize"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="
passwordShown ? t('Hide password') : t('Show password')
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="loginSubmitting"
:disabled="loginSubmitDisabled"
>
{{ loginSubmitLabel }}
</UButton>
</UForm>
</template>
</AuthShell>
</template>
+29 -2
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { ACCOUNT_DELETED_REASON, accountDeletedRedirect } from '@/lib/accountDeletion'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSessionStore } from '@/stores/session'
@@ -72,6 +73,17 @@ const passwordChangedAlert: AlertState = {
},
}
const accountDeletedAlert: AlertState = {
tone: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Your account has been deleted')
},
get description() {
return t('Your articles and media were kept without your name.')
},
}
const route = useRoute()
const router = useRouter()
const api = useApi()
@@ -93,7 +105,9 @@ const alert = ref<AlertState | null>(
? sessionExpiredAlert
: initialReason === 'password-changed'
? passwordChangedAlert
: null,
: initialReason === ACCOUNT_DELETED_REASON
? accountDeletedAlert
: null,
)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
@@ -216,10 +230,23 @@ async function onSubmit() {
}
}
function consumeAccountDeletedReason() {
const rest = { ...route.query }
delete rest.reason
void router.replace({ path: route.path, query: rest })
}
onBeforeUnmount(() => {
accountDeletedRedirect.value = false
})
onMounted(async () => {
if (initialReason === 'expired') {
focusPassword()
}
if (initialReason === ACCOUNT_DELETED_REASON) {
consumeAccountDeletedReason()
}
try {
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
+49 -2
View File
@@ -7,10 +7,15 @@ import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import AuthorProfileCard from '@/components/AuthorProfileCard.vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DangerZoneCard from '@/components/DangerZoneCard.vue'
import DataExportCard from '@/components/DataExportCard.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EmailChangeForm from '@/components/EmailChangeForm.vue'
import PendingEmailNotice from '@/components/PendingEmailNotice.vue'
import ProfileCard from '@/components/ProfileCard.vue'
import YourRoleCard from '@/components/YourRoleCard.vue'
import { ApiError, authRedirectPending } from '@/composables/useApi'
import { useEmailChange } from '@/composables/useEmailChange'
import { useProfile } from '@/composables/useProfile'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { usePermissionsStore } from '@/stores/permissions'
@@ -39,6 +44,7 @@ const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const { updateDisplayName, sendPasswordReset } = useProfile()
const retryAfter = useRetryAfter()
const emailChange = useEmailChange()
const account = ref<Session | null>(session.session)
const field = ref('')
@@ -102,6 +108,8 @@ const readOnlyUi = {
base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base',
}
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
const changeEmailLinkClass =
'h-auto min-h-11 p-0 font-medium text-highlighted underline underline-offset-3 hover:underline lg:min-h-5'
const resetNotes = {
sent: {
@@ -266,7 +274,7 @@ onBeforeRouteLeave(async () => {
<template>
<DashboardShell compact-padding render-while-checking>
<div class="flex max-w-164 flex-col gap-4">
<div class="flex max-w-168 flex-col gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Profile') }}
@@ -281,7 +289,7 @@ onBeforeRouteLeave(async () => {
v-for="(card, cardIndex) in SKELETON_CARDS"
:key="cardIndex"
data-testid="profile-skeleton-card"
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
class="flex flex-col gap-3 border border-default bg-default p-4"
>
<USkeleton class="h-3.5 rounded-none bg-accented" :class="card.titleWidth" />
<USkeleton
@@ -385,6 +393,16 @@ onBeforeRouteLeave(async () => {
/>
</UFormField>
<UFormField :label="t('Email')">
<template v-if="emailChange.showChangeLink.value" #hint>
<UButton
variant="link"
color="neutral"
:class="changeEmailLinkClass"
@click="emailChange.openForm"
>
{{ t('Change email') }}
</UButton>
</template>
<UInput
:model-value="email"
disabled
@@ -394,6 +412,31 @@ onBeforeRouteLeave(async () => {
/>
</UFormField>
</div>
<EmailChangeForm
v-if="emailChange.formOpen.value"
v-model:email="emailChange.newEmail.value"
v-model:password="emailChange.password.value"
:email-error="emailChange.emailError.value"
:password-error="emailChange.passwordError.value"
:notice="emailChange.notice.value"
:sending="emailChange.sending.value"
:send-disabled="emailChange.sendLimited.value || emailChange.sending.value"
:focus-request="emailChange.focusRequest.value"
@submit="emailChange.submit"
@cancel="emailChange.resetForm"
@blur-email="emailChange.checkEmailOnBlur"
/>
<PendingEmailNotice
v-else-if="emailChange.pendingEmail.value"
:pending-email="emailChange.pendingEmail.value"
:current-email="email"
:resend-state="emailChange.resendState.value"
:resend-countdown="emailChange.resendCountdown.value"
:note="emailChange.pendingNote.value"
:busy="emailChange.cancelling.value"
@resend="emailChange.resend"
@cancel-change="emailChange.cancelChange"
/>
</ProfileCard>
<YourRoleCard v-if="permissions.role" />
@@ -443,6 +486,10 @@ onBeforeRouteLeave(async () => {
:close="false"
/>
</ProfileCard>
<DataExportCard />
<DangerZoneCard />
</template>
</div>
</DashboardShell>
+11 -2
View File
@@ -202,6 +202,15 @@ describe('Updated by lookup', () => {
expect(cells(rows()[2]!).at(-1)).toBe('Jonas Pereira')
})
it('names an updater the lookup does not return Deleted user', async () => {
await mountList('/content/events', {
list: (_, query) => listPage([{ ...eventEntries[0]!, updated_by: 9 }], 1, query),
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
})
expect(cells(rows()[0]!).at(-1)).toBe('Deleted user')
})
it('sends no lookup before the entries answer', async () => {
const gate = pending()
await mountList('/content/events', { list: () => gate.promise })
@@ -228,11 +237,11 @@ describe('Updated by lookup', () => {
expect(main().querySelector('[role="alert"]')).toBeNull()
})
it('shows a dash for an id the lookup does not know', async () => {
it('names an id the lookup does not know Deleted user', async () => {
await mountList('/content/events', { lookup: () => lookupNames([3]) })
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
expect(cells(rows()[1]!).at(-1)).toBe('–')
expect(cells(rows()[1]!).at(-1)).toBe('Deleted user')
})
it('leaves entries without an author out of the lookup', async () => {
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import EmailConfirmView from '../EmailConfirmView.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const CONFIRM_PATH = '/v1/auth/email/confirm'
const LOGIN_PATH = '/v1/auth/login'
const NEW = 'marta.alves@atelier-renew.example'
const LOGGED_IN: Session = {
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
type Handler = (init?: RequestInit) => Promise<unknown>
let mounted: VueWrapper | null = null
async function mountLanding(
path: string,
handlers: Record<string, Handler> = {},
session: Session | null = null,
) {
setActivePinia(createPinia())
if (session) {
const store = useSessionStore()
store.session = { ...session }
store.status = 'ready'
}
const request = vi.fn((requestPath: string, init?: RequestInit) => {
const handler = handlers[`${init?.method ?? 'GET'} ${requestPath}`]
if (!handler) {
throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${requestPath}`)
}
return handler(init)
})
const getMe = vi.fn(() =>
Promise.resolve({ verified: true, username: 'marta', email: NEW, displayName: '' }),
)
vi.mocked(useApi).mockReturnValue({ request, getMe } as unknown as ReturnType<typeof useApi>)
await router.push(path)
mounted = mount(EmailConfirmView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return { wrapper: mounted, request, getMe }
}
function confirmCalls(request: ReturnType<typeof vi.fn>) {
return request.mock.calls.filter(([path]) => path === CONFIRM_PATH)
}
function button(wrapper: VueWrapper, label: string) {
return wrapper.findAll('button').find((candidate) => candidate.text() === label)
}
beforeEach(() => {
toastAdd = vi.fn()
})
afterEach(async () => {
vi.useRealTimers()
mounted?.unmount()
mounted = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Confirming: the code is sent on load', () => {
it('shows the spinner copy and sends the code once', async () => {
const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: () => new Promise(() => {}),
})
expect(wrapper.find('[role="status"]').text()).toContain('Confirming your new email…')
expect(confirmCalls(request)).toHaveLength(1)
const [, init] = confirmCalls(request)[0]!
expect(JSON.parse((init as RequestInit).body as string)).toEqual({ code: 'abc123' })
})
})
describe('Success: 200 replaces the session email, toasts and goes to the profile', () => {
it('shows the new address, raises the toast, updates the session and navigates after 800 ms', async () => {
const { wrapper } = await mountLanding(
'/account/email/confirm?c=abc123',
{ [`POST ${CONFIRM_PATH}`]: () => Promise.resolve({ email: NEW }) },
LOGGED_IN,
)
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
expect(wrapper.text()).toContain(
`Your account now uses ${NEW}. Use it the next time you log in.`,
)
expect(button(wrapper, 'Go to profile')).toBeDefined()
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Email address changed',
description: `Your account now uses ${NEW}.`,
})
expect(useSessionStore().session?.email).toBe(NEW)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/profile'), {
timeout: 2000,
})
})
})
describe('Invalid or expired (403)', () => {
it('shows the dead-link copy and the profile action', async () => {
const { wrapper } = await mountLanding(
'/account/email/confirm?c=abc123',
{
[`POST ${CONFIRM_PATH}`]: () =>
Promise.reject(new ApiError(403, undefined, 'invalid_email_change_code')),
},
LOGGED_IN,
)
expect(wrapper.find('h1').text()).toBe("This link doesn't work")
expect(wrapper.text()).toContain(
'It may have expired (links last 2 hours), been cancelled or already been used.',
)
expect(wrapper.text()).toContain('Request a new link from your profile.')
await button(wrapper, 'Go to profile')!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
})
})
describe('Address unavailable (409)', () => {
it('shows one message whether the address is taken or rejected', async () => {
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: () =>
Promise.reject(new ApiError(409, undefined, 'email_unavailable')),
})
expect(wrapper.find('h1').text()).toBe("We couldn't change your email address")
expect(wrapper.text()).toContain(
"This address can't be used for your account. Go back to your profile and try a different one.",
)
expect(button(wrapper, 'Go to profile')).toBeDefined()
})
})
describe('No session (401): the inline log-in', () => {
it('shows the log-in form with the confirm copy', async () => {
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: () => Promise.reject(new ApiError(401)),
})
expect(wrapper.find('h1').text()).toBe('Log in to confirm your new email')
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.",
)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and confirm')
})
it('logs in, then sends the confirmation once with the same code and reaches Success', async () => {
const confirm = vi.fn()
confirm.mockRejectedValueOnce(new ApiError(401))
confirm.mockResolvedValueOnce({ email: NEW })
const { wrapper, request } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: confirm,
[`POST ${LOGIN_PATH}`]: () => Promise.resolve({}),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(confirm).toHaveBeenCalledTimes(2)
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
const bodies = confirmCalls(request).map(([, init]) => (init as RequestInit).body)
expect(bodies).toEqual([
JSON.stringify({ code: 'abc123' }),
JSON.stringify({ code: 'abc123' }),
])
expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm')
const [, loginInit] = request.mock.calls.find(([path]) => path === LOGIN_PATH)!
expect((loginInit as RequestInit).body).not.toContain('abc123')
})
})
describe('Missing code', () => {
it('sends no request and offers Go to log in', async () => {
const { wrapper, request } = await mountLanding('/account/email/confirm')
expect(wrapper.find('h1').text()).toBe('This link is incomplete')
expect(wrapper.text()).toContain(
'The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.',
)
expect(request).not.toHaveBeenCalled()
await button(wrapper, 'Go to log in')!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('Server error', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('after %s offers Try again, which sends the POST again', async (_label, failure) => {
const confirm = vi.fn()
confirm.mockImplementationOnce(failure)
confirm.mockResolvedValueOnce({ email: NEW })
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: confirm,
})
expect(wrapper.find('h1').text()).toBe("Couldn't confirm the change")
expect(wrapper.text()).toContain(SERVER_COPY)
await button(wrapper, 'Try again')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledTimes(2)
expect(wrapper.find('h1').text()).toBe('Your email address has changed')
})
})
describe('The code never stays in the URL', () => {
it('removes ?c= from the address while still confirming with it, and Try again reuses it', async () => {
const confirm = vi.fn()
confirm.mockRejectedValueOnce(new ApiError(500))
confirm.mockResolvedValueOnce({ email: NEW })
const { wrapper } = await mountLanding('/account/email/confirm?c=abc123', {
[`POST ${CONFIRM_PATH}`]: confirm,
})
expect(router.currentRoute.value.fullPath).toBe('/account/email/confirm')
expect(router.currentRoute.value.query).toEqual({})
await button(wrapper, 'Try again')!.trigger('click')
await flushPromises()
const bodies = confirm.mock.calls.map(([init]) => (init as RequestInit).body)
expect(bodies).toEqual([
JSON.stringify({ code: 'abc123' }),
JSON.stringify({ code: 'abc123' }),
])
})
})
@@ -119,13 +119,13 @@ describe('2 Editing', () => {
])
})
it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => {
it('names a creator the lookup does not return Deleted user and keeps the date', async () => {
await mountEditor('/content/events/42', {
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
})
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
'Created by Deleted user · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
)
})
+24
View File
@@ -265,6 +265,30 @@ describe('Password changed — arrived from a successful reset', () => {
})
})
describe('Account deleted — arrived from the delete dialog', () => {
it('shows the success notice once and removes the query', async () => {
const wrapper = await mountLogin('/login?reason=account-deleted', {})
expect(wrapper.text()).toContain('Your account has been deleted')
expect(wrapper.text()).toContain('Your articles and media were kept without your name.')
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.findComponent({ name: 'Alert' }).props('icon')).toBe('i-lucide-circle-check')
expect(router.currentRoute.value.fullPath).toBe('/login')
wrapper.unmount()
const reloaded = await mountLogin('/login', {})
expect(reloaded.text()).not.toContain('Your account has been deleted')
})
it('shows no notice for any other reason', async () => {
const wrapper = await mountLogin('/login?reason=anything-else', {})
expect(wrapper.text()).not.toContain('Your account has been deleted')
expect(wrapper.text()).not.toContain('Your articles and media were kept')
expect(router.currentRoute.value.query.reason).toBe('anything-else')
})
})
describe('Server or network error — the fields keep their values', () => {
it.each([
{
@@ -77,6 +77,7 @@ describe('Profile: the Author profile card sits between Account and Password', (
'Your role',
'Author profile',
'Password',
'Your data',
])
})
})
@@ -236,7 +237,7 @@ describe('Profile: editing the own author', () => {
)
expect(text).toContain('Save changes')
expect(text).not.toContain('Unlink')
expect(textOf(document.body)).not.toContain('Delete')
expect(text).not.toContain('Delete')
})
it('sends one PATCH with only the changed fields', async () => {
@@ -0,0 +1,242 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { downloadBlob } from '@/lib/downloadBlob'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/downloadBlob', () => ({ downloadBlob: vi.fn() }))
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const EXPORT_PATH = '/v1/user/me/export'
const FILENAME = 'renewcms-export-marta-2026-10-29.zip'
const BUTTON = 'Download my data'
const MARTA: Session = {
id: 1,
verified: true,
access: accessFor('noschema'),
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let mountedWrapper: VueWrapper | null = null
async function mountProfile(requestFile: () => Promise<unknown>) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = { ...MARTA }
store.status = 'ready'
const download = vi.fn(requestFile)
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(() => Promise.resolve({ ...MARTA })),
request: vi.fn(() => Promise.resolve([])),
requestFile: download,
} as unknown as ReturnType<typeof useApi>)
await router.push('/profile')
mountedWrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return download
}
function button(label: string) {
return Array.from(document.body.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.trim().includes(label),
) as HTMLButtonElement | undefined
}
async function click(target: HTMLElement | undefined) {
target!.click()
await flushPromises()
}
function text() {
return document.body.textContent ?? ''
}
function toastTexts() {
return toastAdd.mock.calls.map(([source]) => {
const content = typeof source === 'function' ? source() : source
return [content.title, content.description]
})
}
const archive = () =>
Promise.resolve({ blob: new Blob(['zip']), filename: FILENAME as string | undefined })
beforeEach(() => {
toastAdd = vi.fn()
vi.mocked(downloadBlob).mockReset()
localStorage.clear()
authRedirectPending.value = false
})
afterEach(async () => {
vi.useRealTimers()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Your data: Direct (recommended): default', () => {
it('follows the Password card and lists the four contents with the button and hint', async () => {
const download = await mountProfile(archive)
const content = text()
expect(content.indexOf('Password')).toBeLessThan(content.indexOf('Your data'))
expect(content).toContain(
'Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.',
)
for (const row of [
'Profile',
'Authored content',
'Your articles and entries.',
'Uploaded media',
'References to the files you uploaded.',
'Account activity',
'Your log-ins and the actions recorded under your name.',
'Username, display name, email, role and author profile.',
]) {
expect(content).toContain(row)
}
expect(button(BUTTON)!.disabled).toBe(false)
expect(content).toContain('Ready in a few seconds.')
expect(document.body.querySelector('[data-testid="data-export-notice"]')).toBeNull()
expect(download).not.toHaveBeenCalled()
})
})
describe('Your data: Direct: preparing', () => {
it('disables the button and shows Preparing download… while the request is open', async () => {
const pending = deferred()
const download = await mountProfile(() => pending.promise)
await click(button(BUTTON))
const preparing = button('Preparing download…')
expect(preparing).toBeDefined()
expect(preparing!.disabled).toBe(true)
expect(download).toHaveBeenCalledTimes(1)
await click(preparing)
expect(download).toHaveBeenCalledTimes(1)
})
})
describe('Your data: Direct: started', () => {
it('saves the file under the header name, toasts and returns to the default', async () => {
const pending = deferred()
await mountProfile(() => pending.promise)
await click(button(BUTTON))
pending.resolve({ blob: new Blob(['zip']), filename: FILENAME })
await flushPromises()
expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), FILENAME)
expect(toastTexts()).toEqual([['Download started', `${FILENAME} is in your downloads.`]])
expect(button(BUTTON)!.disabled).toBe(false)
expect(text()).toContain('Ready in a few seconds.')
})
})
describe('Your data: Direct: rate limited (429)', () => {
it('shows the notice and the countdown from Retry-After', async () => {
await mountProfile(() => Promise.reject(new ApiError(429, 720)))
await click(button(BUTTON))
expect(text()).toContain('You can export once every 15 minutes')
expect(text()).toMatch(/Your last export was at \d{2}:\d{2}\. Try again in 12 minutes\./)
const waiting = button('Available in 12:00')
expect(waiting).toBeDefined()
expect(waiting!.disabled).toBe(true)
expect(downloadBlob).not.toHaveBeenCalled()
})
it('enables the button again when the countdown ends', async () => {
vi.useFakeTimers()
await mountProfile(() => Promise.reject(new ApiError(429, 2)))
await click(button(BUTTON))
expect(button('Available in 0:02')).toBeDefined()
await vi.advanceTimersByTimeAsync(2000)
await flushPromises()
expect(button(BUTTON)!.disabled).toBe(false)
expect(text()).not.toContain('You can export once every 15 minutes')
})
})
describe('Your data: Direct: error', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the notice and a button to retry after %s', async (_name, failure) => {
let attempt = 0
const download = await mountProfile(() => (attempt++ === 0 ? failure() : archive()))
await click(button(BUTTON))
expect(text()).toContain("Couldn't prepare your data")
expect(text()).toContain('Nothing was changed. Try again in a moment.')
expect(button(BUTTON)!.disabled).toBe(false)
await click(button(BUTTON))
expect(download).toHaveBeenCalledTimes(2)
expect(text()).not.toContain("Couldn't prepare your data")
expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), FILENAME)
})
it('shows no local copy for a 401 or 403, which the client handles globally', async () => {
await mountProfile(() => Promise.reject(new ApiError(403)))
await click(button(BUTTON))
expect(text()).not.toContain("Couldn't prepare your data")
expect(button(BUTTON)!.disabled).toBe(false)
})
})
describe('Your data: export path', () => {
it('asks the API client for the export route', async () => {
const requestFile = vi.fn(archive)
const { useProfile } = await import('@/composables/useProfile')
vi.mocked(useApi).mockReturnValue({ requestFile } as unknown as ReturnType<typeof useApi>)
await useProfile().downloadMyData()
expect(requestFile).toHaveBeenCalledWith(EXPORT_PATH)
})
})
@@ -0,0 +1,514 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import {
ApiError,
NetworkError,
authRedirectPending,
redirectToLogin,
useApi,
} from '@/composables/useApi'
import { accountDeletedRedirect } from '@/lib/accountDeletion'
import { downloadBlob } from '@/lib/downloadBlob'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/downloadBlob', () => ({ downloadBlob: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
const MARTA: Session = {
id: 7,
verified: true,
access: accessFor('noschema'),
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
const PASSWORD = 'limewash2026'
const CONFIRM = 'Delete my account'
const KEEP = 'Keep my account'
const WRONG_PASSWORD = "That password isn't correct. Try again."
const RATE_LIMITED = 'Too many requests. Try again in a few minutes.'
const SERVER_ERROR = "Couldn't delete your account. Nothing was deleted. Try again in a moment."
const BLOCKED_TITLE = "You can't delete your account yet"
const ADMIN_BODY =
'You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.'
const OWNER_BODY =
'You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.'
const PROTECTED_TITLE = "This account can't be deleted"
const PROTECTED_BODY =
"RenewCMS can't delete this account right now. Ask an administrator to help, then try again."
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
type DeleteHandler = (body: { current_password: string; confirmation: string }) => Promise<unknown>
let mountedWrapper: VueWrapper | null = null
let request: ReturnType<typeof vi.fn>
let requestFile: ReturnType<typeof vi.fn>
async function mountProfile(handler: DeleteHandler = () => Promise.resolve(undefined)) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = { ...MARTA, access: accessFor('owner') }
store.status = 'ready'
request = vi.fn((path: string, init?: RequestInit) => {
if (path === '/v1/user/me' && init?.method === 'DELETE') {
return handler(JSON.parse(init.body as string))
}
return Promise.resolve([])
})
requestFile = vi.fn(() =>
Promise.resolve({ blob: new Blob(['zip']), filename: 'renewcms-export.zip' }),
)
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(() => Promise.resolve({ ...MARTA })),
request,
requestFile,
} as unknown as ReturnType<typeof useApi>)
await router.push('/profile')
mountedWrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function deleteCalls() {
return request.mock.calls.filter(
([path, init]) => path === '/v1/user/me' && init?.method === 'DELETE',
)
}
function dialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
function buttonIn(root: ParentNode, label: string) {
return Array.from(root.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.trim().includes(label),
) as HTMLButtonElement | undefined
}
function confirmButton() {
return buttonIn(dialog()!, CONFIRM)!
}
function passwordInput() {
return dialog()!.querySelector<HTMLInputElement>('input[type="password"]')!
}
function wordInput() {
return dialog()!.querySelector<HTMLInputElement>('input[autocomplete="off"]')!
}
async function click(target: HTMLElement | undefined) {
target!.click()
await flushPromises()
}
async function fill(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function openDialog() {
await click(buttonIn(document.body, CONFIRM))
}
async function fillBoth(password = PASSWORD, word = 'DELETE') {
await fill(passwordInput(), password)
await fill(wordInput(), word)
}
async function submit(password = PASSWORD, word = 'DELETE') {
await openDialog()
await fillBoth(password, word)
await click(confirmButton())
}
function dialogText() {
return dialog()?.textContent ?? ''
}
function refusal(status: number, body: string, code?: string, lastRole?: string) {
return () =>
Promise.reject(new ApiError(status, undefined, body, { code: code as never, lastRole }))
}
beforeEach(() => {
vi.mocked(downloadBlob).mockReset()
localStorage.clear()
authRedirectPending.value = false
accountDeletedRedirect.value = false
})
afterEach(async () => {
vi.restoreAllMocks()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Delete account: Danger zone', () => {
it('is the last card with the title, the text and the button, and sends no request', async () => {
await mountProfile()
const card = document.body.querySelector('[data-testid="danger-zone"]')!
expect(card.textContent).toContain('Danger zone')
expect(card.textContent).toContain(
"Delete your account and your personal data. This can't be undone.",
)
expect(buttonIn(card, CONFIRM)).toBeDefined()
const cards = Array.from(
document.body.querySelectorAll('section, [data-testid="danger-zone"]'),
)
expect(cards[cards.length - 1]).toBe(card)
expect(deleteCalls()).toHaveLength(0)
expect(dialog()).toBeNull()
})
})
describe('Delete account: the dialog', () => {
it('shows the design content', async () => {
await mountProfile()
await openDialog()
const content = dialogText()
for (const copy of [
'Delete your account?',
"This permanently deletes your account. It can't be undone.",
'Your username, display name and email address',
'Your author profile and API keys',
"Your sessions: you'll be logged out everywhere",
'Your articles and media stay',
'They stay on the site, credited to "Deleted user". Your name is removed from them.',
'Export my data first',
'Current password',
'Type DELETE to confirm',
KEEP,
CONFIRM,
]) {
expect(content).toContain(copy)
}
expect(dialog()!.querySelector('[aria-label="Close"]')).not.toBeNull()
})
it('Confirm: empty keeps Delete my account disabled', async () => {
await mountProfile()
await openDialog()
expect(confirmButton().disabled).toBe(true)
})
it('Confirm: typing keeps it disabled with a password and a partial word', async () => {
await mountProfile()
await openDialog()
await fillBoth(PASSWORD, 'DEL')
expect(confirmButton().disabled).toBe(true)
})
it('Confirm: ready enables it for exactly DELETE and a password', async () => {
await mountProfile()
await openDialog()
await fillBoth(PASSWORD, 'DELETE')
expect(confirmButton().disabled).toBe(false)
})
it.each(['delete', 'Delete', 'DELETE '])('keeps it disabled for the word %j', async (word) => {
await mountProfile()
await openDialog()
await fillBoth(PASSWORD, word)
expect(confirmButton().disabled).toBe(true)
})
it('keeps it disabled without a password', async () => {
await mountProfile()
await openDialog()
await fillBoth('', 'DELETE')
expect(confirmButton().disabled).toBe(true)
})
it('Keep my account closes the dialog and clears the fields', async () => {
await mountProfile()
await openDialog()
await fillBoth()
await click(buttonIn(dialog()!, KEEP))
await openDialog()
expect(passwordInput().value).toBe('')
expect(wordInput().value).toBe('')
})
it('sends the password and the typed word', async () => {
await mountProfile()
await submit()
expect(deleteCalls()).toHaveLength(1)
expect(JSON.parse(deleteCalls()[0]![1].body)).toEqual({
current_password: PASSWORD,
confirmation: 'DELETE',
})
})
})
describe('Delete account: refusals', () => {
it('Wrong password (403) shows the message, clears and focuses the password and keeps the word', async () => {
await mountProfile(refusal(403, 'wrong_password', 'wrong_password'))
await submit()
expect(dialogText()).toContain(WRONG_PASSWORD)
expect(passwordInput().value).toBe('')
expect(passwordInput()).toBe(document.activeElement)
expect(wordInput().value).toBe('DELETE')
})
it('Deleting shows the pending label and locks everything', async () => {
const pending = deferred()
await mountProfile(() => pending.promise)
await submit()
expect(buttonIn(dialog()!, 'Deleting…')).toBeDefined()
expect(passwordInput().disabled).toBe(true)
expect(wordInput().disabled).toBe(true)
expect(buttonIn(dialog()!, KEEP)!.disabled).toBe(true)
expect(dialog()!.querySelector('[aria-label="Close"]')).toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(dialog()).not.toBeNull()
pending.resolve(undefined)
await flushPromises()
})
it('Rate limited (429) shows the message and keeps Confirm enabled', async () => {
await mountProfile(() => Promise.reject(new ApiError(429, 300)))
await submit()
expect(dialogText()).toContain(RATE_LIMITED)
expect(confirmButton().disabled).toBe(false)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('Server error (%s) shows the message and keeps the values', async (_name, handler) => {
await mountProfile(handler)
await submit()
expect(dialogText()).toContain(SERVER_ERROR)
expect(passwordInput().value).toBe(PASSWORD)
expect(wordInput().value).toBe('DELETE')
expect(confirmButton().disabled).toBe(false)
})
})
describe('Delete account: blocked dialogs', () => {
it('Blocked: last administrator shows the copy and both next steps', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
const content = dialogText()
expect(content).toContain(BLOCKED_TITLE)
expect(content).toContain(ADMIN_BODY)
expect(buttonIn(dialog()!, 'Back to profile')).toBeDefined()
expect(buttonIn(dialog()!, 'Go to Users')).toBeDefined()
expect(content).not.toContain('Current password')
})
it('Blocked: last Owner names the Owner and the transfer', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Owner'))
await submit()
expect(dialogText()).toContain(BLOCKED_TITLE)
expect(dialogText()).toContain(OWNER_BODY)
expect(buttonIn(dialog()!, 'Go to Users')).toBeDefined()
})
it('Blocked: cannot be deleted offers only Back to profile', async () => {
await mountProfile(refusal(409, 'account_not_deletable', 'account_not_deletable'))
await submit()
expect(dialogText()).toContain(PROTECTED_TITLE)
expect(dialogText()).toContain(PROTECTED_BODY)
expect(buttonIn(dialog()!, 'Back to profile')).toBeDefined()
expect(buttonIn(dialog()!, 'Go to Users')).toBeUndefined()
})
function pressEscape() {
const target = dialog() ?? document.body
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
function clickBackdrop() {
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
document.body.click()
}
it.each([
['Escape', pressEscape],
['a click on the backdrop', clickBackdrop],
])('cannot be dismissed with %s and shows no close button', async (_name, dismiss) => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
dismiss()
await flushPromises()
expect(dialog()).not.toBeNull()
expect(dialog()!.querySelector('[aria-label="Close"]')).toBeNull()
})
it('the regular dialog does close with Escape, so the dismissal helper is sound', async () => {
await mountProfile()
await openDialog()
pressEscape()
await flushPromises()
expect(dialog()).toBeNull()
})
it('Back to profile closes the dialog', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
await click(buttonIn(dialog()!, 'Back to profile'))
expect(dialog()).toBeNull()
expect(router.currentRoute.value.path).toBe('/profile')
})
it('Go to Users navigates to /users', async () => {
await mountProfile(refusal(409, 'last_admin', 'last_admin', 'Admin'))
await submit()
await click(buttonIn(dialog()!, 'Go to Users'))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/users'))
})
})
describe('Delete account: Export my data first', () => {
it('starts the download without closing the dialog', async () => {
await mountProfile()
await openDialog()
await click(buttonIn(dialog()!, 'Export my data first'))
expect(requestFile).toHaveBeenCalledWith('/v1/user/me/export')
expect(downloadBlob).toHaveBeenCalled()
expect(dialog()).not.toBeNull()
expect(deleteCalls()).toHaveLength(0)
})
})
describe('Delete account: success', () => {
it('clears the session without logging out and goes to /login?reason=account-deleted', async () => {
const push = vi.spyOn(router, 'push')
await mountProfile()
await submit()
expect(request.mock.calls.some(([path]) => path === '/v1/auth/logout')).toBe(false)
expect(useSessionStore().session).toBeNull()
expect(push).toHaveBeenCalledWith({ path: '/login', query: { reason: 'account-deleted' } })
})
it('does not open the leave-page confirmation of Profile', async () => {
await mountProfile()
const name = mountedWrapper!.find('input[placeholder="Your username"], input[type="text"]')
await name.setValue('Marta A.')
await submit()
expect(document.body.textContent).not.toContain('Leave without saving?')
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('Delete account: sessions that end during the call', () => {
it('sends the deletion without the automatic login redirect', async () => {
await mountProfile()
await submit()
expect(deleteCalls()[0]![1].onUnauthorized).toBe('passthrough')
})
it('goes to the expired-session login when the deletion answers 401', async () => {
await mountProfile(() => Promise.reject(new ApiError(401)))
await submit()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query.reason).toBe('expired')
})
it('keeps the account-deleted login when a stray 401 redirect fires afterwards', async () => {
await mountProfile()
await submit()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await flushPromises()
redirectToLogin()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/login')
expect(document.body.textContent).toContain('Your account has been deleted')
})
it('survives a navigation failure after the deletion', async () => {
await mountProfile()
const push = vi.spyOn(router, 'push').mockRejectedValue(new Error('navigation failed'))
await submit()
expect(push).toHaveBeenCalled()
expect(useSessionStore().session).toBeNull()
expect(authRedirectPending.value).toBe(false)
})
})
@@ -0,0 +1,410 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const OLD = 'marta@renew.example'
const NEW = 'marta.alves@atelier-renew.example'
const CHANGE_PATH = '/v1/auth/email/change'
const RESEND_PATH = '/v1/auth/email/resend'
const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
const RATE_COPY = 'Too many requests. Try again in a few minutes.'
const MARTA: Session = {
id: 1,
verified: true,
access: accessFor('noschema'),
username: 'marta',
email: OLD,
displayName: '',
}
type Handler = (init?: RequestInit) => Promise<unknown>
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let mountedWrapper: VueWrapper | null = null
async function mountProfile(session: Session = MARTA, handlers: Record<string, Handler> = {}) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = { ...session }
store.status = 'ready'
const request = vi.fn((path: string, init?: RequestInit) => {
const handler = handlers[`${init?.method ?? 'GET'} ${path}`]
return handler ? handler(init) : Promise.resolve([])
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(() => Promise.resolve({ ...session })),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push('/profile')
mountedWrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return { request, store }
}
function button(label: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined
}
function field(label: string) {
const labelEl = Array.from(document.body.querySelectorAll('label')).find(
(candidate) => candidate.textContent?.trim() === label,
)
return document.getElementById(labelEl!.getAttribute('for')!) as HTMLInputElement
}
async function type(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function click(target: HTMLElement | undefined) {
target!.click()
await flushPromises()
}
async function openForm() {
await click(button('Change email'))
}
async function fillAndSend(email: string, password: string) {
await type(field('New email address'), email)
await type(field('Current password'), password)
await click(button('Send verification link'))
}
function text() {
return document.body.textContent ?? ''
}
function posts(request: ReturnType<typeof vi.fn>, path: string) {
return request.mock.calls.filter(
([requestPath, init]) =>
requestPath === path && (init as RequestInit | undefined)?.method === 'POST',
)
}
function toastTexts() {
return toastAdd.mock.calls.map(([source]) => {
const content = typeof source === 'function' ? source() : source
return [content.title, content.description]
})
}
const sent = () => Promise.resolve({ pending_email: NEW })
beforeEach(() => {
toastAdd = vi.fn()
localStorage.clear()
authRedirectPending.value = false
})
afterEach(async () => {
vi.useRealTimers()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Change email: Default', () => {
it('shows the Email field and the Change email link, no form and no request', async () => {
const { request } = await mountProfile()
expect(field('Email').value).toBe(OLD)
expect(button('Change email')).toBeDefined()
expect(document.body.querySelector('[data-testid="pending-email-notice"]')).toBeNull()
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Form open', () => {
it('shows both fields, the hint and both buttons with focus on the email field', async () => {
await mountProfile()
await openForm()
expect(text()).toContain('New email address')
expect(text()).toContain('Current password')
expect(text()).toContain(
"We'll send a verification link to the new address. Your email stays the same until you open it.",
)
expect(button('Cancel')).toBeDefined()
expect(button('Send verification link')).toBeDefined()
expect(button('Change email')).toBeUndefined()
expect(document.activeElement).toBe(field('New email address'))
})
it('Cancel closes the form and brings the link back', async () => {
await mountProfile()
await openForm()
await click(button('Cancel'))
expect(button('Change email')).toBeDefined()
expect(button('Send verification link')).toBeUndefined()
})
})
describe('Change email: Invalid format', () => {
it('flags a malformed address on blur and sends nothing', async () => {
const { request } = await mountProfile()
await openForm()
const input = field('New email address')
await type(input, 'marta.alves@atelier')
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
input.dispatchEvent(new FocusEvent('blur'))
await flushPromises()
expect(text()).toContain('Enter a full email address, like name@example.com.')
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
it('flags it on submit', async () => {
const { request } = await mountProfile()
await openForm()
await fillAndSend('marta.alves@atelier', 'limewash2026')
expect(text()).toContain('Enter a full email address, like name@example.com.')
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Same as current', () => {
it('refuses the current address, whatever its case, before any request', async () => {
const { request } = await mountProfile()
await openForm()
await fillAndSend('Marta@Renew.example', 'limewash2026')
expect(text()).toContain("That's already your email address.")
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Password missing', () => {
it('asks for the password and focuses it without sending', async () => {
const { request } = await mountProfile()
await openForm()
await fillAndSend(NEW, '')
expect(text()).toContain('Enter your current password.')
expect(document.activeElement).toBe(field('Current password'))
expect(posts(request, CHANGE_PATH)).toHaveLength(0)
})
})
describe('Change email: Wrong password (403)', () => {
it('shows the error under the password, clears and focuses it, after one POST', async () => {
const { request } = await mountProfile(MARTA, {
[`POST ${CHANGE_PATH}`]: () =>
Promise.reject(new ApiError(403, undefined, 'wrong_password')),
})
await openForm()
await fillAndSend(NEW, 'nope')
expect(text()).toContain("That password isn't correct. Try again.")
expect(field('Current password').value).toBe('')
expect(document.activeElement).toBe(field('Current password'))
expect(field('New email address').value).toBe(NEW)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
})
})
describe('Change email: Sending', () => {
it('disables the fields and the button and reads Sending… while the POST is open', async () => {
const pending = deferred()
const { request } = await mountProfile(MARTA, {
[`POST ${CHANGE_PATH}`]: () => pending.promise,
})
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(button('Sending…')!.disabled).toBe(true)
expect(field('New email address').disabled).toBe(true)
expect(field('Current password').disabled).toBe(true)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
const [, init] = posts(request, CHANGE_PATH)[0]!
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
new_email: NEW,
current_password: 'limewash2026',
})
})
})
describe('Change email: Rate limited (429)', () => {
it('shows the rate copy, keeps the form and disables Send verification link', async () => {
const { request } = await mountProfile(MARTA, {
[`POST ${CHANGE_PATH}`]: () => Promise.reject(new ApiError(429, 120)),
})
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(text()).toContain(RATE_COPY)
expect(button('Send verification link')!.disabled).toBe(true)
expect(field('New email address').value).toBe(NEW)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
})
})
describe('Change email: Server or network error', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('after %s shows the copy and keeps the typed values', async (_label, failure) => {
const { request } = await mountProfile(MARTA, { [`POST ${CHANGE_PATH}`]: failure })
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(text()).toContain(SERVER_COPY)
expect(field('New email address').value).toBe(NEW)
expect(field('Current password').value).toBe('limewash2026')
expect(button('Send verification link')!.disabled).toBe(false)
expect(posts(request, CHANGE_PATH)).toHaveLength(1)
})
})
describe('Change email: Pending', () => {
it('closes the form, keeps the old email and shows the pending card and a toast', async () => {
await mountProfile(MARTA, { [`POST ${CHANGE_PATH}`]: sent })
await openForm()
await fillAndSend(NEW, 'limewash2026')
expect(button('Send verification link')).toBeUndefined()
expect(field('Email').value).toBe(OLD)
expect(text()).toContain('Waiting for verification')
expect(text()).toContain(
`We sent a link to ${NEW}. Until you open it, you keep logging in with ${OLD}.`,
)
expect(button('Resend link')).toBeDefined()
expect(button('Cancel change')).toBeDefined()
expect(button('Change email')).toBeUndefined()
expect(toastTexts()).toEqual([
['Verification link sent', `Check ${NEW}. The link works once, within 2 hours.`],
])
})
it('shows the same card after a reload with pending_email set, without a toast', async () => {
await mountProfile({ ...MARTA, pendingEmail: NEW })
expect(text()).toContain('Waiting for verification')
expect(text()).toContain(NEW)
expect(toastAdd).not.toHaveBeenCalled()
})
})
describe('Change email: Pending: resending, resent', () => {
it('shows Sending… with the button disabled while the resend is in flight', async () => {
const pending = deferred()
const { request } = await mountProfile(
{ ...MARTA, pendingEmail: NEW },
{ [`POST ${RESEND_PATH}`]: () => pending.promise },
)
await click(button('Resend link'))
expect(button('Sending…')!.disabled).toBe(true)
expect(posts(request, RESEND_PATH)).toHaveLength(1)
})
it('raises the resent toast on a 200', async () => {
await mountProfile({ ...MARTA, pendingEmail: NEW }, { [`POST ${RESEND_PATH}`]: sent })
await click(button('Resend link'))
expect(button('Resend link')!.disabled).toBe(false)
expect(toastTexts()).toEqual([['Link sent again', 'The previous link no longer works.']])
})
})
describe('Change email: Pending: resend rate limited (429)', () => {
it('counts down from Retry-After, not the wall clock', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
await mountProfile(
{ ...MARTA, pendingEmail: NEW },
{ [`POST ${RESEND_PATH}`]: () => Promise.reject(new ApiError(429, 270)) },
)
await click(button('Resend link'))
expect(button('Resend in 4:30')!.disabled).toBe(true)
expect(text()).toContain(RATE_COPY)
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(button('Resend in 4:00')).toBeDefined()
vi.advanceTimersByTime(240_000)
await flushPromises()
expect(button('Resend link')!.disabled).toBe(false)
expect(text()).not.toContain(RATE_COPY)
})
})
describe('Change email: Cancelled', () => {
it('deletes without confirmation, returns to Default and raises the cancelled toast', async () => {
const { request } = await mountProfile(
{ ...MARTA, pendingEmail: NEW },
{ [`DELETE ${CHANGE_PATH}`]: () => Promise.resolve(undefined) },
)
await click(button('Cancel change'))
expect(
request.mock.calls.filter(
([path, init]) =>
path === CHANGE_PATH && (init as RequestInit | undefined)?.method === 'DELETE',
),
).toHaveLength(1)
expect(button('Change email')).toBeDefined()
expect(text()).not.toContain('Waiting for verification')
expect(toastTexts()).toEqual([
['Email change cancelled', `You keep logging in with ${OLD}.`],
])
})
})
+2 -1
View File
@@ -312,7 +312,7 @@ describe('Default: the usual first visit with no display name', () => {
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the cards in order Display name, Account, Your role, Author profile, Password', async () => {
it('places the cards in order Display name, Account, Your role, Author profile, Password, Your data', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
@@ -324,6 +324,7 @@ describe('Default: the usual first visit with no display name', () => {
'Your role',
'Author profile',
'Password',
'Your data',
])
})
})
+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': {