refactor(admin): fuse admin-* cards into one tabbed admin card

User feedback: four separate admin cards (admin-users, admin-system,
admin-user-data + admin) bloated the scene-picker without adding value
— they're one logical power-user surface split four ways. Fuse them
into a single admin card with an internal tab switcher.

- lib/modules/admin/tabs/{Overview,Users,System,UserData}Tab.svelte —
  each tab owns its own data + styles
- lib/modules/admin/ListView.svelte is now a tabbed container: one
  role-guard, one pill-row, deep-linkable via `initialTab` prop
- /admin, /admin/users, /admin/system, /admin/user-data routes pass
  the corresponding initialTab so direct URLs still land on the right
  section
- Delete lib/modules/admin-{users,system,user-data}/ + three
  registerApp entries
- Complexity stays a separate card (different shape — iframe-heavy,
  was already its own card before this batch)

Smoketest: all 5 /admin/* routes respond 200; type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 15:04:12 +02:00
parent 3e65637fcb
commit 43b4570e69
10 changed files with 597 additions and 641 deletions

View file

@ -1165,36 +1165,6 @@ registerApp({
},
});
registerApp({
id: 'admin-users',
name: 'Admin · Users',
color: '#EF4444',
icon: AddressBook,
views: {
list: { load: () => import('$lib/modules/admin-users/ListView.svelte') },
},
});
registerApp({
id: 'admin-system',
name: 'Admin · System',
color: '#EF4444',
icon: HardDrives,
views: {
list: { load: () => import('$lib/modules/admin-system/ListView.svelte') },
},
});
registerApp({
id: 'admin-user-data',
name: 'Admin · User Data',
color: '#EF4444',
icon: File,
views: {
list: { load: () => import('$lib/modules/admin-user-data/ListView.svelte') },
},
});
registerApp({
id: 'complexity',
name: 'Complexity',

View file

@ -1,264 +1,150 @@
<!--
Admin — Workbench-embedded admin dashboard with stats, security overview,
and quick links to monitoring tools.
Admin — workbench card with tabs.
One card, four tabs: Overview / Users / System / User Data. Having all
admin surfaces under one id keeps the scene-picker uncluttered while
letting power-users arrange them inside the workbench like any other
card. The `initialTab` prop lets the /admin/* route wrappers deep-link
directly to a tab.
Admin-role guard lives here at the container level so a non-admin
sees one gate-screen, not four.
-->
<script lang="ts">
import { onMount } from 'svelte';
import StatCard from '$lib/components/admin/StatCard.svelte';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
import { adminService, type AdminStats } from '$lib/api/services/admin';
import { authStore } from '$lib/stores/auth.svelte';
import { ShieldWarning } from '@mana/shared-icons';
import OverviewTab from './tabs/OverviewTab.svelte';
import UsersTab from './tabs/UsersTab.svelte';
import SystemTab from './tabs/SystemTab.svelte';
import UserDataTab from './tabs/UserDataTab.svelte';
let stats = $state<AdminStats | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
type TabId = 'overview' | 'users' | 'system' | 'user-data';
const quickLinks = [
{
name: 'Grafana Dashboard',
url: 'https://grafana.mana.how',
description: 'System & Backend Metrics',
icon: 'grafana' as const,
},
{
name: 'Umami Analytics',
url: 'https://stats.mana.how',
description: 'Web Analytics',
icon: 'analytics' as const,
},
{
name: 'Docker Dashboard',
url: 'https://grafana.mana.how/d/backends-docker',
description: 'Container Metrics',
icon: 'docker' as const,
},
{
name: 'System Overview',
url: 'https://grafana.mana.how/d/system-overview',
description: 'CPU, Memory, Disk',
icon: 'grafana' as const,
},
interface Props {
initialTab?: TabId;
}
let { initialTab = 'overview' }: Props = $props();
let activeTab = $state<TabId>(initialTab);
let isAdmin = $derived(authStore.user?.role === 'admin');
const tabs: { id: TabId; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'system', label: 'System' },
{ id: 'user-data', label: 'User Data' },
];
onMount(async () => {
const result = await adminService.getStats();
if (result.error) {
error = result.error;
} else {
stats = result.data;
}
loading = false;
});
let userGrowthPercent = $derived(
stats
? Math.round((stats.newUsers7d / Math.max(stats.totalUsers - stats.newUsers7d, 1)) * 100)
: 0
);
</script>
<div class="admin-page">
<!-- Stats Grid -->
<div class="stats-grid">
<StatCard title="Total Users" value={stats?.totalUsers ?? '-'} icon="users" {loading} />
<StatCard
title="New Users (7d)"
value={stats?.newUsers7d ?? '-'}
change={userGrowthPercent}
changeLabel="vs previous"
icon="users"
{loading}
/>
<StatCard
title="Active Sessions"
value={stats?.activeSessions ?? '-'}
icon="activity"
{loading}
/>
<StatCard
title="Unique Users (24h)"
value={stats?.uniqueUsers24h ?? '-'}
icon="clock"
{loading}
/>
{#if !isAdmin}
<div class="admin-gate">
<ShieldWarning size={40} />
<h3>Admin-only</h3>
<p>Das Admin-Dashboard ist nur für Admin-Nutzer sichtbar.</p>
</div>
{:else}
<div class="admin-card">
<div class="tabs" role="tablist" aria-label="Admin sections">
{#each tabs as tab}
<button
type="button"
role="tab"
class="tab"
class:active={activeTab === tab.id}
aria-selected={activeTab === tab.id}
onclick={() => (activeTab = tab.id)}
>
{tab.label}
</button>
{/each}
</div>
<!-- Security & Quick Links -->
<div class="panels">
<!-- Security Overview -->
<div class="panel">
<h3 class="panel-title">Security (Last 7 Days)</h3>
{#if loading}
<div class="loading-rows">
<div class="loading-bar"></div>
<div class="loading-bar short"></div>
</div>
{:else if stats}
<div class="security-rows">
<div class="security-row">
<div class="security-label">
<span class="dot green"></span>
<span>Successful Logins</span>
</div>
<span class="security-value">{stats.loginSuccess7d}</span>
</div>
<div class="security-row">
<div class="security-label">
<span class="dot red"></span>
<span>Failed Logins</span>
</div>
<span class="security-value">{stats.loginFailed7d}</span>
</div>
<div class="security-divider"></div>
<div class="security-row">
<span class="security-muted">Success Rate</span>
<span class="security-rate">
{stats.loginSuccess7d + stats.loginFailed7d > 0
? Math.round(
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
)
: '—'}%
</span>
</div>
</div>
<div class="tab-content">
{#if activeTab === 'overview'}
<OverviewTab />
{:else if activeTab === 'users'}
<UsersTab />
{:else if activeTab === 'system'}
<SystemTab />
{:else if activeTab === 'user-data'}
<UserDataTab />
{/if}
</div>
<QuickLinks links={quickLinks} />
</div>
{#if error}
<div class="error-box">
<p>{error}</p>
</div>
{/if}
</div>
{/if}
<style>
.admin-page {
padding: 0.75rem;
.admin-gate {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
height: 100%;
overflow-y: auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.625rem;
}
.panels {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.panel {
padding: 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
}
.panel-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin-bottom: 0.75rem;
}
.loading-rows {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.loading-bar {
height: 0.875rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: 0.25rem;
animation: pulse 1.5s ease-in-out infinite;
}
.loading-bar.short {
width: 75%;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.security-rows {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.security-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.security-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.dot.green {
background: hsl(142 71% 45%);
}
.dot.red {
background: hsl(0 84% 60%);
}
.security-value {
font-family: monospace;
font-size: 0.8125rem;
}
.security-divider {
border-top: 1px solid hsl(var(--color-border));
padding-top: 0.375rem;
}
.security-muted {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.security-rate {
font-size: 0.8125rem;
.admin-gate h3 {
font-size: 1rem;
font-weight: 500;
color: hsl(142 71% 45%);
margin: 0;
color: hsl(var(--color-foreground));
}
.error-box {
padding: 0.75rem;
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.5rem;
background: hsl(0 84% 60% / 0.08);
.admin-gate p {
font-size: 0.875rem;
max-width: 24rem;
margin: 0;
}
.error-box p {
font-size: 0.8125rem;
color: hsl(0 84% 60%);
.admin-card {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border));
padding-bottom: 0;
overflow-x: auto;
}
.tab {
padding: 0.5rem 0.875rem;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
white-space: nowrap;
}
.tab:hover {
color: hsl(var(--color-foreground));
}
.tab.active {
color: hsl(var(--color-foreground));
border-bottom-color: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
}
.tab-content {
flex: 1;
min-height: 0;
}
</style>

View file

@ -0,0 +1,239 @@
<!--
Admin → Overview tab.
Stats grid + security (last 7d) + monitoring quick-links.
Extracted from the former admin/ListView.svelte so it can render as
one of four tabs alongside Users, System, User Data.
-->
<script lang="ts">
import { onMount } from 'svelte';
import StatCard from '$lib/components/admin/StatCard.svelte';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
import { adminService, type AdminStats } from '$lib/api/services/admin';
let stats = $state<AdminStats | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
const quickLinks = [
{
name: 'Grafana Dashboard',
url: 'https://grafana.mana.how',
description: 'System & Backend Metrics',
icon: 'grafana' as const,
},
{
name: 'Umami Analytics',
url: 'https://stats.mana.how',
description: 'Web Analytics',
icon: 'analytics' as const,
},
{
name: 'Docker Dashboard',
url: 'https://grafana.mana.how/d/backends-docker',
description: 'Container Metrics',
icon: 'docker' as const,
},
{
name: 'System Overview',
url: 'https://grafana.mana.how/d/system-overview',
description: 'CPU, Memory, Disk',
icon: 'grafana' as const,
},
];
onMount(async () => {
const result = await adminService.getStats();
if (result.error) {
error = result.error;
} else {
stats = result.data;
}
loading = false;
});
let userGrowthPercent = $derived(
stats
? Math.round((stats.newUsers7d / Math.max(stats.totalUsers - stats.newUsers7d, 1)) * 100)
: 0
);
</script>
<div class="overview">
<div class="stats-grid">
<StatCard title="Total Users" value={stats?.totalUsers ?? '-'} icon="users" {loading} />
<StatCard
title="New Users (7d)"
value={stats?.newUsers7d ?? '-'}
change={userGrowthPercent}
changeLabel="vs previous"
icon="users"
{loading}
/>
<StatCard
title="Active Sessions"
value={stats?.activeSessions ?? '-'}
icon="activity"
{loading}
/>
<StatCard
title="Unique Users (24h)"
value={stats?.uniqueUsers24h ?? '-'}
icon="clock"
{loading}
/>
</div>
<div class="panels">
<div class="panel">
<h3 class="panel-title">Security (Last 7 Days)</h3>
{#if loading}
<div class="loading-rows">
<div class="loading-bar"></div>
<div class="loading-bar short"></div>
</div>
{:else if stats}
<div class="security-rows">
<div class="security-row">
<div class="security-label">
<span class="dot green"></span>
<span>Successful Logins</span>
</div>
<span class="security-value">{stats.loginSuccess7d}</span>
</div>
<div class="security-row">
<div class="security-label">
<span class="dot red"></span>
<span>Failed Logins</span>
</div>
<span class="security-value">{stats.loginFailed7d}</span>
</div>
<div class="security-divider"></div>
<div class="security-row">
<span class="security-muted">Success Rate</span>
<span class="security-rate">
{stats.loginSuccess7d + stats.loginFailed7d > 0
? Math.round(
(stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100
)
: '—'}%
</span>
</div>
</div>
{/if}
</div>
<QuickLinks links={quickLinks} />
</div>
{#if error}
<div class="error-box"><p>{error}</p></div>
{/if}
</div>
<style>
.overview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.625rem;
}
.panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
}
.panel {
padding: 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
}
.panel-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 0.75rem;
}
.loading-rows {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.loading-bar {
height: 0.875rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: 0.25rem;
animation: pulse 1.5s ease-in-out infinite;
}
.loading-bar.short {
width: 75%;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.security-rows {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.security-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.security-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.dot.green {
background: hsl(142 71% 45%);
}
.dot.red {
background: hsl(0 84% 60%);
}
.security-value {
font-family: monospace;
font-size: 0.8125rem;
}
.security-divider {
border-top: 1px solid hsl(var(--color-border));
padding-top: 0.375rem;
}
.security-muted {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.security-rate {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(142 71% 45%);
}
.error-box {
padding: 0.75rem;
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.5rem;
background: hsl(0 84% 60% / 0.08);
}
.error-box p {
font-size: 0.8125rem;
color: hsl(0 84% 60%);
margin: 0;
}
</style>

View file

@ -1,16 +1,10 @@
<!--
Admin → System — workbench card.
Service-health grid + quick-links to Grafana/Prometheus/Umami +
environment info. Self-hides for non-admin users.
Service list is still mock data — swap for a /api/admin/health sweep
once the endpoint exists.
Admin → System tab.
Service-health grid + monitoring quick-links + environment info.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { ArrowSquareOut, ShieldWarning } from '@mana/shared-icons';
import { ArrowSquareOut } from '@mana/shared-icons';
import QuickLinks from '$lib/components/admin/QuickLinks.svelte';
interface ServiceHealth {
@ -20,7 +14,6 @@
lastCheck?: string;
}
let isAdmin = $derived(authStore.user?.role === 'admin');
let services = $state<ServiceHealth[]>([]);
let loading = $state(true);
@ -66,10 +59,6 @@
};
onMount(async () => {
if (!isAdmin) {
loading = false;
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
services = [
{ name: 'Mana Core Auth', status: 'healthy', url: 'https://auth.mana.how' },
@ -91,126 +80,91 @@
let totalCount = $derived(services.length);
</script>
{#if !isAdmin}
<div class="admin-gate">
<ShieldWarning size={40} />
<h3>Admin-only</h3>
<p>Die System-Übersicht ist nur für Admin-Nutzer sichtbar.</p>
</div>
{:else}
<div class="pane">
<section class="panel">
<header class="panel-header">
<h3>System Status</h3>
{#if !loading}
<div class="status-summary">
<span
class="dot"
style:background={healthyCount === totalCount
? statusColors.healthy
: statusColors.degraded}
></span>
<span class="status-count">{healthyCount}/{totalCount} healthy</span>
</div>
{/if}
</header>
{#if loading}
<div class="grid">
{#each Array(8) as _}
<div class="skeleton"></div>
{/each}
</div>
{:else}
<div class="grid">
{#each services as service}
<div class="service">
<span class="dot" style:background={statusColors[service.status]}></span>
<div class="service-info">
<p class="service-name">{service.name}</p>
<p class="service-status">{statusLabels[service.status]}</p>
</div>
{#if service.url !== '-'}
<a href={service.url} target="_blank" rel="noopener noreferrer" class="link">
<ArrowSquareOut size={14} />
</a>
{/if}
</div>
{/each}
<div class="system-tab">
<section class="panel">
<header class="panel-header">
<h3>System Status</h3>
{#if !loading}
<div class="status-summary">
<span
class="dot"
style:background={healthyCount === totalCount
? statusColors.healthy
: statusColors.degraded}
></span>
<span class="status-count">{healthyCount}/{totalCount} healthy</span>
</div>
{/if}
</section>
</header>
<QuickLinks links={monitoringLinks} />
{#if loading}
<div class="grid">
{#each Array(8) as _}
<div class="skeleton"></div>
{/each}
</div>
{:else}
<div class="grid">
{#each services as service}
<div class="service">
<span class="dot" style:background={statusColors[service.status]}></span>
<div class="service-info">
<p class="service-name">{service.name}</p>
<p class="service-status">{statusLabels[service.status]}</p>
</div>
{#if service.url !== '-'}
<a href={service.url} target="_blank" rel="noopener noreferrer" class="link">
<ArrowSquareOut size={14} />
</a>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<section class="panel">
<h3>Environment</h3>
<div class="env-grid">
<div class="env-col">
<div class="env-row">
<span class="env-label">Server</span>
<code>Mac Mini (mana.how)</code>
</div>
<div class="env-row">
<span class="env-label">Domain</span>
<code>*.mana.how</code>
</div>
<div class="env-row">
<span class="env-label">SSL</span>
<code class="ok">Caddy (Auto)</code>
</div>
<QuickLinks links={monitoringLinks} />
<section class="panel">
<h3>Environment</h3>
<div class="env-grid">
<div class="env-col">
<div class="env-row">
<span class="env-label">Server</span>
<code>Mac Mini (mana.how)</code>
</div>
<div class="env-col">
<div class="env-row">
<span class="env-label">Database</span>
<code>PostgreSQL 16</code>
</div>
<div class="env-row">
<span class="env-label">Cache</span>
<code>Redis 7</code>
</div>
<div class="env-row">
<span class="env-label">Tunnel</span>
<code>Cloudflare</code>
</div>
<div class="env-row">
<span class="env-label">Domain</span>
<code>*.mana.how</code>
</div>
<div class="env-row">
<span class="env-label">SSL</span>
<code class="ok">Caddy (Auto)</code>
</div>
</div>
</section>
</div>
{/if}
<div class="env-col">
<div class="env-row">
<span class="env-label">Database</span>
<code>PostgreSQL 16</code>
</div>
<div class="env-row">
<span class="env-label">Cache</span>
<code>Redis 7</code>
</div>
<div class="env-row">
<span class="env-label">Tunnel</span>
<code>Cloudflare</code>
</div>
</div>
</div>
</section>
</div>
<style>
.admin-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
height: 100%;
color: hsl(var(--color-muted-foreground));
}
.admin-gate h3 {
font-size: 1rem;
font-weight: 500;
margin: 0;
color: hsl(var(--color-foreground));
}
.admin-gate p {
font-size: 0.875rem;
max-width: 24rem;
margin: 0;
}
.pane {
padding: 1rem;
.system-tab {
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.panel {

View file

@ -1,21 +1,13 @@
<!--
Admin → User Data — workbench card.
Real API-backed user list (adminService.getUsers) with search and
pagination. Opens the existing detail route /admin/user-data/[userId]
when the user clicks "Daten anzeigen" — that page stays route-based
because it's an entity-detail view keyed by id.
Admin-gated inline.
Admin → User Data tab.
Real API-backed user browser (adminService.getUsers). The per-user
detail route /admin/user-data/[userId] stays route-based.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { adminService, type UserListItem } from '$lib/api/services/admin';
import { MagnifyingGlass, ShieldWarning } from '@mana/shared-icons';
let isAdmin = $derived(authStore.user?.role === 'admin');
import { MagnifyingGlass } from '@mana/shared-icons';
let users = $state<UserListItem[]>([]);
let loading = $state(true);
@ -29,7 +21,6 @@
let totalPages = $derived(Math.ceil(total / limit));
async function loadUsers() {
if (!isAdmin) return;
loading = true;
error = null;
@ -78,155 +69,125 @@
}
onMount(() => {
if (isAdmin) loadUsers();
else loading = false;
loadUsers();
});
</script>
{#if !isAdmin}
<div class="admin-gate">
<ShieldWarning size={40} />
<h3>Admin-only</h3>
<p>Der Nutzerdaten-Browser ist nur für Admin-Nutzer sichtbar.</p>
</div>
{:else}
<div class="pane">
<header class="bar">
<div class="title">
<strong>Nutzerdaten</strong>
<span class="sub">{total} Nutzer</span>
</div>
<div class="search">
<MagnifyingGlass size={16} />
<input
type="text"
placeholder="Email oder Name…"
bind:value={searchQuery}
oninput={handleSearch}
/>
</div>
</header>
<div class="panel">
{#if loading}
<div class="loading">
{#each Array(5) as _}
<div class="loading-row">
<div class="avatar-skel"></div>
<div class="meta-skel">
<div class="bar-skel short"></div>
<div class="bar-skel"></div>
</div>
</div>
{/each}
</div>
{:else if error}
<div class="error-box">
<p>{error}</p>
<button type="button" onclick={() => loadUsers()}>Erneut versuchen</button>
</div>
{:else if users.length === 0}
<p class="empty">Keine Nutzer gefunden.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Nutzer</th>
<th>Rolle</th>
<th>Registriert</th>
<th>Letzte Aktivität</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>
<div class="user-cell">
<div class="avatar">
{(user.name || user.email)[0].toUpperCase()}
</div>
<div>
<p class="user-name">{user.name || '—'}</p>
<p class="user-email">{user.email}</p>
</div>
</div>
</td>
<td>
<span class="role-badge" class:role-admin={user.role === 'admin'}>
{user.role}
</span>
</td>
<td class="muted">{formatDate(user.createdAt)}</td>
<td class="muted">{formatRelativeTime(user.lastActiveAt)}</td>
<td class="action-cell">
<button
type="button"
onclick={() => goto(`/admin/user-data/${user.id}`)}
class="view-btn"
>
Daten anzeigen
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if totalPages > 1}
<div class="pagination">
<button
type="button"
onclick={() => {
page = Math.max(1, page - 1);
loadUsers();
}}
disabled={page === 1}>Zurück</button
>
<span>Seite {page} von {totalPages}</span>
<button
type="button"
onclick={() => {
page = Math.min(totalPages, page + 1);
loadUsers();
}}
disabled={page === totalPages}>Weiter</button
>
</div>
{/if}
{/if}
<div class="user-data-tab">
<div class="bar">
<div class="title">
<strong>Nutzerdaten</strong>
<span class="sub">{total} Nutzer</span>
</div>
<div class="search">
<MagnifyingGlass size={16} />
<input
type="text"
placeholder="Email oder Name…"
bind:value={searchQuery}
oninput={handleSearch}
/>
</div>
</div>
{/if}
<div class="panel">
{#if loading}
<div class="loading">
{#each Array(5) as _}
<div class="loading-row">
<div class="avatar-skel"></div>
<div class="meta-skel">
<div class="bar-skel short"></div>
<div class="bar-skel"></div>
</div>
</div>
{/each}
</div>
{:else if error}
<div class="error-box">
<p>{error}</p>
<button type="button" onclick={() => loadUsers()}>Erneut versuchen</button>
</div>
{:else if users.length === 0}
<p class="empty">Keine Nutzer gefunden.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Nutzer</th>
<th>Rolle</th>
<th>Registriert</th>
<th>Letzte Aktivität</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>
<div class="user-cell">
<div class="avatar">
{(user.name || user.email)[0].toUpperCase()}
</div>
<div>
<p class="user-name">{user.name || '—'}</p>
<p class="user-email">{user.email}</p>
</div>
</div>
</td>
<td>
<span class="role-badge" class:role-admin={user.role === 'admin'}>
{user.role}
</span>
</td>
<td class="muted">{formatDate(user.createdAt)}</td>
<td class="muted">{formatRelativeTime(user.lastActiveAt)}</td>
<td class="action-cell">
<button
type="button"
onclick={() => goto(`/admin/user-data/${user.id}`)}
class="view-btn"
>
Daten anzeigen
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if totalPages > 1}
<div class="pagination">
<button
type="button"
onclick={() => {
page = Math.max(1, page - 1);
loadUsers();
}}
disabled={page === 1}>Zurück</button
>
<span>Seite {page} von {totalPages}</span>
<button
type="button"
onclick={() => {
page = Math.min(totalPages, page + 1);
loadUsers();
}}
disabled={page === totalPages}>Weiter</button
>
</div>
{/if}
{/if}
</div>
</div>
<style>
.admin-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
height: 100%;
color: hsl(var(--color-muted-foreground));
}
.admin-gate h3 {
font-size: 1rem;
font-weight: 500;
margin: 0;
color: hsl(var(--color-foreground));
}
.pane {
padding: 1rem;
.user-data-tab {
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar {

View file

@ -1,18 +1,12 @@
<!--
Admin → Users — workbench card.
User search + paginated table. Reads from the same mock dataset the
legacy /admin/users route used; swap in `adminService.getUsers` once a
dedicated role-update endpoint ships.
Admin-gated inline so the card self-hides for non-admin users in
whatever workbench scene they picked.
Admin → Users tab.
Mock user search + paginated table (swap in adminService.getUsers once
a dedicated role-update endpoint ships).
-->
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import UserTable from '$lib/components/admin/UserTable.svelte';
import { MagnifyingGlass, ShieldWarning } from '@mana/shared-icons';
import { MagnifyingGlass } from '@mana/shared-icons';
interface User {
id: string;
@ -23,8 +17,6 @@
role: string;
}
let isAdmin = $derived(authStore.user?.role === 'admin');
let users = $state<User[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
@ -53,10 +45,6 @@
});
onMount(async () => {
if (!isAdmin) {
loading = false;
return;
}
try {
await new Promise((resolve) => setTimeout(resolve, 500));
users = [
@ -107,82 +95,48 @@
});
</script>
{#if !isAdmin}
<div class="admin-gate">
<ShieldWarning size={40} class="text-muted-foreground" />
<h3>Admin-only</h3>
<p>Die Nutzerverwaltung ist nur für Admin-Nutzer sichtbar.</p>
<div class="users-tab">
<div class="bar">
<div class="title">
<strong>Users</strong>
<span class="sub">{filteredUsers.length} / {users.length}</span>
</div>
<div class="search">
<MagnifyingGlass size={16} />
<input type="text" placeholder="Suche…" bind:value={searchQuery} />
</div>
</div>
{:else}
<div class="pane">
<header class="bar">
<div class="title">
<strong>Users</strong>
<span class="sub">{filteredUsers.length} / {users.length}</span>
</div>
<div class="search">
<MagnifyingGlass size={16} />
<input type="text" placeholder="Suche…" bind:value={searchQuery} />
</div>
</header>
<UserTable users={paginatedUsers} {loading} />
<UserTable users={paginatedUsers} {loading} />
{#if totalPages > 1}
<div class="pagination">
<span>Seite {currentPage} von {totalPages}</span>
<div class="buttons">
<button
type="button"
onclick={() => (currentPage = Math.max(1, currentPage - 1))}
disabled={currentPage === 1}>Zurück</button
>
<button
type="button"
onclick={() => (currentPage = Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}>Weiter</button
>
</div>
{#if totalPages > 1}
<div class="pagination">
<span>Seite {currentPage} von {totalPages}</span>
<div class="buttons">
<button
type="button"
onclick={() => (currentPage = Math.max(1, currentPage - 1))}
disabled={currentPage === 1}>Zurück</button
>
<button
type="button"
onclick={() => (currentPage = Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}>Weiter</button
>
</div>
{/if}
</div>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
</div>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
</div>
<style>
.admin-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
height: 100%;
}
.admin-gate h3 {
font-size: 1rem;
font-weight: 500;
margin: 0;
}
.admin-gate p {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 24rem;
margin: 0;
}
.pane {
padding: 1rem;
.users-tab {
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar {

View file

@ -1,10 +1,8 @@
<script lang="ts">
/**
* /admin — renders the workbench-card ListView.
* Admin-role guard lives in the parent layout and inside the ListView.
* /admin — renders the admin workbench card with the Overview tab.
*/
import ListView from '$lib/modules/admin/ListView.svelte';
</script>
<ListView />
<ListView initialTab="overview" />

View file

@ -1,10 +1,8 @@
<script lang="ts">
/**
* /admin/system — renders the workbench-card ListView.
* Admin-role guard lives in the parent layout and inside the ListView.
* /admin/system — deep-links into the System tab of the admin card.
*/
import ListView from '$lib/modules/admin-system/ListView.svelte';
import ListView from '$lib/modules/admin/ListView.svelte';
</script>
<ListView />
<ListView initialTab="system" />

View file

@ -1,11 +1,9 @@
<script lang="ts">
/**
* /admin/user-data — renders the workbench-card ListView.
* Admin-role guard lives in the parent layout and inside the ListView.
* /admin/user-data — deep-links into the User Data tab of the admin card.
* The per-user detail route /admin/user-data/[userId] stays route-based.
*/
import ListView from '$lib/modules/admin-user-data/ListView.svelte';
import ListView from '$lib/modules/admin/ListView.svelte';
</script>
<ListView />
<ListView initialTab="user-data" />

View file

@ -1,10 +1,8 @@
<script lang="ts">
/**
* /admin/users — renders the workbench-card ListView.
* Admin-role guard lives in the parent layout and inside the ListView.
* /admin/users — deep-links into the Users tab of the admin card.
*/
import ListView from '$lib/modules/admin-users/ListView.svelte';
import ListView from '$lib/modules/admin/ListView.svelte';
</script>
<ListView />
<ListView initialTab="users" />