feat: Multi-User/Admin usermanagement + Galaxy Login/Settings + Task detail improvements
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 4s

- Backend: NEW AdminController with user CRUD (GET/POST/DELETE /api/v1/admin/users)
- Backend: NEW GET /api/dashboard/tasks/{id} single task endpoint
- Backend: NEW POST /api/dashboard/tasks/{id}/activity comment endpoint
- Backend: IUserRepository + UserRepository extended with GetAllAsync, DeleteAsync
- Backend: Admin DTOs (AdminUserInfo, AdminCreateUserRequest, AdminUpdateRoleRequest)
- Frontend: NEW TaskDetailView.vue — URL-based (/tasks/:id) Galaxy-themed task detail
  with subtask create/edit/delete, activity with comments, property sidebar
- Frontend: LoginView.vue — полностью Galaxy theme redesign with GalaxyBackground,
  glass-morphism card, password toggle, consistent brand
- Frontend: SettingsView.vue — Galaxy theme redesign with glass cards,
  admin user management section (create/list users, visible only to owner role)
- Frontend: TaskBoardView.vue — added "Full View" link to URL-based detail page
- Frontend: Router — added /tasks/:id route for TaskDetailView
- Frontend: App.vue — added TaskDetail to standaloneViews whitelist
- Frontend: tasks store — stable

Auth: Admin creates accounts, users log in with existing /api/v1/auth/login.
Login/Settings deliver visible Galaxy-consistent design with nexus-tokens.css tokens.
This commit is contained in:
2026-06-20 14:24:40 +02:00
parent dcc8450c62
commit e4091eee80
15 changed files with 2950 additions and 701 deletions
+343 -31
View File
@@ -1,15 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Command, LockKeyhole } from '@lucide/vue'
/**
* LoginView Nexus Mission Control V2 Galaxy Theme
*
* Vollbild-Login mit GalaxyBackground, Glassmorphismus,
* und Consistent Branding.
*/
import { onMounted, onUnmounted, ref } from 'vue'
import { Mail, LockKeyhole, Command, Eye, EyeOff } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const email = ref('')
const password = ref('')
const error = ref('')
const showPassword = ref(false)
async function submit() {
error.value = ''
@@ -20,42 +29,345 @@ async function submit() {
: '/dashboard'
await router.replace(target)
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Login failed.'
error.value = reason instanceof Error ? reason.message : 'Login fehlgeschlagen.'
}
}
</script>
<template>
<main class="login-page">
<section class="login-card">
<div class="login-container">
<GalaxyBackground />
<div class="login-content">
<!-- Branding -->
<div class="login-brand">
<div class="brand-mark"><Command :size="20" /></div>
<div><strong>NEXUS</strong><span>Noveria Operations</span></div>
<div class="brand-icon">
<Command :size="22" />
</div>
<span class="brand-name">NEXUS</span>
<span class="brand-sub">Mission Control · Noveria</span>
</div>
<div class="login-heading">
<span class="eyebrow">OWNER ACCESS</span>
<h1>Sign in to mission control</h1>
<p>Use your private owner credentials to continue.</p>
<!-- Login Card -->
<div class="login-card">
<div class="card-header">
<span class="eyebrow">AUTHENTIFIZIERUNG</span>
<h1>Anmelden</h1>
<p>Gib deine Zugangsdaten ein, um auf das Mission Control zuzugreifen.</p>
</div>
<form @submit.prevent="submit" class="login-form">
<div class="field">
<label for="email">
<Mail :size="14" />
<span>E-Mail</span>
</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="username"
required
maxlength="120"
placeholder="name@noveria.net"
class="field-input"
/>
</div>
<div class="field">
<label for="password">
<LockKeyhole :size="14" />
<span>Passwort</span>
</label>
<div class="password-wrap">
<input
id="password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
required
minlength="10"
maxlength="200"
placeholder="••••••••••"
class="field-input"
/>
<button
type="button"
class="toggle-pw"
@click="showPassword = !showPassword"
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
tabindex="-1"
>
<Eye v-if="!showPassword" :size="16" />
<EyeOff v-else :size="16" />
</button>
</div>
</div>
<p v-if="error" class="login-error" role="alert">
{{ error }}
</p>
<button type="submit" class="submit-btn" :disabled="auth.loading || !email || !password">
<LockKeyhole :size="15" />
{{ auth.loading ? 'Anmelden…' : 'Anmelden' }}
</button>
</form>
<footer class="card-footer">
<span class="lock-icon">🔒</span>
<span>Gesicherte Sitzung · Refresh-Token im HTTP-only Cookie</span>
</footer>
</div>
<form @submit.prevent="submit">
<label>
<span>Email</span>
<input v-model="email" type="email" autocomplete="username" required maxlength="120" />
</label>
<label>
<span>Password</span>
<input v-model="password" type="password" autocomplete="current-password" required minlength="10" maxlength="200" />
</label>
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
<button type="submit" :disabled="auth.loading">
<LockKeyhole :size="15" />
{{ auth.loading ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<footer>Protected owner session · Refresh token stored in a secure HTTP-only cookie</footer>
</section>
</main>
</div>
</div>
</template>
<style scoped>
.login-container {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.login-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
max-width: 420px;
width: 90%;
animation: login-fade-in 0.5s ease-out;
}
@keyframes login-fade-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Branding ─────────────────────── */
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.brand-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
background: linear-gradient(135deg, #4f7cff, #b557f6);
box-shadow: 0 0 32px -4px rgba(124, 108, 255, 0.6);
color: #fff;
margin-bottom: 4px;
}
.brand-name {
font-family: 'Space Grotesk', sans-serif;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.2em;
color: #ece9ff;
line-height: 1;
}
.brand-sub {
font-size: 12px;
color: #6f6aa0;
letter-spacing: 0.05em;
margin-top: 2px;
}
/* ── Login Card ────────────────────── */
.login-card {
width: 100%;
background: linear-gradient(160deg, rgba(20, 17, 48, 0.88), rgba(14, 12, 32, 0.88));
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 20px;
padding: 32px 28px;
backdrop-filter: blur(16px);
box-shadow: 0 0 0 1px rgba(124, 108, 255, 0.06), 0 24px 80px -12px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
gap: 24px;
}
.card-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.eyebrow {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.15em;
color: #7c6cff;
text-transform: uppercase;
}
.card-header h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: 24px;
font-weight: 700;
margin: 0;
color: #ece9ff;
letter-spacing: -0.02em;
}
.card-header p {
margin: 0;
font-size: 13px;
color: #6f6aa0;
line-height: 1.5;
}
/* ── Form ──────────────────────────── */
.login-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: #a8a3d6;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.field-input {
width: 100%;
padding: 11px 14px;
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 12px;
background: rgba(10, 9, 24, 0.55);
color: #ece9ff;
font-size: 14px;
font-family: 'Manrope', sans-serif;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.field-input::placeholder {
color: #4a4680;
}
.field-input:focus {
border-color: rgba(124, 108, 255, 0.5);
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
}
.password-wrap {
position: relative;
display: flex;
align-items: center;
}
.password-wrap .field-input {
padding-right: 44px;
}
.toggle-pw {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: transparent;
color: #6f6aa0;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.toggle-pw:hover {
background: rgba(124, 108, 255, 0.08);
color: #a8a3d6;
}
.login-error {
margin: 0;
padding: 10px 14px;
border-radius: 10px;
background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.2);
color: #fda4af;
font-size: 12.5px;
line-height: 1.4;
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 13px 20px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
color: #fff;
font-size: 14px;
font-weight: 700;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s, box-shadow 0.2s;
box-shadow: 0 0 24px -6px rgba(124, 108, 255, 0.5);
}
.submit-btn:hover:not(:disabled) {
opacity: 0.92;
transform: translateY(-1px);
box-shadow: 0 0 32px -4px rgba(124, 108, 255, 0.65);
}
.submit-btn:active:not(:disabled) {
transform: translateY(0);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
/* ── Footer ────────────────────────── */
.card-footer {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
border-top: 1px solid rgba(150, 140, 255, 0.08);
font-size: 10.5px;
color: #6f6aa0;
}
.lock-icon {
font-size: 13px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff