Files
nexus/frontend/src/views/SettingsView.vue
T
developer b7b44494f0
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
fix(shadcn): isolate Nexus CSS vars with --nx- prefix + admin password reset endpoint
2026-06-11 10:06:58 +02:00

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>