277 lines
7.3 KiB
Vue
277 lines
7.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { Save, Lock, User } from '@lucide/vue'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { apiFetch } from '../services/api'
|
|
|
|
const auth = useAuthStore()
|
|
const editingName = ref(false)
|
|
const displayName = ref(auth.user?.displayName ?? '')
|
|
const savingName = ref(false)
|
|
const nameError = ref('')
|
|
const nameSuccess = ref(false)
|
|
|
|
const currentPassword = ref('')
|
|
const newPassword = ref('')
|
|
const confirmPassword = ref('')
|
|
const changingPassword = ref(false)
|
|
const passwordError = ref('')
|
|
const passwordSuccess = ref(false)
|
|
|
|
async function saveDisplayName() {
|
|
nameError.value = ''
|
|
nameSuccess.value = false
|
|
const trimmed = displayName.value.trim()
|
|
if (!trimmed) {
|
|
nameError.value = 'Display name cannot be empty.'
|
|
return
|
|
}
|
|
savingName.value = true
|
|
try {
|
|
const response = await apiFetch('/api/v1/auth/profile', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ displayName: trimmed }),
|
|
})
|
|
if (!response.ok) throw new Error('Failed to update profile')
|
|
const result = await response.json()
|
|
if (auth.user) auth.user.displayName = result.displayName
|
|
nameSuccess.value = true
|
|
editingName.value = false
|
|
} catch (e) {
|
|
nameError.value = e instanceof Error ? e.message : 'Failed to update name'
|
|
} finally {
|
|
savingName.value = false
|
|
}
|
|
}
|
|
|
|
async function changePassword() {
|
|
passwordError.value = ''
|
|
passwordSuccess.value = false
|
|
|
|
if (!currentPassword.value) {
|
|
passwordError.value = 'Current password is required.'
|
|
return
|
|
}
|
|
if (!newPassword.value || newPassword.value.length < 10) {
|
|
passwordError.value = 'New password must be at least 10 characters.'
|
|
return
|
|
}
|
|
if (newPassword.value !== confirmPassword.value) {
|
|
passwordError.value = 'Passwords do not match.'
|
|
return
|
|
}
|
|
|
|
changingPassword.value = true
|
|
try {
|
|
const response = await apiFetch('/api/v1/auth/change-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
currentPassword: currentPassword.value,
|
|
newPassword: newPassword.value,
|
|
}),
|
|
})
|
|
if (!response.ok) {
|
|
const detail = await response.json().catch(() => ({}))
|
|
throw new Error(detail.detail || 'Failed to change password')
|
|
}
|
|
passwordSuccess.value = true
|
|
currentPassword.value = ''
|
|
newPassword.value = ''
|
|
confirmPassword.value = ''
|
|
} catch (e) {
|
|
passwordError.value = e instanceof Error ? e.message : 'Failed to change password'
|
|
} finally {
|
|
changingPassword.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="settings-page">
|
|
<div class="settings-card">
|
|
<div class="settings-header">
|
|
<User :size="20" />
|
|
<h2>Profile</h2>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label>Email</label>
|
|
<span class="setting-value">{{ auth.user?.email }}</span>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label>Display Name</label>
|
|
<div v-if="editingName" class="setting-edit">
|
|
<input v-model="displayName" maxlength="100" class="setting-input" />
|
|
<div class="setting-edit-actions">
|
|
<button class="btn-primary btn-sm" @click="saveDisplayName" :disabled="savingName">
|
|
<Save :size="13" /> {{ savingName ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
<button class="btn-ghost btn-sm" @click="editingName = false">Cancel</button>
|
|
</div>
|
|
<p v-if="nameError" class="setting-error">{{ nameError }}</p>
|
|
<p v-else-if="nameSuccess" class="setting-success">Display name updated.</p>
|
|
</div>
|
|
<div v-else class="setting-value-row">
|
|
<span class="setting-value">{{ auth.user?.displayName }}</span>
|
|
<button class="btn-ghost btn-sm" @click="editingName = true">Edit</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label>Role</label>
|
|
<span class="setting-value badge">{{ auth.user?.role }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-card">
|
|
<div class="settings-header">
|
|
<Lock :size="20" />
|
|
<h2>Change Password</h2>
|
|
</div>
|
|
|
|
<form class="password-form" @submit.prevent="changePassword">
|
|
<div class="setting-row">
|
|
<label>Current Password</label>
|
|
<input v-model="currentPassword" type="password" class="setting-input" autocomplete="current-password" />
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>New Password</label>
|
|
<input v-model="newPassword" type="password" class="setting-input" autocomplete="new-password" minlength="10" />
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Confirm Password</label>
|
|
<input v-model="confirmPassword" type="password" class="setting-input" autocomplete="new-password" />
|
|
</div>
|
|
|
|
<p v-if="passwordError" class="setting-error">{{ passwordError }}</p>
|
|
<p v-else-if="passwordSuccess" class="setting-success">Password changed successfully.</p>
|
|
|
|
<button type="submit" class="btn-primary" :disabled="changingPassword">
|
|
<Lock :size="14" /> {{ changingPassword ? 'Changing...' : 'Change Password' }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.settings-page {
|
|
max-width: 600px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.25rem;
|
|
}
|
|
.settings-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 1.5rem;
|
|
}
|
|
.settings-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
margin-bottom: 1.25rem;
|
|
color: var(--text-primary);
|
|
}
|
|
.settings-header h2 {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
.setting-row {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.setting-row label {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
.setting-value {
|
|
font-size: 0.95rem;
|
|
color: var(--text-primary);
|
|
}
|
|
.setting-value-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.setting-edit {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.setting-edit-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
.setting-input {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
font-size: 0.95rem;
|
|
color: var(--text-primary);
|
|
}
|
|
.password-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.btn-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.55rem 1rem;
|
|
background: var(--nx-accent);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
width: fit-content;
|
|
}
|
|
.btn-primary:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
.btn-sm {
|
|
padding: 0.35rem 0.65rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.btn-ghost {
|
|
background: none;
|
|
border: 1px solid var(--border);
|
|
color: var(--text-secondary);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
.setting-error {
|
|
color: rgb(239, 68, 68);
|
|
font-size: 0.85rem;
|
|
margin: 0.35rem 0 0;
|
|
}
|
|
.setting-success {
|
|
color: rgb(34, 197, 94);
|
|
font-size: 0.85rem;
|
|
margin: 0.35rem 0 0;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 0.15rem 0.5rem;
|
|
background: var(--nx-accent-soft);
|
|
color: var(--nx-accent);
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
}
|
|
</style>
|