Files
dticket.tootaio.com/app/pages/security/index.vue
xiaomai 227c64d346 refactor(ui): standardize page layouts and component styling
Introduce structural CSS classes for page shells, headers, and surface cards
Update primary theme color to red and neutral to zinc across the application
2026-05-08 16:25:42 +08:00

261 lines
7.9 KiB
Vue

<template>
<UContainer class="page-shell-narrow">
<div class="space-y-6">
<div class="page-header">
<UBadge label="Security" color="primary" variant="soft" class="page-eyebrow" />
<h1 class="page-title">
Password and passkey settings
</h1>
</div>
<div class="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<UCard class="surface-card overflow-hidden rounded-lg">
<template #header>
<h2 class="text-lg font-semibold text-highlighted">
Change password
</h2>
</template>
<UForm :state="passwordForm" :validate="validatePasswordForm" class="space-y-5" @submit="changePassword">
<UFormField name="currentPassword" label="Current Password" required>
<UInput v-model="passwordForm.currentPassword" type="password" size="xl" class="w-full" />
</UFormField>
<UFormField name="newPassword" label="New Password" required>
<UInput v-model="passwordForm.newPassword" type="password" size="xl" class="w-full" />
</UFormField>
<UFormField name="confirmPassword" label="Confirm New Password" required>
<UInput v-model="passwordForm.confirmPassword" type="password" size="xl" class="w-full" />
</UFormField>
<UButton
type="submit"
label="Update Password"
size="xl"
class="w-full justify-center"
:loading="passwordPending"
/>
</UForm>
</UCard>
<UCard class="surface-card overflow-hidden rounded-lg">
<template #header>
<h2 class="text-lg font-semibold text-highlighted">
Passkeys
</h2>
</template>
<div class="space-y-5">
<div class="surface-panel rounded-lg p-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-highlighted">
Registered passkeys
</div>
<div class="text-sm text-muted">
{{ passkeys.length }} passkey{{ passkeys.length === 1 ? '' : 's' }} connected to this account
</div>
</div>
<UBadge
:label="passkeys.length > 0 ? 'Ready' : 'Optional'"
:color="passkeys.length > 0 ? 'success' : 'neutral'"
variant="soft"
/>
</div>
</div>
<UButton
label="Register New Passkey"
size="xl"
class="w-full justify-center"
icon="i-lucide-fingerprint"
:loading="passkeyPending"
@click="registerPasskey"
/>
<div v-if="passkeys.length" class="space-y-3">
<div
v-for="passkey in passkeys"
:key="passkey.id"
class="surface-panel rounded-lg px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-highlighted">
{{ passkey.label }}
</div>
<div class="text-sm text-muted">
Added {{ formatDateTime(passkey.createdAt) }}
</div>
</div>
<UBadge :label="passkey.deviceType === 'multiDevice' ? 'Synced' : 'Single Device'" color="neutral" variant="soft" />
</div>
</div>
</div>
</div>
</UCard>
</div>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { getDefaultAuthenticatedPath, MIN_PASSWORD_LENGTH, type PasskeySummary } from '~~/shared/auth'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
definePageMeta({
middleware: 'auth'
})
const toast = useToast()
const router = useRouter()
const auth = useAuth()
const apiClient = useApiClient()
const passwordPending = ref(false)
const passkeyPending = ref(false)
const passkeys = ref<PasskeySummary[]>([])
const passwordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
await fetchPasskeys()
function validatePasswordForm(state: typeof passwordForm): FormError[] {
const errors: FormError[] = []
if (!state.currentPassword.trim()) {
errors.push({ name: 'currentPassword', message: 'Enter your current password.' })
}
if (state.newPassword.trim().length < MIN_PASSWORD_LENGTH) {
errors.push({ name: 'newPassword', message: `Use at least ${MIN_PASSWORD_LENGTH} characters.` })
}
if (state.confirmPassword.trim() !== state.newPassword.trim()) {
errors.push({ name: 'confirmPassword', message: 'Confirmation does not match the new password.' })
}
return errors
}
async function fetchPasskeys() {
const response = await apiClient<{ passkeys: PasskeySummary[] }>('/api/auth/passkeys')
passkeys.value = response.passkeys
}
function maybeRedirectAfterOnboarding(previouslyRequired: boolean) {
if (previouslyRequired && auth.user.value && !auth.needsOnboarding.value) {
router.push(getDefaultAuthenticatedPath(auth.user.value))
}
}
async function changePassword(event: FormSubmitEvent<typeof passwordForm>) {
event.preventDefault()
if (passwordPending.value) {
return
}
passwordPending.value = true
const previouslyRequired = auth.needsOnboarding.value
try {
const response = await apiClient<{ user: typeof auth.user.value }>('/api/auth/change-password', {
method: 'POST',
body: {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
}
})
auth.setUser(response.user)
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
toast.add({
title: 'Password updated',
description: 'Your account password has been changed.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
maybeRedirectAfterOnboarding(previouslyRequired)
} catch (error: any) {
toast.add({
title: 'Password update failed',
description: getErrorMessage(error, 'Unable to update your password.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passwordPending.value = false
}
}
async function registerPasskey() {
if (passkeyPending.value) {
return
}
passkeyPending.value = true
const previouslyRequired = auth.needsOnboarding.value
try {
const { browserSupportsWebAuthn, startRegistration } = await import('@simplewebauthn/browser')
if (!browserSupportsWebAuthn()) {
throw new Error('This browser does not support passkey registration.')
}
const optionsResponse = await apiClient<{ options: Record<string, any> }>('/api/auth/passkey/register/options', {
method: 'POST'
})
const credential = await startRegistration({
optionsJSON: optionsResponse.options
})
const verification = await apiClient<{
user: typeof auth.user.value
passkeys: PasskeySummary[]
}>('/api/auth/passkey/register/verify', {
method: 'POST',
body: {
response: credential
}
})
auth.setUser(verification.user)
passkeys.value = verification.passkeys
toast.add({
title: 'Passkey registered',
description: 'You can now use this passkey to sign in.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
maybeRedirectAfterOnboarding(previouslyRequired)
} catch (error: any) {
toast.add({
title: 'Passkey registration failed',
description: getErrorMessage(error, 'Unable to register a passkey.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
passkeyPending.value = false
}
}
</script>