Files
nexus/frontend/src/views/SettingsView.vue
T
devops 86ceb2bcce
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
style: unify all views on Mission Control V2 design tokens
- Map legacy v1 CSS variables (--nx-*, --panel, --text-*, --surface*) to V2 tokens in nexus-tokens.css
- Restyle global shell (main.css), AppSidebar, AppHeader to match V2 Sidebar/Topbar (glass, gradients, Space Grotesk)
- Add GalaxyBackground to the v1 shell in App.vue
- Replace hardcoded v1 hex colors with V2 tokens in all deviating views (Agents, Calendar, Docs, Incidents, Memory, Notifications, ProjectDetail, Security, Team, TaskDetail, Settings)
- Keep JS status colors as hex where alpha suffixes are concatenated (AgentsIndex, Team, Notifications)
- Add Settings nav item (System group) + gear icon to V2 sidebar so it shows on the dashboard
- No component or layout structure changes; LoginView and TaskBoardView untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:05:52 +02:00

893 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* SettingsView Galaxy Theme + Admin User Management
*
* - Profile (Display-Name ändern)
* - Passwort ändern
* - Admin: User-Liste + User anlegen (für owner- und admin-Rollen sichtbar)
*/
import { onMounted, ref } from 'vue'
import {
Save, Lock, User, Shield, Plus, Mail, Trash2, Users,
Eye, EyeOff, CheckCircle,
} from '@lucide/vue'
import { useAuthStore } from '../stores/auth'
import { apiFetch } from '../services/api'
const auth = useAuthStore()
/* ── Profile ─────────────────────────────────────── */
const editingName = ref(false)
const displayName = ref(auth.user?.displayName ?? '')
const savingName = ref(false)
const nameError = ref('')
const nameSuccess = ref(false)
async function saveDisplayName() {
nameError.value = ''
nameSuccess.value = false
const trimmed = displayName.value.trim()
if (!trimmed) {
nameError.value = 'Anzeigename darf nicht leer sein.'
return
}
savingName.value = true
try {
const res = await apiFetch('/api/v1/auth/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: trimmed }),
})
if (!res.ok) throw new Error('Update fehlgeschlagen')
const result = await res.json()
if (auth.user) auth.user.displayName = result.displayName
nameSuccess.value = true
editingName.value = false
} catch (e) {
nameError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
} finally {
savingName.value = false
}
}
/* ── Password ────────────────────────────────────── */
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const changingPassword = ref(false)
const passwordError = ref('')
const passwordSuccess = ref(false)
const showCurrentPw = ref(false)
const showNewPw = ref(false)
const showConfirmPw = ref(false)
async function changePassword() {
passwordError.value = ''
passwordSuccess.value = false
if (!currentPassword.value) {
passwordError.value = 'Aktuelles Passwort ist erforderlich.'
return
}
if (!newPassword.value || newPassword.value.length < 10) {
passwordError.value = 'Neues Passwort muss mindestens 10 Zeichen haben.'
return
}
if (newPassword.value !== confirmPassword.value) {
passwordError.value = 'Passwörter stimmen nicht überein.'
return
}
changingPassword.value = true
try {
const res = await apiFetch('/api/v1/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword: currentPassword.value,
newPassword: newPassword.value,
}),
})
if (!res.ok) {
const detail = await res.json().catch(() => ({}))
throw new Error(detail.detail || 'Passwort-Änderung fehlgeschlagen')
}
passwordSuccess.value = true
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
} catch (e) {
passwordError.value = e instanceof Error ? e.message : 'Fehler beim Ändern'
} finally {
changingPassword.value = false
}
}
/* ── Admin: User Management ──────────────────────── */
interface AdminUser {
id: string
email: string
displayName: string
role: string
createdAt: string
lastLoginAt: string | null
}
const users = ref<AdminUser[]>([])
const usersLoading = ref(false)
const usersError = ref('')
const showCreateUser = ref(false)
const createEmail = ref('')
const createPassword = ref('')
const createDisplayName = ref('')
const createRole = ref('user')
const creatingUser = ref(false)
const createError = ref('')
const createSuccess = ref('')
const canManageUsers = auth.user?.role === 'owner' || auth.user?.role === 'admin'
async function loadUsers() {
if (!canManageUsers) return
usersLoading.value = true
usersError.value = ''
try {
const res = await apiFetch('/api/v1/admin/users')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
users.value = await res.json()
} catch (e) {
usersError.value = e instanceof Error ? e.message : 'Benutzer konnten nicht geladen werden'
} finally {
usersLoading.value = false
}
}
async function handleCreateUser() {
createError.value = ''
createSuccess.value = ''
if (!createEmail.value.trim() || !createPassword.value) {
createError.value = 'E-Mail und Passwort sind erforderlich.'
return
}
if (createPassword.value.length < 10) {
createError.value = 'Passwort muss mindestens 10 Zeichen haben.'
return
}
creatingUser.value = true
try {
const res = await apiFetch('/api/v1/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: createEmail.value.trim(),
password: createPassword.value,
displayName: createDisplayName.value.trim() || null,
role: createRole.value,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Benutzer konnte nicht erstellt werden')
}
createSuccess.value = `Benutzer ${createEmail.value.trim()} erfolgreich angelegt.`
createEmail.value = ''
createPassword.value = ''
createDisplayName.value = ''
createRole.value = 'user'
showCreateUser.value = false
await loadUsers()
} catch (e) {
createError.value = e instanceof Error ? e.message : 'Fehler beim Anlegen'
} finally {
creatingUser.value = false
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
onMounted(() => {
loadUsers()
})
</script>
<template>
<div class="settings-wrap">
<div class="settings-heading">
<h1><span class="grad-text">Einstellungen</span></h1>
<p class="settings-subtitle">Profil, Sicherheit &amp; Benutzerverwaltung</p>
</div>
<div class="settings-grid">
<!-- ── Profile Card ────────────────────────── -->
<section class="glass-card">
<div class="card-head">
<User :size="18" />
<h2>Profil</h2>
</div>
<div class="field-row">
<div class="field">
<label>E-Mail</label>
<div class="value-tag">{{ auth.user?.email }}</div>
</div>
<div class="field">
<label>Rolle</label>
<div class="role-badge">{{ auth.user?.role }}</div>
</div>
</div>
<div class="field">
<label>Anzeigename</label>
<div v-if="editingName" class="name-edit">
<div class="input-group">
<input v-model="displayName" maxlength="100" class="galaxy-input" />
<button class="btn-primary btn-sm" @click="saveDisplayName" :disabled="savingName">
<Save :size="13" /> {{ savingName ? 'Speichert…' : 'Speichern' }}
</button>
<button class="btn-ghost btn-sm" @click="editingName = false">Abbrechen</button>
</div>
<p v-if="nameError" class="msg error">{{ nameError }}</p>
<p v-else-if="nameSuccess" class="msg success">Anzeigename aktualisiert.</p>
</div>
<div v-else class="value-row">
<span class="value-tag">{{ auth.user?.displayName }}</span>
<button class="btn-ghost btn-sm" @click="editingName = true">Bearbeiten</button>
</div>
</div>
</section>
<!-- ── Password Card ────────────────────────── -->
<section class="glass-card">
<div class="card-head">
<Lock :size="18" />
<h2>Passwort ändern</h2>
</div>
<form class="password-form" @submit.prevent="changePassword">
<div class="field">
<label>Aktuelles Passwort</label>
<div class="pw-wrap">
<input v-model="currentPassword" :type="showCurrentPw ? 'text' : 'password'" class="galaxy-input" autocomplete="current-password" />
<button type="button" class="pw-toggle" @click="showCurrentPw = !showCurrentPw" tabindex="-1">
<Eye v-if="!showCurrentPw" :size="16" />
<EyeOff v-else :size="16" />
</button>
</div>
</div>
<div class="field">
<label>Neues Passwort</label>
<div class="pw-wrap">
<input v-model="newPassword" :type="showNewPw ? 'text' : 'password'" class="galaxy-input" autocomplete="new-password" minlength="10" />
<button type="button" class="pw-toggle" @click="showNewPw = !showNewPw" tabindex="-1">
<Eye v-if="!showNewPw" :size="16" />
<EyeOff v-else :size="16" />
</button>
</div>
</div>
<div class="field">
<label>Passwort bestätigen</label>
<div class="pw-wrap">
<input v-model="confirmPassword" :type="showConfirmPw ? 'text' : 'password'" class="galaxy-input" autocomplete="new-password" />
<button type="button" class="pw-toggle" @click="showConfirmPw = !showConfirmPw" tabindex="-1">
<Eye v-if="!showConfirmPw" :size="16" />
<EyeOff v-else :size="16" />
</button>
</div>
</div>
<p v-if="passwordError" class="msg error">{{ passwordError }}</p>
<p v-else-if="passwordSuccess" class="msg success">
<CheckCircle :size="14" /> Passwort erfolgreich geändert.
</p>
<button type="submit" class="btn-primary" :disabled="changingPassword">
<Lock :size="14" /> {{ changingPassword ? 'Ändert…' : 'Passwort ändern' }}
</button>
</form>
</section>
<!-- ── Admin: User Management (owner & admin) ── -->
<section v-if="canManageUsers" class="glass-card admin-card">
<div class="card-head">
<Shield :size="18" />
<h2>Benutzerverwaltung</h2>
<button class="btn-primary btn-sm create-user-btn" @click="showCreateUser = true">
<Plus :size="13" /> Benutzer anlegen
</button>
</div>
<p v-if="usersError" class="msg error">{{ usersError }}</p>
<div v-if="usersLoading" class="loading-pulse">
<div class="pulse-bar"></div>
<div class="pulse-bar"></div>
<div class="pulse-bar"></div>
</div>
<div v-else-if="users.length === 0" class="empty-state">
<Users :size="32" />
<p>Noch keine Benutzer. Lege den ersten an.</p>
</div>
<div v-else class="user-list">
<div v-for="user in users" :key="user.id" class="user-row">
<div class="user-avatar">
{{ user.displayName.charAt(0).toUpperCase() }}
</div>
<div class="user-info">
<div class="user-name">{{ user.displayName }}</div>
<div class="user-email">
<Mail :size="11" /> {{ user.email }}
</div>
</div>
<div class="user-meta">
<span class="role-tag" :class="user.role">{{ user.role }}</span>
<span class="user-date">
Seit {{ formatDate(user.createdAt) }}
</span>
</div>
</div>
</div>
<!-- Create User Modal -->
<Teleport to="body">
<div v-if="showCreateUser" class="modal-overlay" @click.self="showCreateUser = false">
<div class="modal-card">
<div class="modal-header">
<h2>Benutzer anlegen</h2>
<button class="modal-close" @click="showCreateUser = false">&times;</button>
</div>
<form @submit.prevent="handleCreateUser" class="modal-form">
<div class="field">
<label>E-Mail <span class="req">*</span></label>
<input v-model="createEmail" type="email" class="galaxy-input" placeholder="user@noveria.net" required />
</div>
<div class="field">
<label>Passwort <span class="req">*</span> (min. 10 Zeichen)</label>
<input v-model="createPassword" type="password" class="galaxy-input" minlength="10" required />
</div>
<div class="field">
<label>Anzeigename (optional)</label>
<input v-model="createDisplayName" class="galaxy-input" placeholder="Max Mustermann" />
</div>
<div class="field">
<label>Rolle</label>
<select v-model="createRole" class="galaxy-input galaxy-select">
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
<!-- owner ist nicht über UI wählbar bleibt Sonderrolle -->
</select>
</div>
<p v-if="createError" class="msg error">{{ createError }}</p>
<p v-if="createSuccess" class="msg success">{{ createSuccess }}</p>
<div class="modal-actions">
<button type="button" class="btn-ghost" @click="showCreateUser = false">Abbrechen</button>
<button type="submit" class="btn-primary" :disabled="creatingUser">
<Plus :size="14" /> {{ creatingUser ? 'Erstelle…' : 'Benutzer anlegen' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</section>
</div>
</div>
</template>
<style scoped>
.settings-wrap {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 20px;
animation: fadeIn 0.35s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.settings-heading {
margin-bottom: 24px;
}
.settings-heading h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
font-family: 'Space Grotesk', sans-serif;
letter-spacing: -0.02em;
}
.grad-text {
background: linear-gradient(135deg, var(--a-blue), var(--a-purple));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.settings-subtitle {
margin: 4px 0 0;
font-size: 11px;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
}
.settings-grid {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Glass Card ─────────────────────────────── */
.glass-card {
background: linear-gradient(160deg, rgba(20, 17, 48, 0.75), rgba(14, 12, 32, 0.75));
border: 1px solid rgba(150, 140, 255, 0.10);
border-radius: 18px;
padding: 24px;
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
gap: 18px;
}
.card-head {
display: flex;
align-items: center;
gap: 10px;
color: var(--tx);
padding-bottom: 14px;
border-bottom: 1px solid rgba(150, 140, 255, 0.08);
}
.card-head h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
font-family: 'Space Grotesk', sans-serif;
}
.create-user-btn {
margin-left: auto;
}
/* ── Fields ──────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 10px;
font-weight: 600;
color: var(--tx-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field-row {
display: flex;
gap: 24px;
}
.field-row > .field {
flex: 1;
}
.galaxy-input {
width: 100%;
padding: 10px 14px;
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 11px;
background: rgba(10, 9, 24, 0.55);
color: var(--tx);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.galaxy-input:focus {
border-color: rgba(124, 108, 255, 0.5);
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
}
.galaxy-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236f6aa0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.galaxy-select option {
background: var(--space-3);
color: var(--tx);
}
.value-tag {
font-size: 14px;
color: var(--tx);
font-weight: 500;
}
.value-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.role-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
background: linear-gradient(135deg, rgba(79, 124, 255, 0.15), rgba(181, 87, 246, 0.15));
color: var(--a-mid);
border: 1px solid rgba(124, 108, 255, 0.15);
width: fit-content;
}
/* ── Name Edit ──────────────────────────────────── */
.name-edit {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-group {
display: flex;
gap: 8px;
align-items: center;
}
.input-group .galaxy-input {
flex: 1;
}
/* ── Password ────────────────────────────────── */
.password-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.pw-wrap {
position: relative;
display: flex;
align-items: center;
}
.pw-wrap .galaxy-input {
padding-right: 44px;
}
.pw-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: transparent;
color: var(--tx-3);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pw-toggle:hover {
background: rgba(124, 108, 255, 0.08);
color: var(--tx-2);
}
/* ── Messages ──────────────────────────────────── */
.msg {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 12px;
padding: 8px 12px;
border-radius: 8px;
}
.msg.error {
background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.2);
color: var(--st-block);
}
.msg.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--st-work);
}
/* ── Buttons ──────────────────────────────────── */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, var(--a-blue), var(--a-mid), var(--a-purple));
color: #fff;
font-size: 12.5px;
font-weight: 600;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
width: fit-content;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 11px;
border-radius: 8px;
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 11px;
font-family: 'Manrope', sans-serif;
background: transparent;
border: 1px solid rgba(150, 140, 255, 0.12);
color: var(--tx-2);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-ghost:hover {
background: rgba(124, 108, 255, 0.08);
color: var(--tx);
}
/* ── Admin User Management ───────────────────── */
.admin-card {
border-color: rgba(124, 108, 255, 0.15);
}
.user-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(10, 9, 24, 0.35);
border: 1px solid rgba(150, 140, 255, 0.06);
transition: background 0.15s, border-color 0.15s;
}
.user-row:hover {
background: rgba(124, 108, 255, 0.05);
border-color: rgba(150, 140, 255, 0.12);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(181, 87, 246, 0.2));
border: 1px solid rgba(124, 108, 255, 0.15);
color: var(--a-mid);
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 600;
color: var(--tx);
}
.user-email {
font-size: 11px;
color: var(--tx-3);
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
.user-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.role-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(124, 108, 255, 0.1);
color: var(--a-mid);
border: 1px solid rgba(124, 108, 255, 0.1);
}
.role-tag.owner {
background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(251, 191, 36, 0.15));
color: var(--st-queue);
border-color: rgba(251, 191, 36, 0.2);
}
.role-tag.admin {
background: rgba(79, 124, 255, 0.12);
color: var(--a-blue);
border-color: rgba(79, 124, 255, 0.2);
}
.role-tag.viewer {
background: rgba(107, 103, 150, 0.15);
color: var(--tx-2);
border-color: rgba(107, 103, 150, 0.2);
}
.user-date {
font-size: 10px;
color: var(--tx-3);
}
.loading-pulse {
display: flex;
flex-direction: column;
gap: 8px;
}
.pulse-bar {
height: 52px;
border-radius: 12px;
background: linear-gradient(90deg, rgba(124, 108, 255, 0.04), rgba(124, 108, 255, 0.1), rgba(124, 108, 255, 0.04));
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px;
color: var(--tx-3);
}
.empty-state p {
margin: 0;
font-size: 12px;
}
/* ── Modal ────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5, 4, 16, 0.75);
backdrop-filter: blur(16px);
}
.modal-card {
width: 100%;
max-width: 460px;
background: linear-gradient(160deg, rgba(20, 17, 48, 0.92), rgba(14, 12, 32, 0.92));
border: 1px solid rgba(150, 140, 255, 0.18);
border-radius: 18px;
padding: 24px;
box-shadow: 0 0 0 1px rgba(124, 108, 255, 0.12), 0 20px 60px -12px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(150, 140, 255, 0.08);
}
.modal-header h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--tx);
font-family: 'Space Grotesk', sans-serif;
}
.modal-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--tx-3);
cursor: pointer;
border-radius: 6px;
font-size: 20px;
transition: background 0.15s, color 0.15s;
}
.modal-close:hover {
background: rgba(124, 108, 255, 0.08);
color: var(--tx);
}
.req {
color: var(--st-block);
}
.modal-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
</style>