86ceb2bcce
- 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>
893 lines
24 KiB
Vue
893 lines
24 KiB
Vue
<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 & 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">×</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>
|