feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import ProfileCard from '@/components/ProfileCard.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSessionStore, type Session } from '@/stores/session'
const DISPLAY_NAME_MAX = 60
const DISPLAY_NAME_LIMIT_BODY = 'Display name must be 60 characters or fewer.'
const DISPLAY_NAME_LIMIT_COPY = 'Use 60 characters or fewer.'
const SAVE_FAILED_COPY =
"Couldn't save your display name. Your text is still here. Try again in a moment."
const RESET_FALLBACK_SECONDS = 300
const PASSWORD_MASK = '••••••••••••'
const SKELETON_CARDS = [
{ titleWidth: 'w-30', inputs: 1 },
{ titleWidth: 'w-22.5', inputs: 2 },
{ titleWidth: 'w-25', inputs: 1 },
]
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
const session = useSessionStore()
const toast = useToast()
const overlay = useOverlay()
const { request } = useApi()
const retryAfter = useRetryAfter()
const account = ref<Session | null>(session.session)
const field = ref('')
const saving = ref(false)
const saveFailed = ref(false)
const serverLimitError = ref(false)
const resetState = ref<ResetState>('idle')
const inputRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
watch(
() => session.session,
(value) => {
if (value) {
account.value = value
}
},
)
watch(
account,
(value, previous) => {
if (value && !previous) {
field.value = value.displayName ?? ''
}
},
{ immediate: true },
)
watch(field, () => {
serverLimitError.value = false
})
watch(retryAfter.remaining, (value) => {
if (value === 0 && resetState.value === 'limited') {
resetState.value = 'idle'
}
})
const username = computed(() => account.value?.username ?? '')
const email = computed(() => account.value?.email ?? '')
const savedName = computed(() => account.value?.displayName ?? '')
const draft = computed(() => field.value.trim())
const count = computed(() => Array.from(draft.value).length)
const dirty = computed(() => draft.value !== savedName.value)
const tooLong = computed(() => count.value > DISPLAY_NAME_MAX)
const showFieldError = computed(() => tooLong.value || serverLimitError.value)
const saveEnabled = computed(() => dirty.value && !showFieldError.value)
const showUnsaved = computed(() => dirty.value && !saving.value)
const statusText = computed(() => {
if (showUnsaved.value) {
return 'Unsaved changes'
}
return savedName.value
? `You appear as “${savedName.value}”.`
: `You appear as “${username.value}”, your username.`
})
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
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 resetNotes = {
sent: {
color: 'success',
icon: 'i-lucide-mail-check',
title: 'Reset link sent',
},
limited: {
color: 'warning',
icon: 'i-lucide-clock-alert',
title: 'Too many requests',
description: "You've asked for several links recently. Wait a few minutes, then try again.",
},
error: {
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't send the link",
description: 'Check your connection and try again.',
},
} as const
const resetNote = computed(() => {
if (resetState.value === 'sent') {
return {
...resetNotes.sent,
description: `Check ${email.value}. The link works once, within 1 hour.`,
}
}
if (resetState.value === 'limited' || resetState.value === 'error') {
return resetNotes[resetState.value]
}
return null
})
const resetButtonLabel = computed(() => {
if (resetState.value === 'sending') {
return 'Sending…'
}
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
})
const resetBusy = computed(() => resetState.value === 'sending')
const resetDisabled = computed(() => resetBusy.value || resetState.value === 'limited')
function focusField() {
nextTick(() => inputRef.value?.inputRef?.focus())
}
function announceSaved(displayName: string) {
toast.add({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: displayName ? 'Display name saved' : 'Display name removed',
description: displayName
? `You now appear as ${displayName} across RenewCMS and in emails.`
: `You now appear as ${username.value}, your username.`,
})
}
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.status === 403)
}
async function onSave() {
if (saving.value || !saveEnabled.value) {
return
}
saving.value = true
saveFailed.value = false
serverLimitError.value = false
try {
const updated = await request<{ display_name?: string | null }>('/v1/user/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: draft.value }),
})
const displayName = updated.display_name ?? ''
session.setDisplayName(displayName)
field.value = displayName
announceSaved(displayName)
} catch (error) {
if (isHandledGlobally(error)) {
return
}
if (
error instanceof ApiError &&
error.status === 400 &&
error.body === DISPLAY_NAME_LIMIT_BODY
) {
serverLimitError.value = true
} else {
saveFailed.value = true
}
focusField()
} finally {
saving.value = false
}
}
async function onSendReset() {
if (resetDisabled.value) {
return
}
resetState.value = 'sending'
try {
await request('/v1/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
resetState.value = 'sent'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
retryAfter.start(error.retryAfter || RESET_FALLBACK_SECONDS)
resetState.value = 'limited'
return
}
resetState.value = isHandledGlobally(error) ? 'idle' : 'error'
}
}
onBeforeRouteLeave(async () => {
if (authRedirectPending.value || !dirty.value) {
return true
}
const keepEditing = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: "Your display name change hasn't been saved.",
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
},
})
.open()
return !keepEditing
})
</script>
<template>
<DashboardShell render-while-checking>
<div class="flex max-w-164 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
<p class="text-muted">Your account on this RenewCMS workspace.</p>
</div>
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
<div
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"
>
<USkeleton class="h-3.5 rounded-none bg-accented" :class="card.titleWidth" />
<USkeleton
v-for="inputIndex in card.inputs"
:key="inputIndex"
class="h-10 rounded-none bg-accented"
/>
</div>
</div>
<template v-else>
<UForm :state="{ displayName: field }" @submit="onSave">
<ProfileCard
title="Display name"
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
>
<UAlert
v-if="saveFailed"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:description="SAVE_FAILED_COPY"
:close="false"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
/>
<UFormField name="displayName" :error="showFieldError">
<template #label>
Display name <span class="font-normal text-muted">(optional)</span>
</template>
<UInput
ref="inputRef"
v-model="field"
placeholder="e.g. Marta Alves"
autocomplete="off"
:disabled="saving"
class="w-full"
:ui="inputUi"
@keydown.enter.prevent="onSave"
/>
<div class="mt-1.5 flex items-start gap-3">
<span
v-if="showFieldError"
data-testid="display-name-error"
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ DISPLAY_NAME_LIMIT_COPY }}
</span>
<span v-else class="flex-1" />
<CharacterCounter
data-testid="display-name-counter"
variant="field"
:value="count"
:max="DISPLAY_NAME_MAX"
/>
</div>
</UFormField>
<template #footer>
<span
data-testid="display-name-status"
class="min-w-0 flex-1 text-xs/[18px]"
:class="showUnsaved ? 'text-warning' : 'text-muted'"
>{{ statusText }}</span
>
<UButton
type="submit"
:loading="saving"
:disabled="!saveEnabled && !saving"
class="h-11 shrink-0 px-3.5 lg:h-8"
>
{{ saving ? 'Saving…' : 'Save' }}
</UButton>
</template>
</ProfileCard>
</UForm>
<ProfileCard
title="Account"
description="Set when your account was created. You log in with your username, even after adding a display name."
>
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<UFormField label="Username">
<UInput
:model-value="username"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
<UFormField label="Email">
<UInput
:model-value="email"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
</div>
</ProfileCard>
<ProfileCard
title="Password"
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
<UFormField label="Password" class="min-w-0 flex-1">
<UInput
:model-value="PASSWORD_MASK"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
<UButton
color="neutral"
variant="outline"
:icon="resetBusy ? undefined : 'i-lucide-mail'"
:loading="resetBusy"
:disabled="resetDisabled"
class="h-11 justify-center border border-accented bg-default px-3.5 whitespace-nowrap text-highlighted ring-0 lg:h-10"
@click="onSendReset"
>
{{ resetButtonLabel }}
</UButton>
</div>
<UAlert
v-if="resetNote"
data-testid="reset-note"
role="status"
variant="soft"
:color="resetNote.color"
:icon="resetNote.icon"
:title="resetNote.title"
:description="resetNote.description"
:close="false"
/>
</ProfileCard>
</template>
</div>
</DashboardShell>
</template>