feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 = ''
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { namesWithDeletedFallback } from '@/lib/deletedUser'
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('namesWithDeletedFallback', () => {
|
||||
const found = [{ id: 2, display_name: 'Jonas Weber' }]
|
||||
|
||||
it('names an id that the lookup did not return Deleted user', () => {
|
||||
expect(namesWithDeletedFallback([2, 7], found)).toEqual({
|
||||
2: 'Jonas Weber',
|
||||
7: 'Deleted user',
|
||||
})
|
||||
})
|
||||
|
||||
it('speaks French when the language is French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(namesWithDeletedFallback([7], [])).toEqual({ 7: 'Utilisateur supprimé' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { downloadBlob } from '@/lib/downloadBlob'
|
||||
|
||||
describe('downloadBlob', () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:export')
|
||||
const revokeObjectURL = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
createObjectURL.mockClear()
|
||||
revokeObjectURL.mockClear()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('clicks an anchor carrying the file name and removes it', () => {
|
||||
const clicked: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
clicked.push(this)
|
||||
})
|
||||
|
||||
downloadBlob(new Blob(['zip']), 'export.zip')
|
||||
|
||||
expect(clicked).toHaveLength(1)
|
||||
expect(clicked[0]!.download).toBe('export.zip')
|
||||
expect(clicked[0]!.href).toBe('blob:export')
|
||||
expect(document.body.contains(clicked[0]!)).toBe(false)
|
||||
})
|
||||
|
||||
it('revokes the object URL only after a delay so the download is not cancelled', () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
downloadBlob(new Blob(['zip']), 'export.zip')
|
||||
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:export')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const DELETE_WORD = 'DELETE'
|
||||
export const ACCOUNT_DELETED_REASON = 'account-deleted'
|
||||
|
||||
export const accountDeletedRedirect = ref(false)
|
||||
@@ -99,6 +99,30 @@ const CATALOGUE: Record<string, Template> = {
|
||||
success: 'Updated the profile of {0}',
|
||||
failed: 'Failed to update the profile of {0}',
|
||||
},
|
||||
'user.request_email_change': {
|
||||
success: 'Requested an email change for {0}',
|
||||
failed: 'Failed to request an email change for {0}',
|
||||
},
|
||||
'user.resend_email_change': {
|
||||
success: 'Resent the email change link for {0}',
|
||||
failed: 'Failed to resend the email change link for {0}',
|
||||
},
|
||||
'user.cancel_email_change': {
|
||||
success: 'Cancelled the email change for {0}',
|
||||
failed: 'Failed to cancel the email change for {0}',
|
||||
},
|
||||
'user.confirm_email_change': {
|
||||
success: 'Confirmed the email change for {0}',
|
||||
failed: 'Failed to confirm the email change for {0}',
|
||||
},
|
||||
'user.export_data': {
|
||||
success: 'Exported the personal data of {0}',
|
||||
failed: 'Failed to export the personal data of {0}',
|
||||
},
|
||||
'user.delete_account': {
|
||||
success: 'Deleted the account of {0}',
|
||||
failed: 'Failed to delete the account of {0}',
|
||||
},
|
||||
'invite.create': { success: 'Invited {0}', failed: 'Failed to invite {0}' },
|
||||
'invite.cancel': {
|
||||
success: 'Cancelled the invite for {0}',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const KNOWN_ERROR_CODES = [
|
||||
'user_not_found',
|
||||
'email_has_account',
|
||||
'invite_pending',
|
||||
'wrong_password',
|
||||
'last_admin',
|
||||
'account_not_deletable',
|
||||
] as const
|
||||
|
||||
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
|
||||
@@ -28,6 +31,7 @@ export interface ApiErrorDetails {
|
||||
code?: ApiErrorCode
|
||||
permission?: string
|
||||
escalationKey?: string
|
||||
lastRole?: string
|
||||
}
|
||||
|
||||
const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
|
||||
@@ -50,6 +54,7 @@ export class ApiError extends Error {
|
||||
code?: ApiErrorCode
|
||||
permission?: string
|
||||
escalationKey?: string
|
||||
lastRole?: string
|
||||
|
||||
constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) {
|
||||
super(`Request failed with status ${status}`)
|
||||
@@ -59,5 +64,6 @@ export class ApiError extends Error {
|
||||
this.code = details.code
|
||||
this.permission = details.permission
|
||||
this.escalationKey = details.escalationKey
|
||||
this.lastRole = details.lastRole
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const QUOTED_FILENAME = /filename\s*=\s*"((?:[^"\\]|\\.)*)"/i
|
||||
const BARE_FILENAME = /filename\s*=\s*([^;\s]+)/i
|
||||
|
||||
export function filenameFromDisposition(header: string | null): string | undefined {
|
||||
if (!header) {
|
||||
return undefined
|
||||
}
|
||||
const quoted = QUOTED_FILENAME.exec(header)
|
||||
if (quoted) {
|
||||
return quoted[1]!.replace(/\\(.)/g, '$1')
|
||||
}
|
||||
return BARE_FILENAME.exec(header)?.[1]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { UserName } from '@/lib/contentTypes'
|
||||
|
||||
export const DELETED_USER_NAME = 'Deleted user'
|
||||
|
||||
export function namesWithDeletedFallback(ids: number[], found: UserName[]) {
|
||||
const known = new Map(found.map((user) => [user.id, user.display_name]))
|
||||
return Object.fromEntries(ids.map((id) => [id, known.get(id) ?? t(DELETED_USER_NAME)]))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const REVOKE_DELAY_MS = 1000
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const NEW_EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
|
||||
export const WRONG_PASSWORD_BODY = 'wrong_password'
|
||||
export const RATE_LIMIT_FALLBACK_SECONDS = 300
|
||||
|
||||
export function isSameEmail(candidate: string, current: string | undefined) {
|
||||
return candidate.trim().toLowerCase() === (current ?? '').trim().toLowerCase()
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}.`],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user