Introduce structural CSS classes for page shells, headers, and surface cards Update primary theme color to red and neutral to zinc across the application
261 lines
7.9 KiB
Vue
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>
|