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

+10
View File
@@ -155,6 +155,11 @@
--ui-info: var(--ui-color-info-700);
--ui-warning: var(--ui-color-warning-700);
--ui-error: var(--ui-color-error-700);
--rc-success-soft: #eaefeb;
--rc-info-soft: #ebeef0;
--rc-warning-soft: #f0ede6;
--rc-error-soft: #f3eae9;
--rc-overlay: rgb(10 10 10 / 0.4);
}
.dark {
@@ -179,6 +184,11 @@
--ui-info: var(--ui-color-info-300);
--ui-warning: var(--ui-color-warning-300);
--ui-error: var(--ui-color-error-300);
--rc-success-soft: #202420;
--rc-info-soft: #202325;
--rc-warning-soft: #26221c;
--rc-error-soft: #28201e;
--rc-overlay: rgb(0 0 0 / 0.6);
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
}
+30 -17
View File
@@ -33,29 +33,38 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-sm'))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center',
belowSm.value ? 'bg-default' : 'bg-muted pt-[12vh]',
'flex min-h-screen flex-col items-center gap-5',
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardUi = computed(() => ({
root: belowSm.value ? 'ring-0' : '',
container: belowSm.value ? 'pt-2 px-5 pb-0' : 'p-10',
header: 'mb-6',
title: 'text-xl font-semibold leading-7',
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
container: belowSm.value
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
header: props.title ? 'mb-6' : 'mb-0',
title: 'text-xl/7 font-semibold',
description: 'mt-1.5 text-base text-muted',
}))
const footerClass = computed(() => [
'mt-4 flex w-full items-center justify-between',
belowSm.value ? 'pt-0 px-5 pb-6' : 'p-0',
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
belowSm.value ? 'px-5 pb-6' : 'p-0',
cardClass.value,
])
const colorModeSelectUi = { trailing: 'hidden' }
const colorModeSelectUi = {
base: 'h-7 ps-7 pe-2 text-sm text-toned',
leading: 'ps-2',
leadingIcon: 'size-3.5 text-toned',
trailing: 'hidden',
}
watch(
() => props.title,
@@ -96,16 +105,20 @@ watch(
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
</template>
<div v-if="slots.notice" class="mb-4">
<slot name="notice" />
</div>
<slot v-if="slots.notice" name="notice" />
<slot :control-size="controlSize" :control-class="controlClass" />
<slot
:control-size="controlSize"
:control-class="controlClass"
:input-class="inputClass"
/>
</UPageCard>
<footer :class="footerClass">
<slot name="footer" />
<UColorModeSelect size="sm" class="ms-auto" :ui="colorModeSelectUi" />
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
<span class="flex flex-wrap items-center gap-1.5">
<slot name="footer" />
</span>
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
</footer>
</main>
</template>
+8 -3
View File
@@ -76,7 +76,12 @@ function onCancel() {
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:ui="{
content:
'max-sm:!inset-x-0 max-sm:!bottom-0 max-sm:!top-auto max-sm:!left-0 max-sm:!right-0 max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:!w-full max-sm:!max-w-none',
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
title: 'text-lg max-sm:text-[1.0625rem]/6',
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
footer: 'p-6 sm:px-6 max-sm:p-5',
}"
>
<template v-if="error" #body>
@@ -96,7 +101,7 @@ function onCancel() {
color="neutral"
variant="outline"
block
class="sm:w-auto"
class="h-11 justify-center text-[0.9375rem] text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
:disabled="pending"
@click="onCancel"
>
@@ -107,7 +112,7 @@ function onCancel() {
:loading="pending"
:disabled="pending"
block
class="sm:w-auto"
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
@click="onConfirm"
>
{{ confirmText }}
+16 -12
View File
@@ -11,16 +11,23 @@ const props = defineProps<{
showErrors: boolean
}>()
function itemClass(rule: PasswordRule) {
function iconClass(rule: PasswordRule) {
if (rule.met) {
return 'text-success'
}
return props.showErrors ? 'text-error' : 'text-toned'
return props.showErrors ? 'text-error' : 'text-muted'
}
function textClass(rule: PasswordRule) {
if (rule.met) {
return 'text-default'
}
return props.showErrors ? 'text-error' : 'text-muted'
}
function itemIcon(rule: PasswordRule) {
if (rule.met) {
return 'i-lucide-circle-check'
return 'i-lucide-check'
}
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
}
@@ -28,21 +35,18 @@ function itemIcon(rule: PasswordRule) {
const items = computed(() =>
props.rules.map((rule) => ({
...rule,
classes: itemClass(rule),
iconClass: iconClass(rule),
textClass: textClass(rule),
icon: itemIcon(rule),
})),
)
</script>
<template>
<ul class="flex flex-col gap-1">
<li
v-for="item in items"
:key="item.label"
:class="['flex items-center gap-1.5 text-sm', item.classes]"
>
<UIcon :name="item.icon" class="shrink-0" />
{{ item.label }}
<ul class="flex flex-col gap-1.5">
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
</ul>
+17 -10
View File
@@ -21,9 +21,9 @@ const props = defineProps<{
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
success: 'bg-success/10 text-success',
error: 'bg-error/10 text-error',
info: 'bg-info/10 text-info',
success: 'bg-(--rc-success-soft) text-success',
error: 'bg-(--rc-error-soft) text-error',
info: 'bg-(--rc-info-soft) text-info',
}
const tileClass = computed(() => toneTileClass[props.tone])
@@ -38,24 +38,31 @@ onMounted(async () => {
<template>
<div :role="role" class="flex flex-col gap-4">
<div :class="['flex h-11 w-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="h-6 w-6" />
<div :class="['flex size-11 items-center justify-center', tileClass]">
<UIcon :name="icon" class="size-5.5" />
</div>
<div class="flex flex-col gap-1.5">
<h1 ref="titleEl" tabindex="-1" class="text-xl font-semibold leading-7 outline-none">
<h1
ref="titleEl"
tabindex="-1"
class="text-xl/7 font-semibold text-highlighted outline-none"
>
{{ title }}
</h1>
<p class="text-toned text-sm">{{ description }}</p>
<p class="text-base text-pretty text-toned">{{ description }}</p>
<slot />
</div>
<slot />
<div class="flex w-full flex-col gap-2">
<div v-if="actions.length" class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
:key="action.label"
block
size="lg"
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:class="[
'h-11 justify-center text-base sm:h-10',
action.variant === 'outline' ? 'text-highlighted' : '',
]"
:to="action.to"
:disabled="action.disabled"
@click="action.onClick"
@@ -116,7 +116,7 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
})
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
it('uses the muted background token, keeps the card ring, capped width and 12vh top offset', () => {
it('uses the muted background token, keeps the card border, capped width and 12vh top offset', () => {
stubMatchMedia(false)
const wrapper = mount(AuthShell, {
props: { title: 'Log in', description: 'Welcome back.' },
@@ -130,7 +130,7 @@ describe('The page sits on the muted background from sm up, and the card keeps i
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.classes()).toContain('max-w-[400px]')
expect(card.classes().some((c) => c === 'ring-0')).toBe(false)
expect(card.classes()).toContain('border')
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).not.toContain('p-5')
@@ -55,57 +55,6 @@ afterEach(async () => {
resetAuthShellNavigation()
})
describe('Card padding and control heights at sm and up', () => {
it('pads the card 40px on every side and sizes controls at 40px, 14px text', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('p-10')
expect(container.classes()).not.toContain('pt-2')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-10')
expect(el.classes()).toContain('!text-sm')
}
const title = wrapper.find('[data-slot="title"]')
expect(title.classes()).toContain('text-xl')
expect(title.classes()).toContain('font-semibold')
expect(title.classes()).toContain('leading-7')
})
})
describe('Card padding and control heights below sm', () => {
it('splits card/footer padding and sizes controls at 44px, 16px text', async () => {
stubMatchMedia(true)
const wrapper = await mountLogin()
const container = wrapper.find('[data-slot="container"]')
expect(container.classes()).toContain('pt-2')
expect(container.classes()).toContain('px-5')
expect(container.classes()).toContain('pb-0')
const footer = wrapper.find('footer')
expect(footer.classes()).toContain('pt-0')
expect(footer.classes()).toContain('px-5')
expect(footer.classes()).toContain('pb-6')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const submitButton = wrapper.find('button[type="submit"]')
for (const el of [usernameInput, passwordInput, submitButton]) {
expect(el.classes()).toContain('!h-11')
expect(el.classes()).toContain('!text-base')
}
})
})
describe('"Forgot password?" is underlined and muted', () => {
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
const wrapper = await mountLogin()
@@ -151,6 +100,7 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
describe('The theme select is right-aligned in the footer with no chevron', () => {
it('renders as the last child, showing only the mode icon and label', async () => {
stubMatchMedia(false)
const wrapper = await mountLogin()
const footer = wrapper.find('footer')
@@ -201,7 +151,9 @@ describe('The alert renders inside the card, under the description', () => {
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(alert.classes().some((c) => /^bg-(info|success)\/10$/.test(c))).toBe(true)
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
true,
)
},
)
})
+5
View File
@@ -0,0 +1,5 @@
export const VERIFIED_REDIRECT_DELAY_MS = 800
export function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+87 -9
View File
@@ -1,4 +1,4 @@
const overlayBackground = 'bg-(--ui-color-neutral-950)/40'
const overlayBackground = 'bg-(--rc-overlay)'
export const renewUi = {
colors: {
@@ -37,7 +37,7 @@ export const renewUi = {
},
button: {
slots: {
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)',
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard) disabled:opacity-40 aria-disabled:opacity-40',
},
variants: {
size: {
@@ -45,20 +45,61 @@ export const renewUi = {
leadingIcon: 'size-4',
trailingIcon: 'size-4',
},
xl: {
leadingIcon: 'size-4',
trailingIcon: 'size-4',
},
},
},
compoundVariants: [
{
loading: true,
class: {
base: 'disabled:opacity-100 aria-disabled:opacity-100',
},
},
],
defaultVariants: {
size: 'md',
},
},
input: {
formField: {
slots: {
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted',
labelWrapper: 'gap-2',
label: 'text-highlighted',
error: 'mt-1.5 text-xs/[18px]',
help: 'mt-1.5 text-xs/[18px]',
},
variants: {
size: {
xl: {
root: 'text-sm',
},
},
orientation: {
vertical: {
container: 'mt-1.5',
},
},
},
},
input: {
slots: {
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted focus-visible:outline-none disabled:bg-elevated disabled:text-dimmed disabled:ring-(--ui-border) disabled:opacity-100',
},
compoundVariants: [
{
highlight: true,
class: 'ring-2',
},
],
variants: {
size: {
md: {
base: 'text-base md:text-sm',
base: 'ps-2.75 text-base',
},
xl: {
base: 'ps-2.75 text-lg',
},
},
},
@@ -164,19 +205,56 @@ export const renewUi = {
},
toast: {
slots: {
root: 'rounded-none shadow-(--shadow-popover)',
progress: 'rounded-none',
root: 'rounded-none shadow-(--shadow-popover) ring-0 border border-default gap-2.5 px-3.5 py-3',
title: 'text-sm font-semibold max-sm:text-base/5',
description: 'text-sm text-muted max-sm:text-base/5',
icon: 'mt-0.5 size-4 max-sm:mt-0.75',
progress: 'hidden',
},
variants: {
orientation: {
horizontal: {
root: 'items-start',
},
},
title: {
true: {
description: 'mt-0.5',
},
},
},
compoundVariants: [
{
color: ['primary', 'secondary', 'success', 'info', 'warning', 'neutral'],
class: { close: 'hidden' },
},
],
},
toaster: {
slots: {
viewport: 'z-(--z-toast)',
viewport: 'z-(--z-toast) sm:right-6 sm:bottom-6 sm:w-89',
},
},
alert: {
slots: {
root: 'rounded-none',
root: 'rounded-none gap-2.5 px-3.5 py-3',
icon: 'mt-0.5 size-4',
title: 'text-sm font-semibold',
description: 'text-sm text-pretty text-toned opacity-100',
},
variants: {
title: {
true: {
description: 'mt-0.5',
},
},
},
compoundVariants: [
{ color: 'success', variant: 'soft', class: { root: 'bg-(--rc-success-soft)' } },
{ color: 'info', variant: 'soft', class: { root: 'bg-(--rc-info-soft)' } },
{ color: 'warning', variant: 'soft', class: { root: 'bg-(--rc-warning-soft)' } },
{ color: 'error', variant: 'soft', class: { root: 'bg-(--rc-error-soft)' } },
],
},
card: {
slots: {
+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()
})
})