mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 10:33:40 +02:00
✨ feat(admin): add user data dashboard for cross-project data visualization
Add comprehensive admin dashboard to view and manage user data across all projects: Backend: - Add admin endpoints to Chat, Todo, Contacts, Calendar, Picture, Zitare, Presi - Each backend exposes GET/DELETE /api/v1/admin/user-data/:userId - Service-to-service auth via X-Service-Key header Aggregation (mana-core-auth): - GET /api/v1/admin/users - Paginated user list with search - GET /api/v1/admin/users/:userId/data - Aggregated data from all backends - DELETE /api/v1/admin/users/:userId/data - GDPR deletion across all projects Frontend (ManaCore web): - New User Data tab in admin navigation - User search page at /admin/user-data - User detail page with ProjectDataCard components - GDPR deletion dialog with email confirmation Presi: - Migrate user_id from UUID to TEXT for Better Auth compatibility - Add SQL migration script
This commit is contained in:
parent
5b6f231e1a
commit
a2e2a5b73c
57 changed files with 3847 additions and 465 deletions
185
apps/manacore/apps/web/src/lib/api/services/admin.ts
Normal file
185
apps/manacore/apps/web/src/lib/api/services/admin.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Admin API Service
|
||||
*
|
||||
* Provides admin functionality for managing user data across all projects.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { createApiClient, type ApiResult } from '../base-client';
|
||||
|
||||
// Get Auth API URL dynamically at runtime (admin endpoints are on auth service)
|
||||
function getAuthApiUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
if (injectedUrl) {
|
||||
return `${injectedUrl}/api/v1`;
|
||||
}
|
||||
}
|
||||
return 'http://localhost:3001/api/v1';
|
||||
}
|
||||
|
||||
// Lazy-initialized client
|
||||
let _client: ReturnType<typeof createApiClient> | null = null;
|
||||
|
||||
function getClient() {
|
||||
if (!_client) {
|
||||
_client = createApiClient(getAuthApiUrl());
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
/**
|
||||
* User list item from admin API
|
||||
*/
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User list response with pagination
|
||||
*/
|
||||
export interface UserListResponse {
|
||||
users: UserListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity count for a project
|
||||
*/
|
||||
export interface EntityCount {
|
||||
entity: string;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project data summary
|
||||
*/
|
||||
export interface ProjectDataSummary {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
icon: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
entities: EntityCount[];
|
||||
totalCount: number;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User info
|
||||
*/
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth data summary
|
||||
*/
|
||||
export interface AuthDataSummary {
|
||||
sessionsCount: number;
|
||||
accountsCount: number;
|
||||
has2FA: boolean;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits data summary
|
||||
*/
|
||||
export interface CreditsDataSummary {
|
||||
balance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
transactionsCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full user data summary
|
||||
*/
|
||||
export interface UserDataSummary {
|
||||
user: UserInfo;
|
||||
auth: AuthDataSummary;
|
||||
credits: CreditsDataSummary;
|
||||
projects: ProjectDataSummary[];
|
||||
totals: {
|
||||
totalEntities: number;
|
||||
projectsWithData: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete result for a project
|
||||
*/
|
||||
export interface ProjectDeleteResult {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full delete response
|
||||
*/
|
||||
export interface DeleteUserDataResponse {
|
||||
success: boolean;
|
||||
deletedFromProjects: ProjectDeleteResult[];
|
||||
deletedFromAuth: {
|
||||
sessions: number;
|
||||
accounts: number;
|
||||
credits: number;
|
||||
user: boolean;
|
||||
};
|
||||
totalDeleted: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin service for user data management
|
||||
*/
|
||||
export const adminService = {
|
||||
/**
|
||||
* Get list of users with pagination and search
|
||||
*/
|
||||
async getUsers(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
search?: string
|
||||
): Promise<ApiResult<UserListResponse>> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
if (search) {
|
||||
params.set('search', search);
|
||||
}
|
||||
|
||||
return getClient().get<UserListResponse>(`/admin/users?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get aggregated user data from all projects
|
||||
*/
|
||||
async getUserData(userId: string): Promise<ApiResult<UserDataSummary>> {
|
||||
return getClient().get<UserDataSummary>(`/admin/users/${userId}/data`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all user data (GDPR right to be forgotten)
|
||||
*/
|
||||
async deleteUserData(userId: string): Promise<ApiResult<DeleteUserDataResponse>> {
|
||||
return getClient().delete<DeleteUserDataResponse>(`/admin/users/${userId}/data`);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import type { ProjectDataSummary } from '$lib/api/services/admin';
|
||||
|
||||
interface Props {
|
||||
project: ProjectDataSummary;
|
||||
}
|
||||
|
||||
let { project }: Props = $props();
|
||||
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'gerade eben';
|
||||
if (diffMins < 60) return `vor ${diffMins} Min`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Std`;
|
||||
if (diffDays < 7) return `vor ${diffDays} Tagen`;
|
||||
return new Date(dateStr).toLocaleDateString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border bg-card shadow-sm overflow-hidden {project.available
|
||||
? ''
|
||||
: 'opacity-60'}"
|
||||
>
|
||||
<div class="p-4 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">{project.icon}</span>
|
||||
<div>
|
||||
<h3 class="font-semibold">{project.projectName}</h3>
|
||||
{#if project.available}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{project.totalCount} Einträge
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-red-500">{project.error || 'Nicht verfügbar'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if project.available}
|
||||
<div
|
||||
class="h-2 w-2 rounded-full {project.totalCount > 0 ? 'bg-green-500' : 'bg-gray-300'}"
|
||||
></div>
|
||||
{:else}
|
||||
<div class="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if project.available}
|
||||
<div class="p-4">
|
||||
{#if project.entities.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each project.entities as entity}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{entity.label}</span>
|
||||
<span class="font-mono font-medium">{entity.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if project.lastActivityAt}
|
||||
<div class="mt-4 pt-3 border-t">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Letzte Aktivitat: {formatRelativeTime(project.lastActivityAt)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground text-center py-4">Keine Daten vorhanden</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-muted-foreground text-center py-2">Backend nicht erreichbar</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -7,12 +7,14 @@
|
|||
const tabs = [
|
||||
{ href: '/admin', label: 'Overview', icon: 'home' },
|
||||
{ href: '/admin/users', label: 'Users', icon: 'users' },
|
||||
{ href: '/admin/user-data', label: 'User Data', icon: 'database' },
|
||||
{ href: '/admin/system', label: 'System', icon: 'server' },
|
||||
];
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
home: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />`,
|
||||
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
|
||||
database: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />`,
|
||||
server: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { adminService, type UserListItem } from '$lib/api/services/admin';
|
||||
|
||||
let users = $state<UserListItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let page = $state(1);
|
||||
let total = $state(0);
|
||||
const limit = 20;
|
||||
|
||||
let totalPages = $derived(Math.ceil(total / limit));
|
||||
|
||||
async function loadUsers() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await adminService.getUsers(page, limit, searchQuery || undefined);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
users = [];
|
||||
} else if (result.data) {
|
||||
users = result.data.users;
|
||||
total = result.data.total;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
page = 1;
|
||||
loadUsers();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'gerade eben';
|
||||
if (diffMins < 60) return `vor ${diffMins} Min`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Std`;
|
||||
if (diffDays < 7) return `vor ${diffDays} Tagen`;
|
||||
return formatDate(dateStr);
|
||||
}
|
||||
|
||||
function viewUserData(userId: string) {
|
||||
goto(`/admin/user-data/${userId}`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Nutzerdaten</h1>
|
||||
<p class="text-muted-foreground">Durchsuche und analysiere Nutzerdaten aller Projekte</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nach Email oder Name suchen..."
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{total} Nutzer gefunden
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- User Table -->
|
||||
<div class="rounded-lg border bg-card shadow-sm overflow-hidden">
|
||||
<div class="p-4 border-b">
|
||||
<h3 class="text-lg font-semibold">Nutzer</h3>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="p-4 space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="animate-pulse flex items-center gap-4">
|
||||
<div class="h-10 w-10 bg-muted rounded-full"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-4 bg-muted rounded w-1/4 mb-2"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-8 text-center">
|
||||
<p class="text-red-500">{error}</p>
|
||||
<button
|
||||
onclick={() => loadUsers()}
|
||||
class="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Nutzer
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Rolle
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Registriert
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Letzte Aktivitat
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
{#each users as user}
|
||||
<tr class="hover:bg-muted/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-sm font-medium text-primary">
|
||||
{(user.name || user.email)[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-sm">{user.name || '-'}</p>
|
||||
<p class="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{user.role === 'admin'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}"
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatDate(user.createdAt)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatRelativeTime(user.lastActiveAt)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
onclick={() => viewUserData(user.id)}
|
||||
class="px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-md hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Daten anzeigen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if users.length === 0}
|
||||
<div class="p-8 text-center text-muted-foreground">Keine Nutzer gefunden</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="p-4 border-t flex items-center justify-between">
|
||||
<button
|
||||
onclick={() => {
|
||||
page = Math.max(1, page - 1);
|
||||
loadUsers();
|
||||
}}
|
||||
disabled={page === 1}
|
||||
class="px-3 py-1.5 text-sm border rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted"
|
||||
>
|
||||
Zuruck
|
||||
</button>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => {
|
||||
page = Math.min(totalPages, page + 1);
|
||||
loadUsers();
|
||||
}}
|
||||
disabled={page === totalPages}
|
||||
class="px-3 py-1.5 text-sm border rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import StatCard from '$lib/components/admin/StatCard.svelte';
|
||||
import ProjectDataCard from '$lib/components/admin/ProjectDataCard.svelte';
|
||||
import {
|
||||
adminService,
|
||||
type UserDataSummary,
|
||||
type DeleteUserDataResponse,
|
||||
} from '$lib/api/services/admin';
|
||||
|
||||
let userId = $derived($page.params.userId ?? '');
|
||||
let userData = $state<UserDataSummary | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Delete dialog state
|
||||
let showDeleteDialog = $state(false);
|
||||
let deleteConfirmEmail = $state('');
|
||||
let deleting = $state(false);
|
||||
let deleteResult = $state<DeleteUserDataResponse | null>(null);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
async function loadUserData() {
|
||||
if (!userId) {
|
||||
error = 'Keine Nutzer-ID angegeben';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await adminService.getUserData(userId);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
userData = null;
|
||||
} else {
|
||||
userData = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!userData || !userId || deleteConfirmEmail !== userData.user.email) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleting = true;
|
||||
deleteError = null;
|
||||
|
||||
const result = await adminService.deleteUserData(userId);
|
||||
|
||||
if (result.error) {
|
||||
deleteError = result.error;
|
||||
} else {
|
||||
deleteResult = result.data;
|
||||
}
|
||||
|
||||
deleting = false;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadUserData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onclick={() => goto('/admin/user-data')}
|
||||
class="p-2 rounded-lg hover:bg-muted transition-colors"
|
||||
aria-label="Zuruck zur Nutzerliste"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold">Nutzerdaten</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{userData?.user.email || 'Laden...'}
|
||||
</p>
|
||||
</div>
|
||||
{#if userData}
|
||||
<button
|
||||
onclick={() => (showDeleteDialog = true)}
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Daten loschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{#each Array(4) as _}
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm animate-pulse">
|
||||
<div class="h-4 bg-muted rounded w-20 mb-2"></div>
|
||||
<div class="h-8 bg-muted rounded w-16"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-6 text-center"
|
||||
>
|
||||
<p class="text-red-600 dark:text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onclick={loadUserData}
|
||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else if userData}
|
||||
<!-- User Info Card -->
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-primary">
|
||||
{(userData.user.name || userData.user.email)[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold">{userData.user.name || 'Kein Name'}</h2>
|
||||
<p class="text-muted-foreground">{userData.user.email}</p>
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{userData.user.role === 'admin'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}"
|
||||
>
|
||||
{userData.user.role}
|
||||
</span>
|
||||
{#if userData.user.emailVerified}
|
||||
<span class="text-xs text-green-600 flex items-center gap-1">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Email verifiziert
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-yellow-600 flex items-center gap-1">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Email nicht verifiziert
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
Registriert am {formatDate(userData.user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Gesamt-Entitaten" value={userData.totals.totalEntities} icon="chart" />
|
||||
<StatCard
|
||||
title="Projekte mit Daten"
|
||||
value="{userData.totals.projectsWithData} / {userData.projects.length}"
|
||||
icon="activity"
|
||||
/>
|
||||
<StatCard title="Credits" value={userData.credits.balance} icon="chart" />
|
||||
<StatCard title="Transaktionen" value={userData.credits.transactionsCount} icon="clock" />
|
||||
</div>
|
||||
|
||||
<!-- Auth & Credits Details -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Auth Data -->
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold mb-4">Authentifizierung</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Aktive Sessions</span>
|
||||
<span class="font-mono">{userData.auth.sessionsCount}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Verknupfte Accounts</span>
|
||||
<span class="font-mono">{userData.auth.accountsCount}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">2FA aktiviert</span>
|
||||
<span class={userData.auth.has2FA ? 'text-green-500' : 'text-muted-foreground'}>
|
||||
{userData.auth.has2FA ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Letzter Login</span>
|
||||
<span class="text-sm">
|
||||
{userData.auth.lastLoginAt ? formatDate(userData.auth.lastLoginAt) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits Data -->
|
||||
<div class="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold mb-4">Credits</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Aktueller Stand</span>
|
||||
<span class="font-mono font-bold text-lg">{userData.credits.balance}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Gesamt verdient</span>
|
||||
<span class="font-mono text-green-600">+{userData.credits.totalEarned}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Gesamt ausgegeben</span>
|
||||
<span class="font-mono text-red-500">-{userData.credits.totalSpent}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">Transaktionen</span>
|
||||
<span class="font-mono">{userData.credits.transactionsCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Data -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">Projektdaten</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each userData.projects as project}
|
||||
<ProjectDataCard {project} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
{#if showDeleteDialog}
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-card rounded-xl shadow-xl max-w-md w-full">
|
||||
{#if deleteResult}
|
||||
<!-- Success State -->
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
class="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold">Loschung abgeschlossen</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Insgesamt wurden <strong>{deleteResult.totalDeleted}</strong> Eintrage geloscht.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
{#each deleteResult.deletedFromProjects as project}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>{project.projectName}</span>
|
||||
{#if project.success}
|
||||
<span class="text-green-600">{project.deletedCount} geloscht</span>
|
||||
{:else}
|
||||
<span class="text-red-500">Fehler</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="pt-2 border-t">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>Sessions</span>
|
||||
<span>{deleteResult.deletedFromAuth.sessions}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>Accounts</span>
|
||||
<span>{deleteResult.deletedFromAuth.accounts}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() => goto('/admin/user-data')}
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Zuruck zur Ubersicht
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Confirmation State -->
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-red-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-red-600">Daten unwiderruflich loschen?</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Diese Aktion loscht <strong>alle Daten</strong> des Nutzers aus allen Projekten. Dies umfasst:
|
||||
</p>
|
||||
|
||||
<ul class="text-sm text-muted-foreground mb-4 list-disc list-inside space-y-1">
|
||||
<li>Alle Projektdaten (Chat, Todo, Calendar, etc.)</li>
|
||||
<li>Alle Sessions und verknupften Accounts</li>
|
||||
<li>Credits und Transaktionshistorie</li>
|
||||
<li>Das Nutzerkonto selbst</li>
|
||||
</ul>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="delete-confirm-email" class="block text-sm font-medium mb-2">
|
||||
Zur Bestätigung, geben Sie die Email-Adresse ein:
|
||||
</label>
|
||||
<input
|
||||
id="delete-confirm-email"
|
||||
type="email"
|
||||
placeholder={userData?.user.email}
|
||||
bind:value={deleteConfirmEmail}
|
||||
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-red-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if deleteError}
|
||||
<p class="text-sm text-red-500 mb-4">{deleteError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => {
|
||||
showDeleteDialog = false;
|
||||
deleteConfirmEmail = '';
|
||||
deleteError = null;
|
||||
}}
|
||||
class="flex-1 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
|
||||
disabled={deleting}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
disabled={deleting || deleteConfirmEmail !== userData?.user.email}
|
||||
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{#if deleting}
|
||||
Losche...
|
||||
{:else}
|
||||
Endgültig loschen
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Loading…
Add table
Add a link
Reference in a new issue