doxa-maps-cdn/app/pages/profile.vue
2026-04-21 12:12:54 +01:00

464 lines
13 KiB
Vue

<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const { user, logout, deleteAccount } = useAuth()
const route = useRoute()
// Check for email change status in query params
onMounted(() => {
const emailChangeStatus = route.query.email_change as string
if (emailChangeStatus) {
switch (emailChangeStatus) {
case 'success':
emailSuccess.value = 'Email address successfully changed! You can now log in with your new email.'
break
case 'invalid_token':
emailError.value = 'Invalid or expired verification link'
break
case 'no_pending':
emailError.value = 'No pending email change found'
break
case 'email_taken':
emailError.value = 'The new email address is already in use'
break
}
}
})
// State for name change
const nameState = reactive({
display_name: user.value?.display_name || ''
})
const nameLoading = ref(false)
const nameError = ref('')
const nameSuccess = ref('')
// State for email change
const emailState = reactive({
new_email: '',
current_password: ''
})
const emailLoading = ref(false)
const emailError = ref('')
const emailSuccess = ref('')
const canSubmitEmail = computed(() => {
return emailState.new_email.trim() &&
emailState.current_password &&
!emailLoading.value &&
emailState.new_email !== user.value?.email
})
// State for password change
const passwordState = reactive({
current_password: '',
new_password: '',
confirm_password: ''
})
const passwordLoading = ref(false)
const passwordError = ref('')
const passwordSuccess = ref('')
// State for account deletion
const deleteState = reactive({
password: '',
confirmDelete: false
})
const deleteLoading = ref(false)
const deleteError = ref('')
// Watch user changes to update name field
watch(user, (newUser) => {
if (newUser) {
nameState.display_name = newUser.display_name
}
})
// Password validation
const passwordMatch = computed(() => {
return passwordState.new_password === passwordState.confirm_password
})
const passwordStrength = computed(() => {
const password = passwordState.new_password
if (password.length === 0) return { strength: 0, label: '', color: 'gray' }
if (password.length < 8) return { strength: 25, label: 'Weak', color: 'red' }
if (password.length < 12) return { strength: 50, label: 'Fair', color: 'yellow' }
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
return { strength: 50, label: 'Fair', color: 'yellow' }
}
return { strength: 100, label: 'Strong', color: 'green' }
})
const canSubmitPassword = computed(() => {
return passwordState.current_password &&
passwordState.new_password.length >= 8 &&
passwordMatch.value &&
!passwordLoading.value
})
// Handle name update
async function handleNameUpdate() {
nameError.value = ''
nameSuccess.value = ''
if (!nameState.display_name.trim()) {
nameError.value = 'Display name is required'
return
}
nameLoading.value = true
try {
await $fetch('/api/profile/name', {
method: 'PATCH',
body: {
display_name: nameState.display_name.trim()
}
})
nameSuccess.value = 'Display name updated successfully'
// Update user in auth composable
if (user.value) {
user.value.display_name = nameState.display_name.trim()
}
} catch (err: any) {
nameError.value = err.data?.statusMessage || 'Failed to update display name'
} finally {
nameLoading.value = false
}
}
// Handle email change
async function handleEmailChange() {
emailError.value = ''
emailSuccess.value = ''
if (!canSubmitEmail.value) {
return
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(emailState.new_email)) {
emailError.value = 'Please enter a valid email address'
return
}
emailLoading.value = true
try {
await $fetch('/api/profile/email', {
method: 'POST',
body: {
new_email: emailState.new_email.trim(),
current_password: emailState.current_password
}
})
emailSuccess.value = 'Verification email sent! Please check your new email address to confirm the change.'
// Reset form
emailState.new_email = ''
emailState.current_password = ''
} catch (err: any) {
emailError.value = err.data?.statusMessage || 'Failed to request email change'
} finally {
emailLoading.value = false
}
}
// Handle password change
async function handlePasswordChange() {
passwordError.value = ''
passwordSuccess.value = ''
if (!canSubmitPassword.value) {
return
}
passwordLoading.value = true
try {
await $fetch('/api/profile/password', {
method: 'PATCH',
body: {
current_password: passwordState.current_password,
new_password: passwordState.new_password
}
})
passwordSuccess.value = 'Password changed successfully'
// Reset form
passwordState.current_password = ''
passwordState.new_password = ''
passwordState.confirm_password = ''
} catch (err: any) {
passwordError.value = err.data?.statusMessage || 'Failed to change password'
} finally {
passwordLoading.value = false
}
}
// Handle account deletion
async function handleDeleteAccount() {
deleteError.value = ''
if (!deleteState.password || !deleteState.confirmDelete) {
return
}
deleteLoading.value = true
try {
const result = await deleteAccount(deleteState.password)
if (!result.success) {
deleteError.value = result.message || 'Failed to delete account'
}
} catch (err: any) {
deleteError.value = err.data?.statusMessage || 'Failed to delete account'
} finally {
deleteLoading.value = false
}
}
// Reset delete confirmation when password changes
watch(() => deleteState.password, () => {
deleteState.confirmDelete = false
})
</script>
<template>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-3xl font-bold">Profile Settings</h1>
<!-- Account Info -->
<UCard>
<template #header>
<h2 class="text-xl font-semibold">Account Information</h2>
</template>
<div class="space-y-2 text-sm">
<div>
<span class="font-medium">Email:</span>
<span class="ml-2 text-(--ui-text-muted)">{{ user?.email }}</span>
</div>
<div>
<span class="font-medium">Account created:</span>
<span class="ml-2 text-(--ui-text-muted)">
{{ new Date(user?.created).toLocaleDateString() }}
</span>
</div>
</div>
</UCard>
<!-- Change Display Name -->
<UCard>
<template #header>
<h2 class="text-xl font-semibold">Change Display Name</h2>
</template>
<form @submit.prevent="handleNameUpdate" class="space-y-4">
<UFormField label="Display Name">
<UInput
v-model="nameState.display_name"
type="text"
size="lg"
:disabled="nameLoading"
placeholder="Enter your display name"
/>
</UFormField>
<UAlert v-if="nameError" color="error" :title="nameError" />
<UAlert v-if="nameSuccess" color="success" :title="nameSuccess" />
<UButton
type="submit"
size="lg"
:loading="nameLoading"
:disabled="!nameState.display_name.trim() || nameLoading"
>
Update Name
</UButton>
</form>
</UCard>
<!-- Change Email -->
<UCard>
<template #header>
<h2 class="text-xl font-semibold">Change Email Address</h2>
</template>
<form @submit.prevent="handleEmailChange" class="space-y-4">
<div class="text-sm text-(--ui-text-muted) mb-4">
<p>Current email: <strong>{{ user?.email }}</strong></p>
<p class="mt-2">A verification link will be sent to your new email address.</p>
</div>
<UFormField label="New Email Address">
<UInput
v-model="emailState.new_email"
type="email"
size="lg"
:disabled="emailLoading"
placeholder="Enter new email address"
autocomplete="email"
/>
</UFormField>
<UFormField label="Current Password">
<UInput
v-model="emailState.current_password"
type="password"
size="lg"
:disabled="emailLoading"
placeholder="Enter your current password"
autocomplete="current-password"
/>
</UFormField>
<UAlert v-if="emailError" color="error" :title="emailError" />
<UAlert v-if="emailSuccess" color="success" :title="emailSuccess" />
<UButton
type="submit"
size="lg"
:loading="emailLoading"
:disabled="!canSubmitEmail"
>
Send Verification Email
</UButton>
</form>
</UCard>
<!-- Change Password -->
<UCard>
<template #header>
<h2 class="text-xl font-semibold">Change Password</h2>
</template>
<form @submit.prevent="handlePasswordChange" class="space-y-4">
<UFormField label="Current Password">
<UInput
v-model="passwordState.current_password"
type="password"
size="lg"
:disabled="passwordLoading"
placeholder="Enter current password"
autocomplete="current-password"
/>
</UFormField>
<UFormField label="New Password">
<UInput
v-model="passwordState.new_password"
type="password"
size="lg"
:disabled="passwordLoading"
placeholder="Enter new password (min 8 characters)"
autocomplete="new-password"
/>
</UFormField>
<!-- Password Strength Indicator -->
<div v-if="passwordState.new_password" class="space-y-2">
<div class="flex justify-between text-sm">
<span>Password Strength:</span>
<span :class="{
'text-red-600': passwordStrength.color === 'red',
'text-yellow-600': passwordStrength.color === 'yellow',
'text-green-600': passwordStrength.color === 'green'
}">
{{ passwordStrength.label }}
</span>
</div>
<div class="h-2 bg-(--ui-bg-accented) rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="{
'bg-red-500': passwordStrength.color === 'red',
'bg-yellow-500': passwordStrength.color === 'yellow',
'bg-green-500': passwordStrength.color === 'green'
}"
:style="{ width: passwordStrength.strength + '%' }"
/>
</div>
</div>
<UFormField label="Confirm New Password">
<UInput
v-model="passwordState.confirm_password"
type="password"
size="lg"
:disabled="passwordLoading"
placeholder="Confirm new password"
autocomplete="new-password"
/>
</UFormField>
<!-- Password Match Indicator -->
<div v-if="passwordState.confirm_password" class="text-sm">
<span v-if="passwordMatch" class="text-green-600">Passwords match</span>
<span v-else class="text-red-600">Passwords do not match</span>
</div>
<UAlert v-if="passwordError" color="error" :title="passwordError" />
<UAlert v-if="passwordSuccess" color="success" :title="passwordSuccess" />
<UButton
type="submit"
size="lg"
:loading="passwordLoading"
:disabled="!canSubmitPassword"
>
Change Password
</UButton>
</form>
</UCard>
<!-- Sign Out -->
<UCard>
<template #header>
<h2 class="text-xl font-semibold">Sign Out</h2>
</template>
<div class="space-y-4">
<p class="text-sm text-(--ui-text-muted)">
Sign out of your account on this device.
</p>
<UButton color="error" variant="outline" size="lg" @click="logout">
Sign Out
</UButton>
</div>
</UCard>
<!-- Delete Account -->
<UCard>
<template #header>
<h2 class="text-xl font-semibold text-red-600">Delete Account</h2>
</template>
<div class="space-y-4">
<UAlert
color="warning"
title="Warning: This action is permanent"
description="Deleting your account will permanently remove all your data. This action cannot be undone."
/>
<UFormField label="Enter your password to confirm">
<UInput
v-model="deleteState.password"
type="password"
size="lg"
:disabled="deleteLoading"
placeholder="Enter your password"
autocomplete="current-password"
/>
</UFormField>
<div class="flex items-center gap-2">
<UCheckbox
v-model="deleteState.confirmDelete"
:disabled="!deleteState.password || deleteLoading"
/>
<span class="text-sm">I understand this action is permanent and cannot be undone</span>
</div>
<UAlert v-if="deleteError" color="error" :title="deleteError" />
<UButton
color="error"
size="lg"
:loading="deleteLoading"
:disabled="!deleteState.password || !deleteState.confirmDelete || deleteLoading"
@click="handleDeleteAccount"
>
Delete My Account
</UButton>
</div>
</UCard>
</div>
</template>