fix: visuals reference matching overhaul and better workflow

This commit is contained in:
fsylvain committed 2026-09-26 21:04:51 +02:00
1 parent 930b836241
commit d493a181e1
25 files changed
+461 -241

No files matched your search

+1 -7
View File
@@ -226,13 +226,7 @@ onMounted(loadArticles)
icon="i-lucide-circle-x"
title="Couldn't load your articles"
description="Check your connection and try again."
:ui="{
root: 'gap-2.5 px-3.5 py-3',
icon: 'mt-0.5 size-4',
title: 'text-sm font-semibold',
description: 'mt-0.5 text-sm text-toned opacity-100',
actions: 'mt-2',
}"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
+26 -20
View File
@@ -11,7 +11,6 @@ interface AlertState {
icon: string
title: string
description: string
closable: boolean
}
const wrongCredentialsAlert: AlertState = {
@@ -19,7 +18,6 @@ const wrongCredentialsAlert: AlertState = {
icon: 'i-lucide-circle-x',
title: "That username and password don't match",
description: 'Check both and try again. Passwords are case-sensitive.',
closable: false,
}
const tooManyAttemptsAlert: AlertState = {
@@ -27,7 +25,6 @@ const tooManyAttemptsAlert: AlertState = {
icon: 'i-lucide-timer',
title: 'Too many attempts',
description: 'For your security, wait a moment before trying again.',
closable: false,
}
const serverErrorAlert: AlertState = {
@@ -35,7 +32,6 @@ const serverErrorAlert: AlertState = {
icon: 'i-lucide-circle-x',
title: "Couldn't reach RenewCMS",
description: 'Check your connection and try again.',
closable: false,
}
const sessionExpiredAlert: AlertState = {
@@ -44,7 +40,6 @@ const sessionExpiredAlert: AlertState = {
title: 'Your session expired',
description:
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
closable: true,
}
const passwordChangedAlert: AlertState = {
@@ -52,7 +47,6 @@ const passwordChangedAlert: AlertState = {
icon: 'i-lucide-circle-check',
title: 'Password changed',
description: 'Log in with your new password.',
closable: true,
}
const route = useRoute()
@@ -205,14 +199,13 @@ onMounted(async () => {
:title="alert.title"
:description="alert.description"
variant="soft"
:close="alert.closable"
:close="false"
:role="alertRole"
@close="alert = null"
/>
</template>
<template #default="{ controlSize, controlClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4" @submit="onSubmit">
<template #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UFormField
label="Username"
name="username"
@@ -221,7 +214,10 @@ onMounted(async () => {
>
<template v-if="usernameError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
@@ -229,12 +225,12 @@ onMounted(async () => {
v-model="username"
placeholder="Your username"
autocomplete="username"
:disabled="submitting"
:disabled="submitDisabled"
variant="outline"
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
/>
</UFormField>
@@ -255,7 +251,10 @@ onMounted(async () => {
</template>
<template v-if="passwordError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
@@ -263,12 +262,15 @@ onMounted(async () => {
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="submitting"
:disabled="submitDisabled"
variant="outline"
color="primary"
:highlight="!!passwordError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
>
<template #trailing>
@@ -278,6 +280,7 @@ onMounted(async () => {
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
@@ -300,12 +303,15 @@ onMounted(async () => {
</template>
<template v-if="registrationOpen" #footer>
<p>
<span class="flex flex-wrap items-center gap-1.5">
First time here?
<RouterLink to="/register" class="text-primary"
>Create the owner account</RouterLink
<RouterLink
to="/register"
class="font-medium text-highlighted underline underline-offset-3"
>
</p>
Create the owner account
</RouterLink>
</span>
</template>
</AuthShell>
</template>
+15 -2
View File
@@ -7,6 +7,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
import { useSessionStore } from '@/stores/session'
const router = useRouter()
@@ -18,6 +19,7 @@ const toast = useToast()
const pendingEmail = ref<string | null>(null)
const loggingOut = ref(false)
const cancelOutcome = ref<'cancelled' | 'alreadyVerified' | null>(null)
const verified = ref(false)
onMounted(() => {
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
@@ -88,13 +90,15 @@ async function cancelRegistration() {
}
if (cancelOutcome.value === 'alreadyVerified') {
await router.push('/home')
verified.value = true
toast.add({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
await wait(VERIFIED_REDIRECT_DELAY_MS)
await router.push('/home')
}
}
@@ -119,13 +123,22 @@ const actions = computed(() => [
<template>
<AuthShell title="">
<ResultPanel
v-if="verified"
tone="success"
icon="i-lucide-shield-check"
title="You're verified"
description="Taking you to your dashboard…"
:actions="[]"
/>
<ResultPanel
v-else
tone="success"
icon="i-lucide-mail-check"
title="Check your email"
:description="description"
:actions="actions"
>
<p class="text-toned text-sm">
<p class="text-sm text-pretty text-muted">
Opened it on another device? That works too; you'll log in there first. Can't find
it? Check your spam folder. If the link has expired, cancel your registration and
start again.
+31 -11
View File
@@ -120,10 +120,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
/>
</template>
<template v-else #default="{ controlSize, controlClass }">
<template v-else #default="{ controlSize, controlClass, inputClass }">
<UForm
:state="{ username, email, password }"
class="flex flex-col gap-4"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
@@ -134,7 +134,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template v-if="usernameError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
@@ -142,7 +145,12 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
Letters and numbers, 3 to 20 characters. You'll use it to log in.
</template>
<template #hint>
<span :class="usernameError ? 'text-error' : 'text-toned'">
<span
:class="[
'font-mono text-xs',
usernameError ? 'text-error' : 'text-muted',
]"
>
{{ username.length }} / 20
</span>
</template>
@@ -155,7 +163,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
color="primary"
:highlight="!!usernameError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
@blur="usernameTouched = true"
/>
@@ -169,7 +177,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template v-if="emailError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon name="i-lucide-circle-alert" class="text-error shrink-0" />
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
@@ -184,7 +195,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
color="primary"
:highlight="!!emailError"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
@blur="emailTouched = true"
/>
@@ -205,7 +216,10 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
@blur="passwordTouched = true"
>
@@ -216,6 +230,7 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
@@ -238,10 +253,15 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</template>
<template v-if="!registrationClosed" #footer>
<p>
<span class="flex flex-wrap items-center gap-1.5">
Already set up?
<RouterLink to="/login" class="text-primary">Log in</RouterLink>
</p>
<RouterLink
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Log in
</RouterLink>
</span>
</template>
</AuthShell>
</template>
+63 -27
View File
@@ -7,6 +7,7 @@ import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
import { useSessionStore } from '@/stores/session'
type VerifyState =
@@ -52,8 +53,6 @@ const cancelFailedAlert: AlertState = {
description: 'Something went wrong on the server. Try again in a moment.',
}
const VERIFICATION_SUCCESS_DELAY_MS = 800
const route = useRoute()
const router = useRouter()
const api = useApi()
@@ -78,13 +77,10 @@ const shellDescription = computed(() =>
: undefined,
)
const loginSubmitDisabled = computed(
() =>
username.value.length === 0 ||
password.value.length === 0 ||
loginSubmitting.value ||
retryAfter.active.value,
)
const usernameError = ref('')
const passwordError = ref('')
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
@@ -96,10 +92,6 @@ const loginSubmitLabel = computed(() => {
return 'Log in and verify'
})
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function focusPassword() {
await nextTick()
await nextTick()
@@ -116,14 +108,14 @@ async function performVerify(code: string) {
onForbidden: 'passthrough',
})
state.value = 'success'
await Promise.all([session.retry(), wait(VERIFICATION_SUCCESS_DELAY_MS)])
await router.push('/home')
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Your account is verified',
description: 'Welcome to RenewCMS.',
})
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
await router.push('/home')
return
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
@@ -159,10 +151,19 @@ function handleLoginError(error: unknown) {
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 {
@@ -274,14 +275,15 @@ onMounted(() => {
/>
</template>
<template #default="{ controlSize, controlClass }">
<template #default="{ controlSize, controlClass, inputClass }">
<div
v-if="state === 'verifying' || state === 'noSessionRetrying'"
role="status"
class="flex flex-col items-center gap-4 py-10"
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon name="i-lucide-loader-2" class="h-8 w-8 animate-spin text-primary" />
<p class="text-toned text-sm">Verifying your account…</p>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">Verifying your account…</p>
<p class="text-base text-muted">This only takes a moment.</p>
</div>
<ResultPanel
@@ -301,7 +303,7 @@ onMounted(() => {
description="It may have expired (links last 2 hours) or already been used."
:actions="invalidActions"
>
<p class="text-toned text-sm">
<p class="text-sm text-pretty text-muted">
Cancel your registration and start over to get a new link.
</p>
</ResultPanel>
@@ -318,33 +320,66 @@ onMounted(() => {
<UForm
v-else-if="state === 'noSession'"
:state="{ username, password }"
class="flex flex-col gap-4"
class="flex flex-col gap-4.5"
@submit="onLoginSubmit"
>
<UFormField label="Username" name="username" :size="controlSize">
<UFormField
label="Username"
name="username"
:error="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"
/>
{{ error }}
</span>
</template>
<UInput
v-model="username"
:highlight="!!usernameError"
placeholder="Your username"
autocomplete="username"
:disabled="loginSubmitting"
:disabled="loginSubmitDisabled"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<UFormField
label="Password"
name="password"
:error="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"
/>
{{ error }}
</span>
</template>
<UInput
v-model="password"
:highlight="!!passwordError"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="loginSubmitting"
:disabled="loginSubmitDisabled"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
>
<template #trailing>
@@ -354,6 +389,7 @@ onMounted(() => {
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
@@ -199,6 +199,12 @@ describe('Too many attempts — the button is disabled with a live countdown', (
expect(button.text()).toBe('Try again in 0:42')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
const fields = () => [
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
]
expect(fields().map((field) => field.disabled)).toEqual([true, true])
await vi.advanceTimersByTimeAsync(42000)
await flushPromises()
@@ -206,6 +212,7 @@ describe('Too many attempts — the button is disabled with a live countdown', (
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect(fields().map((field) => field.disabled)).toEqual([false, false])
vi.useRealTimers()
})
})
+9 -2
View File
@@ -239,7 +239,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
})
})
describe('Already verified — 403 refreshes the session and navigates to /home with a toast', () => {
describe('Already verified — 403 shows the verified panel and toast, then navigates to /home', () => {
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': () => mockResponse(403),
@@ -263,13 +263,20 @@ describe('Already verified — 403 refreshes the session and navigates to /home
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
expect(wrapper.text()).toContain("You're verified")
expect(wrapper.text()).toContain('Taking you to your dashboard…')
expect(toastAdd).toHaveBeenCalledWith({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
expect(router.currentRoute.value.path).not.toBe('/home')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
})
+16 -14
View File
@@ -98,10 +98,6 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
expect(wrapper.text()).toContain('Your account is verified')
expect(wrapper.text()).toContain('Taking you to your dashboard…')
expect(router.currentRoute.value.path).toBe('/register/validate')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -109,6 +105,11 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
description: 'Welcome to RenewCMS.',
})
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
expect(session.session).toEqual({ verified: true })
})
@@ -233,22 +234,23 @@ describe('No session — the inline log-in form replaces the spinner', () => {
})
})
describe('No session — submit is disabled until both fields are filled', () => {
it('enables the submit button once both fields have a value', async () => {
describe('No session — empty fields are caught on submit, as on Log in', () => {
it('keeps the submit button enabled and shows both field errors without calling login', async () => {
const login = vi.fn(() => mockResponse(200))
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': login,
})
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
true,
)
const submit = wrapper.find('button[type="submit"]')
expect((submit.element as HTMLButtonElement).disabled).toBe(false)
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((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect(wrapper.text()).toContain('Use at least 3 characters.')
expect(wrapper.text()).toContain('Enter your password.')
expect(login).not.toHaveBeenCalled()
})
})