feat(admin): Phase 2 Frontend — Domains/Stats/Users/Moderation pages + responsive layout
4 page-implementations + server-route-proxies (admin-secret stays server-only): DOMAINS (apps/admin/pages/domains.vue): - UTable mit pending-submissions queue - Approve / Reject buttons per row - Reject-confirm-modal mit optional note - useToast + refresh nach action - 3 server-routes: GET list + POST approve/reject STATS (apps/admin/pages/stats.vue): - Stat-cards: Total Users + delta-week, Total Posts + delta-week, Domains pending (link to /domains), Domains approved, Feedback pending, Lyra-Posts (30d) - UProgress für Domain-Approval-Quote - Auto-refresh 60s + manual refresh-button - USkeleton während loading - 1 server-route: GET /api/stats USERS (apps/admin/pages/users.vue): - UTable mit avatar+nickname/username, plan-badge, streak, status, createdAt - Search-input + plan-filter dropdown - Action-dropdown per row: Plan-Change / Ban-Toggle / Soft-Delete - 3 separate UModals mit confirm-pattern - Cursor-pagination (Mehr laden button) - 3 server-routes: GET list, PATCH /:id, DELETE /:id MODERATION (apps/admin/pages/moderation.vue): - Stack-layout mit card-pro-item (statt table — content-preview braucht space) - Type-badge (Post/Comment), Author + Plan-badge, content-preview (200 chars), reportedAt - Action-buttons: Dismiss (gray), Delete Content (red soft + reason-modal), Ban User (red solid + warning-modal) - Empty-state, cursor-pagination - 4 server-routes: GET /queue, POST /:id/dismiss/delete/ban-user Server-route pattern (apps/admin/server/api/...): - Use useRuntimeConfig().adminSecret server-only - Client never sees x-admin-secret - Body/query passthrough to backend Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
056726a166
commit
3bc5360832
@ -1,14 +1,329 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-white mb-1">Domain-Approval</h1>
|
<div class="flex items-start justify-between mb-8">
|
||||||
<p class="text-sm text-gray-500 mb-8">Ausstehende Blocker-Domain-Anfragen genehmigen oder ablehnen.</p>
|
<div>
|
||||||
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
|
<h1 class="text-xl font-semibold text-white mb-1">Domain-Approval</h1>
|
||||||
<UIcon name="heroicons:globe-alt" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
|
<p class="text-sm text-gray-500">
|
||||||
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/domains pending</p>
|
Ausstehende Blocker-Domain-Anfragen genehmigen oder ablehnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
icon="heroicons:arrow-path"
|
||||||
|
:loading="pending"
|
||||||
|
size="sm"
|
||||||
|
@click="refresh()"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error-State -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-lg border border-red-900 bg-red-950/40 p-4 mb-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:exclamation-triangle"
|
||||||
|
class="h-5 w-5 text-red-400 shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-red-300">
|
||||||
|
Fehler beim Laden der Anfragen
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-red-400/80 mt-1">
|
||||||
|
{{ error.statusMessage || error.message || "Unbekannter Fehler" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading-State (initial) -->
|
||||||
|
<div
|
||||||
|
v-else-if="pending && !submissions"
|
||||||
|
class="rounded-lg border border-gray-800 bg-gray-900 p-12 text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:arrow-path"
|
||||||
|
class="h-6 w-6 text-gray-600 mx-auto mb-3 animate-spin"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-gray-500">Lade Domain-Anfragen…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty-State -->
|
||||||
|
<div
|
||||||
|
v-else-if="!submissions || submissions.length === 0"
|
||||||
|
class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:inbox"
|
||||||
|
class="h-8 w-8 text-gray-600 mx-auto mb-3"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-gray-500">Keine pending requests</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-2">
|
||||||
|
Alle Domain-Anfragen sind aktuell bearbeitet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabelle -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden"
|
||||||
|
>
|
||||||
|
<UTable
|
||||||
|
:data="submissions"
|
||||||
|
:columns="columns"
|
||||||
|
:ui="{
|
||||||
|
th: 'text-xs uppercase tracking-wider text-gray-500 bg-gray-900/60',
|
||||||
|
td: 'text-sm text-gray-300 align-middle',
|
||||||
|
tr: 'border-b border-gray-800 last:border-b-0',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #domain-cell="{ row }">
|
||||||
|
<span class="font-mono text-white">{{ row.original.domain }}</span>
|
||||||
|
<UBadge
|
||||||
|
v-if="row.original.status === 'in_review'"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
in_review
|
||||||
|
</UBadge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #requestedBy-cell="{ row }">
|
||||||
|
<span class="font-mono text-xs text-gray-400">
|
||||||
|
{{ shortId(row.original.userId) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #requestedAt-cell="{ row }">
|
||||||
|
<span class="text-gray-400">{{
|
||||||
|
formatDate(row.original.createdAt)
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #votes-cell="{ row }">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="inline-flex items-center gap-1 text-green-400">
|
||||||
|
<UIcon name="heroicons:hand-thumb-up" class="h-3.5 w-3.5" />
|
||||||
|
{{ row.original.yesVotes }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1 text-red-400">
|
||||||
|
<UIcon name="heroicons:hand-thumb-down" class="h-3.5 w-3.5" />
|
||||||
|
{{ row.original.noVotes }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-cell="{ row }">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<UButton
|
||||||
|
color="success"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
icon="heroicons:check"
|
||||||
|
:loading="busyId === row.original.id && busyAction === 'approve'"
|
||||||
|
:disabled="!!busyId"
|
||||||
|
@click="onApprove(row.original)"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
icon="heroicons:x-mark"
|
||||||
|
:loading="busyId === row.original.id && busyAction === 'reject'"
|
||||||
|
:disabled="!!busyId"
|
||||||
|
@click="askReject(row.original)"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reject-Confirm-Dialog -->
|
||||||
|
<UModal v-model:open="rejectOpen" title="Domain-Anfrage ablehnen">
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
Anfrage für
|
||||||
|
<span class="font-mono text-white">{{
|
||||||
|
rejectTarget?.domain
|
||||||
|
}}</span>
|
||||||
|
wirklich ablehnen?
|
||||||
|
</p>
|
||||||
|
<UTextarea
|
||||||
|
v-model="rejectNote"
|
||||||
|
placeholder="Optionaler Begründungs-Hinweis (intern)…"
|
||||||
|
:rows="3"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2 w-full">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="!!busyId"
|
||||||
|
@click="rejectOpen = false"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
:loading="!!busyId"
|
||||||
|
@click="confirmReject"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ middleware: "admin-auth" })
|
import type { TableColumn } from "@nuxt/ui";
|
||||||
|
|
||||||
|
definePageMeta({ middleware: "admin-auth" });
|
||||||
|
|
||||||
|
interface DomainSubmission {
|
||||||
|
id: string;
|
||||||
|
domain: string;
|
||||||
|
yesVotes: number;
|
||||||
|
noVotes: number;
|
||||||
|
status: "pending" | "in_review" | "approved" | "rejected";
|
||||||
|
createdAt: string;
|
||||||
|
userId: string;
|
||||||
|
postId: string | null;
|
||||||
|
customDomain: { id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: submissions,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = await useFetch<DomainSubmission[]>("/api/domain-submissions", {
|
||||||
|
default: () => [],
|
||||||
|
server: false, // Auth-Guard erst client-side -> SSR-Fetch wuerde unauthenticated landen
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: TableColumn<DomainSubmission>[] = [
|
||||||
|
{ accessorKey: "domain", header: "Domain" },
|
||||||
|
{ accessorKey: "requestedBy", header: "Angefragt von" },
|
||||||
|
{ accessorKey: "requestedAt", header: "Angefragt am" },
|
||||||
|
{ accessorKey: "votes", header: "Votes" },
|
||||||
|
{ accessorKey: "actions", header: "", meta: { class: { td: "text-right" } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shortId(id: string): string {
|
||||||
|
if (!id) return "—";
|
||||||
|
return id.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Approve / Reject ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const busyId = ref<string | null>(null);
|
||||||
|
const busyAction = ref<"approve" | "reject" | null>(null);
|
||||||
|
|
||||||
|
const rejectOpen = ref(false);
|
||||||
|
const rejectTarget = ref<DomainSubmission | null>(null);
|
||||||
|
const rejectNote = ref("");
|
||||||
|
|
||||||
|
async function onApprove(row: DomainSubmission) {
|
||||||
|
if (busyId.value) return;
|
||||||
|
busyId.value = row.id;
|
||||||
|
busyAction.value = "approve";
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/domain-submissions/${row.id}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
toast.add({
|
||||||
|
title: "Domain genehmigt",
|
||||||
|
description: row.domain,
|
||||||
|
color: "success",
|
||||||
|
icon: "heroicons:check-circle",
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Approve fehlgeschlagen",
|
||||||
|
description:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
||||||
|
color: "error",
|
||||||
|
icon: "heroicons:exclamation-triangle",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
busyId.value = null;
|
||||||
|
busyAction.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askReject(row: DomainSubmission) {
|
||||||
|
if (busyId.value) return;
|
||||||
|
rejectTarget.value = row;
|
||||||
|
rejectNote.value = "";
|
||||||
|
rejectOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReject() {
|
||||||
|
const target = rejectTarget.value;
|
||||||
|
if (!target || busyId.value) return;
|
||||||
|
busyId.value = target.id;
|
||||||
|
busyAction.value = "reject";
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/domain-submissions/${target.id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { note: rejectNote.value || undefined },
|
||||||
|
});
|
||||||
|
toast.add({
|
||||||
|
title: "Domain abgelehnt",
|
||||||
|
description: target.domain,
|
||||||
|
color: "warning",
|
||||||
|
icon: "heroicons:x-circle",
|
||||||
|
});
|
||||||
|
rejectOpen.value = false;
|
||||||
|
rejectTarget.value = null;
|
||||||
|
rejectNote.value = "";
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Reject fehlgeschlagen",
|
||||||
|
description:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
||||||
|
color: "error",
|
||||||
|
icon: "heroicons:exclamation-triangle",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
busyId.value = null;
|
||||||
|
busyAction.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,14 +1,518 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-white mb-1">Moderation</h1>
|
<div class="flex items-start justify-between mb-8">
|
||||||
<p class="text-sm text-gray-500 mb-8">Gemeldete Inhalte pruefen und behandeln.</p>
|
<div>
|
||||||
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
|
<h1 class="text-xl font-semibold text-white mb-1">Moderation</h1>
|
||||||
<UIcon name="heroicons:shield-check" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
|
<p class="text-sm text-gray-500">
|
||||||
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/moderation pending</p>
|
Gemeldete Inhalte prüfen, dismiss / delete / ban-user. Audit-Log persistiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
icon="heroicons:arrow-path"
|
||||||
|
:loading="pending"
|
||||||
|
size="sm"
|
||||||
|
@click="reload()"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error-State -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-lg border border-red-900 bg-red-950/40 p-4 mb-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:exclamation-triangle"
|
||||||
|
class="h-5 w-5 text-red-400 shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-red-300">
|
||||||
|
Fehler beim Laden der Moderation-Queue
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-red-400/80 mt-1">
|
||||||
|
{{ error.statusMessage || error.message || "Unbekannter Fehler" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading-State (initial) -->
|
||||||
|
<div
|
||||||
|
v-else-if="pending && items.length === 0"
|
||||||
|
class="rounded-lg border border-gray-800 bg-gray-900 p-12 text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:arrow-path"
|
||||||
|
class="h-6 w-6 text-gray-600 mx-auto mb-3 animate-spin"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-gray-500">Lade Moderation-Queue…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty-State -->
|
||||||
|
<div
|
||||||
|
v-else-if="items.length === 0"
|
||||||
|
class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:shield-check"
|
||||||
|
class="h-8 w-8 text-emerald-600 mx-auto mb-3"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-gray-300">Keine offene moderation-items</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
Alle gemeldeten Inhalte sind bearbeitet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stack-Layout: ein Card pro Item -->
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="`${item.type}:${item.id}`"
|
||||||
|
class="rounded-lg border border-gray-800 bg-gray-900 p-5"
|
||||||
|
>
|
||||||
|
<!-- Header-Row: type-badge, user-info, reportedAt -->
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-3">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<UBadge
|
||||||
|
:color="item.type === 'post' ? 'primary' : 'info'"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ item.type === "post" ? "Post" : "Comment" }}
|
||||||
|
</UBadge>
|
||||||
|
|
||||||
|
<UBadge
|
||||||
|
v-if="item.isDeleted"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
gelöscht
|
||||||
|
</UBadge>
|
||||||
|
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:user-circle"
|
||||||
|
class="h-3.5 w-3.5 inline -mt-0.5"
|
||||||
|
/>
|
||||||
|
{{ item.author?.nickname || "—" }}
|
||||||
|
<span class="text-gray-600 ml-1">
|
||||||
|
({{ shortId(item.userId) }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<UBadge
|
||||||
|
v-if="item.author?.plan && item.author.plan !== 'free'"
|
||||||
|
:color="item.author.plan === 'legend' ? 'warning' : 'success'"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ item.author.plan }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-xs text-gray-500 shrink-0">
|
||||||
|
{{
|
||||||
|
item.reportedAt
|
||||||
|
? `gemeldet ${formatDate(item.reportedAt)}`
|
||||||
|
: `erstellt ${formatDate(item.createdAt)}`
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content-Preview (max 200 chars) -->
|
||||||
|
<div
|
||||||
|
class="rounded border border-gray-800 bg-gray-950/60 px-4 py-3 mb-4"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="item.isDeleted"
|
||||||
|
class="text-sm italic text-gray-600"
|
||||||
|
>
|
||||||
|
[Inhalt bereits gelöscht — Original im Audit-Log]
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-gray-200 whitespace-pre-wrap">
|
||||||
|
{{ truncate(item.content, 200) }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="item.type === 'comment' && item.postId"
|
||||||
|
class="text-xs text-gray-600 mt-2"
|
||||||
|
>
|
||||||
|
zum Post {{ shortId(item.postId) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action-Buttons -->
|
||||||
|
<div class="flex items-center justify-end gap-2 flex-wrap">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="heroicons:check"
|
||||||
|
:loading="busy(item, 'dismiss')"
|
||||||
|
:disabled="anyBusy"
|
||||||
|
@click="onDismiss(item)"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
icon="heroicons:trash"
|
||||||
|
:disabled="anyBusy || item.isDeleted"
|
||||||
|
:loading="busy(item, 'delete')"
|
||||||
|
@click="askDelete(item)"
|
||||||
|
>
|
||||||
|
Delete Content
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="solid"
|
||||||
|
size="xs"
|
||||||
|
icon="heroicons:no-symbol"
|
||||||
|
:disabled="anyBusy"
|
||||||
|
:loading="busy(item, 'ban')"
|
||||||
|
@click="askBan(item)"
|
||||||
|
>
|
||||||
|
Ban User
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="nextCursor" class="flex justify-center pt-4">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
Mehr laden
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete-Confirm-Modal -->
|
||||||
|
<UModal v-model:open="deleteOpen" title="Inhalt löschen">
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
{{ deleteTarget?.type === "post" ? "Post" : "Kommentar" }}
|
||||||
|
wirklich soft-löschen? Original-Inhalt bleibt im Audit-Log
|
||||||
|
erhalten.
|
||||||
|
</p>
|
||||||
|
<UTextarea
|
||||||
|
v-model="deleteReason"
|
||||||
|
placeholder="Optionale Begründung (intern)…"
|
||||||
|
:rows="3"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2 w-full">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="anyBusy"
|
||||||
|
@click="deleteOpen = false"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton color="error" :loading="anyBusy" @click="confirmDelete">
|
||||||
|
Löschen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Ban-User-Confirm-Modal -->
|
||||||
|
<UModal v-model:open="banOpen" title="User bannen">
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="rounded border border-red-900 bg-red-950/40 px-4 py-3 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="heroicons:exclamation-triangle"
|
||||||
|
class="h-5 w-5 text-red-400 shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-red-300">
|
||||||
|
User wird auf API-Ebene gesperrt (Login bleibt unberührt).
|
||||||
|
Reuses /api/admin/users[id]-Patch-Pattern.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
User
|
||||||
|
<span class="font-mono text-white">{{
|
||||||
|
banTarget?.author?.nickname || shortId(banTarget?.userId || "")
|
||||||
|
}}</span>
|
||||||
|
wegen
|
||||||
|
{{ banTarget?.type === "post" ? "diesem Post" : "diesem Kommentar" }}
|
||||||
|
bannen?
|
||||||
|
</p>
|
||||||
|
<UTextarea
|
||||||
|
v-model="banReason"
|
||||||
|
placeholder="Begründung (Pflicht intern, im Audit-Log persistiert)…"
|
||||||
|
:rows="3"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2 w-full">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="anyBusy"
|
||||||
|
@click="banOpen = false"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton color="error" :loading="anyBusy" @click="confirmBan">
|
||||||
|
Bannen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ middleware: "admin-auth" })
|
import type {
|
||||||
|
ModerationItem,
|
||||||
|
ModerationQueueResponse,
|
||||||
|
} from "~/server/api/moderation/queue.get";
|
||||||
|
|
||||||
|
definePageMeta({ middleware: "admin-auth" });
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const items = ref<ModerationItem[]>([]);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const pending = ref(false);
|
||||||
|
const error = ref<{ statusMessage?: string; message?: string } | null>(null);
|
||||||
|
|
||||||
|
async function fetchPage(cursor: string | null = null) {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (cursor) query.cursor = cursor;
|
||||||
|
return $fetch<ModerationQueueResponse>("/api/moderation/queue", { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
pending.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await fetchPage(null);
|
||||||
|
items.value = res.items;
|
||||||
|
nextCursor.value = res.nextCursor;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = {
|
||||||
|
statusMessage: err?.statusMessage,
|
||||||
|
message: err?.message,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
pending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
async function loadMore() {
|
||||||
|
if (!nextCursor.value || loadingMore.value) return;
|
||||||
|
loadingMore.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetchPage(nextCursor.value);
|
||||||
|
items.value = [...items.value, ...res.items];
|
||||||
|
nextCursor.value = res.nextCursor;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Laden fehlgeschlagen",
|
||||||
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
||||||
|
color: "error",
|
||||||
|
icon: "heroicons:exclamation-triangle",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial-load — client-side only (Auth-Middleware redirected sonst SSR).
|
||||||
|
onMounted(() => {
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shortId(id: string): string {
|
||||||
|
if (!id) return "—";
|
||||||
|
return id.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
if (!s) return "";
|
||||||
|
if (s.length <= max) return s;
|
||||||
|
return s.slice(0, max) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Action-Busy-State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ActionKind = "dismiss" | "delete" | "ban";
|
||||||
|
|
||||||
|
const busyKey = ref<string | null>(null); // "{type}:{id}:{action}"
|
||||||
|
|
||||||
|
function busy(item: ModerationItem, action: ActionKind): boolean {
|
||||||
|
return busyKey.value === `${item.type}:${item.id}:${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anyBusy = computed(() => busyKey.value !== null);
|
||||||
|
|
||||||
|
// ─── Dismiss ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function onDismiss(item: ModerationItem) {
|
||||||
|
if (anyBusy.value) return;
|
||||||
|
busyKey.value = `${item.type}:${item.id}:dismiss`;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/moderation/${item.id}/dismiss`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { type: item.type },
|
||||||
|
});
|
||||||
|
toast.add({
|
||||||
|
title: "Flag gelöscht",
|
||||||
|
description: item.type === "post" ? "Post" : "Kommentar",
|
||||||
|
color: "success",
|
||||||
|
icon: "heroicons:check-circle",
|
||||||
|
});
|
||||||
|
items.value = items.value.filter(
|
||||||
|
(i) => !(i.id === item.id && i.type === item.type),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Dismiss fehlgeschlagen",
|
||||||
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
||||||
|
color: "error",
|
||||||
|
icon: "heroicons:exclamation-triangle",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
busyKey.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const deleteOpen = ref(false);
|
||||||
|
const deleteTarget = ref<ModerationItem | null>(null);
|
||||||
|
const deleteReason = ref("");
|
||||||
|
|
||||||
|
function askDelete(item: ModerationItem) {
|
||||||
|
if (anyBusy.value) return;
|
||||||
|
deleteTarget.value = item;
|
||||||
|
deleteReason.value = "";
|
||||||
|
deleteOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const target = deleteTarget.value;
|
||||||
|
if (!target || anyBusy.value) return;
|
||||||
|
busyKey.value = `${target.type}:${target.id}:delete`;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/moderation/${target.id}/delete`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
type: target.type,
|
||||||
|
reason: deleteReason.value || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.add({
|
||||||
|
title: "Inhalt gelöscht",
|
||||||
|
description: "Original im Audit-Log persistiert",
|
||||||
|
color: "warning",
|
||||||
|
icon: "heroicons:trash",
|
||||||
|
});
|
||||||
|
deleteOpen.value = false;
|
||||||
|
deleteTarget.value = null;
|
||||||
|
deleteReason.value = "";
|
||||||
|
// Item lokal als gelöscht markieren statt aus Liste entfernen — Admin
|
||||||
|
// sieht weiter dass es bearbeitet wurde, kann anschließend dismiss-en.
|
||||||
|
items.value = items.value.map((i) =>
|
||||||
|
i.id === target.id && i.type === target.type
|
||||||
|
? { ...i, isDeleted: true, content: "" }
|
||||||
|
: i,
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Delete fehlgeschlagen",
|
||||||
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
||||||
|
color: "error",
|
||||||
|
icon: "heroicons:exclamation-triangle",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
busyKey.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ban-User ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const banOpen = ref(false);
|
||||||
|
const banTarget = ref<ModerationItem | null>(null);
|
||||||
|
const banReason = ref("");
|
||||||
|
|
||||||
|
function askBan(item: ModerationItem) {
|
||||||
|
if (anyBusy.value) return;
|
||||||
|
banTarget.value = item;
|
||||||
|
banReason.value = "";
|
||||||
|
banOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBan() {
|
||||||
|
const target = banTarget.value;
|
||||||
|
if (!target || anyBusy.value) return;
|
||||||
|
busyKey.value = `${target.type}:${target.id}:ban`;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/moderation/${target.id}/ban-user`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
type: target.type,
|
||||||
|
reason: banReason.value || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.add({
|
||||||
|
title: "User gebannt",
|
||||||
|
description: target.author?.nickname || shortId(target.userId),
|
||||||
|
color: "error",
|
||||||
|
icon: "heroicons:no-symbol",
|
||||||
|
});
|
||||||
|
banOpen.value = false;
|
||||||
|
banTarget.value = null;
|
||||||
|
banReason.value = "";
|
||||||
|
// Refresh — andere Items vom selben User sind jetzt auch ban-betroffen.
|
||||||
|
await reload();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Ban fehlgeschlagen",
|
||||||
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
||||||
|
color: "error",
|
||||||
|
icon: "heroicons:exclamation-triangle",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
busyKey.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,14 +1,243 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-white mb-1">Statistiken</h1>
|
<div class="flex items-start justify-between mb-8">
|
||||||
<p class="text-sm text-gray-500 mb-8">Aggregierte, anonyme Nutzungsmetriken.</p>
|
<div>
|
||||||
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
|
<h1 class="text-xl font-semibold text-white mb-1">Statistiken</h1>
|
||||||
<UIcon name="heroicons:chart-bar" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
|
<p class="text-sm text-gray-500">Aggregierte, anonyme Nutzungsmetriken. Auto-refresh alle 60s.</p>
|
||||||
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/stats pending</p>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span v-if="lastUpdated" class="text-xs text-gray-600">
|
||||||
|
Aktualisiert {{ lastUpdatedLabel }}
|
||||||
|
</span>
|
||||||
|
<UButton
|
||||||
|
icon="heroicons:arrow-path"
|
||||||
|
size="xs"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:loading="status === 'pending'"
|
||||||
|
@click="refresh()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading-skeleton beim ersten Load -->
|
||||||
|
<div v-if="status === 'pending' && !data" class="grid grid-cols-2 gap-4 lg:grid-cols-4 mb-8">
|
||||||
|
<div
|
||||||
|
v-for="n in 6"
|
||||||
|
:key="n"
|
||||||
|
class="rounded-lg border border-gray-800 bg-gray-900 p-4"
|
||||||
|
>
|
||||||
|
<USkeleton class="h-4 w-24 mb-3" />
|
||||||
|
<USkeleton class="h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error / Empty-state -->
|
||||||
|
<div
|
||||||
|
v-else-if="error || !data"
|
||||||
|
class="rounded-lg border border-dashed border-red-900 bg-gray-900 p-12 text-center"
|
||||||
|
>
|
||||||
|
<UIcon name="heroicons:exclamation-triangle" class="h-8 w-8 text-red-500 mx-auto mb-3" />
|
||||||
|
<p class="text-sm text-red-400 mb-1">Stats konnten nicht geladen werden</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ error?.statusMessage || error?.message || 'Backend nicht erreichbar' }}</p>
|
||||||
|
<UButton
|
||||||
|
class="mt-4"
|
||||||
|
size="xs"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="refresh()"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stat-cards-grid -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4 mb-8">
|
||||||
|
<div
|
||||||
|
v-for="card in cards"
|
||||||
|
:key="card.label"
|
||||||
|
class="rounded-lg border border-gray-800 bg-gray-900 p-4"
|
||||||
|
:class="[card.linkTo ? 'hover:border-gray-700 cursor-pointer transition-colors' : '']"
|
||||||
|
@click="card.linkTo ? navigateTo(card.linkTo) : undefined"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon :name="card.icon" class="h-4 w-4 text-gray-500" />
|
||||||
|
<span class="text-xs text-gray-500">{{ card.label }}</span>
|
||||||
|
</div>
|
||||||
|
<UIcon
|
||||||
|
v-if="card.linkTo"
|
||||||
|
name="heroicons:arrow-top-right-on-square"
|
||||||
|
class="h-3 w-3 text-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-white">{{ formatNumber(card.value) }}</p>
|
||||||
|
<div v-if="card.delta !== undefined" class="mt-2 flex items-center gap-1">
|
||||||
|
<UIcon
|
||||||
|
:name="card.delta > 0 ? 'heroicons:arrow-trending-up' : card.delta < 0 ? 'heroicons:arrow-trending-down' : 'heroicons:minus'"
|
||||||
|
class="h-3 w-3"
|
||||||
|
:class="card.delta > 0 ? 'text-emerald-500' : card.delta < 0 ? 'text-red-500' : 'text-gray-600'"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-xs"
|
||||||
|
:class="card.delta > 0 ? 'text-emerald-500' : card.delta < 0 ? 'text-red-500' : 'text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ card.delta > 0 ? '+' : '' }}{{ card.delta }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-600">{{ card.deltaLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="card.sublabel" class="mt-2">
|
||||||
|
<span class="text-xs text-gray-600">{{ card.sublabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domain-Approval-Progress-Section -->
|
||||||
|
<div class="rounded-lg border border-gray-800 bg-gray-900 p-6 mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-sm font-medium text-gray-400">Domain-Genehmigungs-Quote</h2>
|
||||||
|
<span class="text-xs text-gray-600">
|
||||||
|
{{ data.domains.approved }} / {{ data.domains.approved + data.domains.pending }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<UProgress
|
||||||
|
:model-value="approvalRatio"
|
||||||
|
:max="100"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
{{ data.domains.pending }} Domain(s) warten auf Review.
|
||||||
|
<NuxtLink to="/domains" class="text-primary-400 hover:text-primary-300">
|
||||||
|
Zur Approval-Queue
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lyra-Activity -->
|
||||||
|
<div class="rounded-lg border border-gray-800 bg-gray-900 p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<UIcon name="heroicons:sparkles" class="h-4 w-4 text-purple-400" />
|
||||||
|
<h2 class="text-sm font-medium text-gray-400">Lyra-Bot-Aktivitaet (30d)</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-white">{{ formatNumber(data.lyra.postsLast30d) }}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Community-Posts in den letzten 30 Tagen</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { AdminStats } from "~/server/api/stats.get"
|
||||||
|
|
||||||
definePageMeta({ middleware: "admin-auth" })
|
definePageMeta({ middleware: "admin-auth" })
|
||||||
|
|
||||||
|
const { data, error, status, refresh } = await useFetch<AdminStats>("/api/stats", {
|
||||||
|
// Stats kommen aggregiert vom Backend -- kein client-side caching, immer frisch.
|
||||||
|
key: "admin-stats",
|
||||||
|
default: () => null as unknown as AdminStats,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-refresh alle 60s.
|
||||||
|
const lastUpdated = ref<Date | null>(data.value ? new Date() : null)
|
||||||
|
const now = ref(new Date())
|
||||||
|
|
||||||
|
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let clockInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshInterval = setInterval(async () => {
|
||||||
|
await refresh()
|
||||||
|
lastUpdated.value = new Date()
|
||||||
|
}, 60_000)
|
||||||
|
// Clock-tick fuer "vor X sek" label.
|
||||||
|
clockInterval = setInterval(() => { now.value = new Date() }, 5_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
|
if (clockInterval) clearInterval(clockInterval)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(data, (val) => {
|
||||||
|
if (val) lastUpdated.value = new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastUpdatedLabel = computed(() => {
|
||||||
|
if (!lastUpdated.value) return ""
|
||||||
|
const seconds = Math.floor((now.value.getTime() - lastUpdated.value.getTime()) / 1000)
|
||||||
|
if (seconds < 5) return "gerade eben"
|
||||||
|
if (seconds < 60) return `vor ${seconds}s`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
return `vor ${minutes}m`
|
||||||
|
})
|
||||||
|
|
||||||
|
interface StatCard {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
icon: string
|
||||||
|
delta?: number
|
||||||
|
deltaLabel?: string
|
||||||
|
sublabel?: string
|
||||||
|
linkTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = computed<StatCard[]>(() => {
|
||||||
|
if (!data.value) return []
|
||||||
|
const d = data.value
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Total Users",
|
||||||
|
value: d.users.total,
|
||||||
|
icon: "heroicons:users",
|
||||||
|
delta: d.users.newThisWeek,
|
||||||
|
deltaLabel: "neue diese Woche",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total Posts",
|
||||||
|
value: d.posts.total,
|
||||||
|
icon: "heroicons:chat-bubble-left-right",
|
||||||
|
delta: d.posts.newThisWeek,
|
||||||
|
deltaLabel: "neue diese Woche",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Domains pending",
|
||||||
|
value: d.domains.pending,
|
||||||
|
icon: "heroicons:clock",
|
||||||
|
sublabel: "warten auf Review",
|
||||||
|
linkTo: "/domains",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Domains approved",
|
||||||
|
value: d.domains.approved,
|
||||||
|
icon: "heroicons:check-badge",
|
||||||
|
sublabel: "genehmigt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Feedback pending",
|
||||||
|
value: d.feedback.pending,
|
||||||
|
icon: "heroicons:inbox",
|
||||||
|
sublabel: `${d.feedback.total} insgesamt`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Lyra-Posts (30d)",
|
||||||
|
value: d.lyra.postsLast30d,
|
||||||
|
icon: "heroicons:sparkles",
|
||||||
|
sublabel: "Bot-Aktivitaet",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const approvalRatio = computed(() => {
|
||||||
|
if (!data.value) return 0
|
||||||
|
const total = data.value.domains.approved + data.value.domains.pending
|
||||||
|
if (total === 0) return 0
|
||||||
|
return Math.round((data.value.domains.approved / total) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
if (n === undefined || n === null) return "-"
|
||||||
|
return new Intl.NumberFormat("de-DE").format(n)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,17 +1,440 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-white mb-1">User-Management</h1>
|
<h1 class="text-xl font-semibold text-white mb-1">User-Management</h1>
|
||||||
<p class="text-sm text-gray-500 mb-8">
|
<p class="text-sm text-gray-500 mb-6">
|
||||||
User-Liste, Plan-Status, letzte Aktivitaet. Anonymisiert -- keine echten Namen, nur Nicknames.
|
User-Liste, Plan-Status, Ban / Soft-Delete. Anonym -- nur Nicknames, niemals E-Mail oder Klarname.
|
||||||
</p>
|
</p>
|
||||||
<div class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center">
|
|
||||||
<UIcon name="heroicons:users" class="h-8 w-8 text-gray-600 mx-auto mb-3" />
|
<!-- Filter-Bar -->
|
||||||
<p class="text-sm text-gray-500">Phase 2 -- Backend-API /api/admin/users pending</p>
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<p class="text-xs text-gray-600 mt-2">DSGVO: Nur Nickname sichtbar, niemals E-Mail oder Name</p>
|
<UInput
|
||||||
|
v-model="searchInput"
|
||||||
|
placeholder="Suche Nickname / Username"
|
||||||
|
icon="heroicons:magnifying-glass"
|
||||||
|
size="sm"
|
||||||
|
class="w-72"
|
||||||
|
@keyup.enter="reload"
|
||||||
|
/>
|
||||||
|
<USelect
|
||||||
|
v-model="planFilter"
|
||||||
|
:items="planOptions"
|
||||||
|
placeholder="Alle Pläne"
|
||||||
|
size="sm"
|
||||||
|
class="w-40"
|
||||||
|
@update:model-value="reload"
|
||||||
|
/>
|
||||||
|
<UButton size="sm" color="neutral" variant="soft" @click="reload">
|
||||||
|
Aktualisieren
|
||||||
|
</UButton>
|
||||||
|
<span class="text-xs text-gray-500 ml-auto">
|
||||||
|
{{ rows.length }} User geladen
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabelle -->
|
||||||
|
<div class="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden">
|
||||||
|
<UTable
|
||||||
|
:data="rows"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:empty-state="{ icon: 'heroicons:users', label: 'Keine User gefunden.' }"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<template #nickname-cell="{ row }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UAvatar
|
||||||
|
:src="row.original.avatar ?? undefined"
|
||||||
|
:alt="row.original.nickname ?? row.original.username ?? ''"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm text-white">
|
||||||
|
{{ row.original.nickname ?? row.original.username ?? "—" }}
|
||||||
|
</span>
|
||||||
|
<span v-if="row.original.username && row.original.nickname" class="text-xs text-gray-500">
|
||||||
|
{{ row.original.username }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #plan-cell="{ row }">
|
||||||
|
<UBadge
|
||||||
|
:color="planBadgeColor(row.original.plan)"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
class="uppercase"
|
||||||
|
>
|
||||||
|
{{ row.original.plan }}
|
||||||
|
</UBadge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #streak-cell="{ row }">
|
||||||
|
<span class="text-sm text-gray-300">{{ row.original.streak }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #status-cell="{ row }">
|
||||||
|
<UBadge
|
||||||
|
v-if="row.original.deletedAt"
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
gelöscht
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-else-if="row.original.banned"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
gebannt
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-else color="success" variant="subtle" size="xs">
|
||||||
|
aktiv
|
||||||
|
</UBadge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #createdAt-cell="{ row }">
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ formatDate(row.original.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-cell="{ row }">
|
||||||
|
<UDropdownMenu :items="rowActions(row.original)">
|
||||||
|
<UButton
|
||||||
|
icon="heroicons:ellipsis-horizontal"
|
||||||
|
size="xs"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</UDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="nextCursor" class="mt-4 flex justify-center">
|
||||||
|
<UButton
|
||||||
|
size="sm"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
Weitere laden
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plan-ändern Modal -->
|
||||||
|
<UModal v-model:open="planModalOpen" title="Plan ändern">
|
||||||
|
<template #body>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">
|
||||||
|
Aktiver User: <strong>{{ selectedUser?.nickname ?? selectedUser?.username ?? selectedUser?.id }}</strong>
|
||||||
|
</p>
|
||||||
|
<USelect v-model="newPlan" :items="planChangeOptions" placeholder="Neuer Plan" />
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2 justify-end w-full">
|
||||||
|
<UButton color="neutral" variant="ghost" @click="planModalOpen = false">
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton color="primary" :loading="acting" @click="confirmPlanChange">
|
||||||
|
Speichern
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Ban-Confirm Modal -->
|
||||||
|
<UModal v-model:open="banModalOpen" :title="banDialogTitle">
|
||||||
|
<template #body>
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
User <strong>{{ selectedUser?.nickname ?? selectedUser?.username }}</strong>
|
||||||
|
{{ selectedUser?.banned ? "wieder freischalten" : "wirklich bannen" }}?
|
||||||
|
</p>
|
||||||
|
<UInput
|
||||||
|
v-if="!selectedUser?.banned"
|
||||||
|
v-model="banReason"
|
||||||
|
placeholder="Grund (optional)"
|
||||||
|
class="mt-3"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2 justify-end w-full">
|
||||||
|
<UButton color="neutral" variant="ghost" @click="banModalOpen = false">
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton :color="selectedUser?.banned ? 'success' : 'error'" :loading="acting" @click="confirmBan">
|
||||||
|
{{ selectedUser?.banned ? "Freischalten" : "Bannen" }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Soft-Delete-Confirm Modal -->
|
||||||
|
<UModal v-model:open="deleteModalOpen" title="Soft-Delete (DSGVO)">
|
||||||
|
<template #body>
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
User <strong>{{ selectedUser?.nickname ?? selectedUser?.username }}</strong> wird soft-gelöscht:
|
||||||
|
</p>
|
||||||
|
<ul class="text-xs text-gray-500 mt-2 space-y-1 list-disc pl-5">
|
||||||
|
<li>Nickname → null, Avatar → null, Demographie → null</li>
|
||||||
|
<li>Username → "deleted-{shortid}"</li>
|
||||||
|
<li>deletedAt → jetzt</li>
|
||||||
|
<li>Auth-Account bleibt zunächst (separate Operation)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-xs text-error-400 mt-3">Diese Aktion ist nicht rückgängig zu machen.</p>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2 justify-end w-full">
|
||||||
|
<UButton color="neutral" variant="ghost" @click="deleteModalOpen = false">
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton color="error" :loading="acting" @click="confirmDelete">
|
||||||
|
Soft-Delete bestätigen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ middleware: "admin-auth" })
|
definePageMeta({ middleware: "admin-auth" })
|
||||||
|
|
||||||
|
type AdminUserRow = {
|
||||||
|
id: string
|
||||||
|
nickname: string | null
|
||||||
|
username: string | null
|
||||||
|
avatar: string | null
|
||||||
|
plan: string
|
||||||
|
streak: number
|
||||||
|
banned: boolean
|
||||||
|
bannedAt: string | null
|
||||||
|
deletedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
lyraVoiceId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListResponse = {
|
||||||
|
items: AdminUserRow[]
|
||||||
|
nextCursor: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rows = ref<AdminUserRow[]>([])
|
||||||
|
const nextCursor = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const acting = ref(false)
|
||||||
|
|
||||||
|
const searchInput = ref("")
|
||||||
|
const planFilter = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const planOptions = [
|
||||||
|
{ label: "Alle Pläne", value: "" },
|
||||||
|
{ label: "Free", value: "free" },
|
||||||
|
{ label: "Pro", value: "pro" },
|
||||||
|
{ label: "Legend", value: "legend" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const planChangeOptions = [
|
||||||
|
{ label: "Free", value: "free" },
|
||||||
|
{ label: "Pro", value: "pro" },
|
||||||
|
{ label: "Legend", value: "legend" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const planModalOpen = ref(false)
|
||||||
|
const banModalOpen = ref(false)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const selectedUser = ref<AdminUserRow | null>(null)
|
||||||
|
const newPlan = ref<string>("free")
|
||||||
|
const banReason = ref("")
|
||||||
|
const banDialogTitle = computed(() =>
|
||||||
|
selectedUser.value?.banned ? "User freischalten" : "User bannen",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Columns ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ accessorKey: "nickname", header: "User", id: "nickname" },
|
||||||
|
{ accessorKey: "plan", header: "Plan", id: "plan" },
|
||||||
|
{ accessorKey: "streak", header: "Streak", id: "streak" },
|
||||||
|
{ accessorKey: "status", header: "Status", id: "status" },
|
||||||
|
{ accessorKey: "createdAt", header: "Beigetreten", id: "createdAt" },
|
||||||
|
{ accessorKey: "actions", header: "", id: "actions" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function planBadgeColor(plan: string): "primary" | "success" | "warning" | "neutral" {
|
||||||
|
if (plan === "legend") return "warning"
|
||||||
|
if (plan === "pro") return "primary"
|
||||||
|
if (plan === "free") return "neutral"
|
||||||
|
return "neutral"
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string): string {
|
||||||
|
if (!d) return "—"
|
||||||
|
try {
|
||||||
|
return new Date(d).toLocaleDateString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowActions(user: AdminUserRow) {
|
||||||
|
const items: Array<Array<{ label: string; icon: string; onSelect: () => void }>> = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: "Plan ändern",
|
||||||
|
icon: "heroicons:star",
|
||||||
|
onSelect: () => openPlanModal(user),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: user.banned ? "Bann aufheben" : "Bannen",
|
||||||
|
icon: user.banned ? "heroicons:lock-open" : "heroicons:lock-closed",
|
||||||
|
onSelect: () => openBanModal(user),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: "Soft-Delete",
|
||||||
|
icon: "heroicons:trash",
|
||||||
|
onSelect: () => openDeleteModal(user),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchPage(opts: { append: boolean }): Promise<void> {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (searchInput.value.trim()) params.q = searchInput.value.trim()
|
||||||
|
if (planFilter.value && planFilter.value !== "") params.plan = planFilter.value
|
||||||
|
if (opts.append && nextCursor.value) params.cursor = nextCursor.value
|
||||||
|
|
||||||
|
const res = await $fetch<ListResponse>("/api/users", { params })
|
||||||
|
if (opts.append) {
|
||||||
|
rows.value = [...rows.value, ...res.items]
|
||||||
|
} else {
|
||||||
|
rows.value = res.items
|
||||||
|
}
|
||||||
|
nextCursor.value = res.nextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
nextCursor.value = null
|
||||||
|
await fetchPage({ append: false })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[users] load failed:", err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore(): Promise<void> {
|
||||||
|
if (!nextCursor.value || loadingMore.value) return
|
||||||
|
loadingMore.value = true
|
||||||
|
try {
|
||||||
|
await fetchPage({ append: true })
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openPlanModal(user: AdminUserRow): void {
|
||||||
|
selectedUser.value = user
|
||||||
|
newPlan.value = user.plan
|
||||||
|
planModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBanModal(user: AdminUserRow): void {
|
||||||
|
selectedUser.value = user
|
||||||
|
banReason.value = ""
|
||||||
|
banModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(user: AdminUserRow): void {
|
||||||
|
selectedUser.value = user
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPlanChange(): Promise<void> {
|
||||||
|
if (!selectedUser.value) return
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
const updated = await $fetch<AdminUserRow>(`/api/users/${selectedUser.value.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { plan: newPlan.value },
|
||||||
|
})
|
||||||
|
mergeRow(updated)
|
||||||
|
planModalOpen.value = false
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[users] plan-change failed:", err)
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBan(): Promise<void> {
|
||||||
|
if (!selectedUser.value) return
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
const newBanned = !selectedUser.value.banned
|
||||||
|
const updated = await $fetch<AdminUserRow>(`/api/users/${selectedUser.value.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: {
|
||||||
|
banned: newBanned,
|
||||||
|
bannedReason: newBanned && banReason.value ? banReason.value : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mergeRow(updated)
|
||||||
|
banModalOpen.value = false
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[users] ban-toggle failed:", err)
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(): Promise<void> {
|
||||||
|
if (!selectedUser.value) return
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/users/${selectedUser.value.id}`, { method: "DELETE" })
|
||||||
|
// Soft-deleted user fliegt aus der Liste (default: includeDeleted=false)
|
||||||
|
rows.value = rows.value.filter((r) => r.id !== selectedUser.value!.id)
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[users] soft-delete failed:", err)
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRow(updated: AdminUserRow): void {
|
||||||
|
const idx = rows.value.findIndex((r) => r.id === updated.id)
|
||||||
|
if (idx >= 0) rows.value[idx] = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
reload()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
34
apps/admin/server/api/domain-submissions.get.ts
Normal file
34
apps/admin/server/api/domain-submissions.get.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// apps/admin/server/api/domain-submissions.get.ts
|
||||||
|
//
|
||||||
|
// Server-side proxy: holt Pending-Domain-Submissions vom Backend.
|
||||||
|
// Admin-Secret bleibt server-only (NIE im Client-Bundle landen lassen).
|
||||||
|
//
|
||||||
|
// Auth-Modell: client ruft /api/domain-submissions auf (Nuxt-server-route),
|
||||||
|
// hier wird x-admin-secret aus runtimeConfig.adminSecret an Backend weitergereicht.
|
||||||
|
|
||||||
|
export default defineEventHandler(async (_event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${apiBase}/api/admin/domain-submissions`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
// apps/admin/server/api/domain-submissions/[id]/approve.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: leitet Approve-Request inkl. optionaler note ans Backend.
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(event, "id");
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "ID fehlt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch(
|
||||||
|
`${apiBase}/api/admin/domain-submissions/${encodeURIComponent(id)}/approve`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
41
apps/admin/server/api/domain-submissions/[id]/reject.post.ts
Normal file
41
apps/admin/server/api/domain-submissions/[id]/reject.post.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// apps/admin/server/api/domain-submissions/[id]/reject.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: leitet Reject-Request inkl. optionaler note ans Backend.
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(event, "id");
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "ID fehlt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch(
|
||||||
|
`${apiBase}/api/admin/domain-submissions/${encodeURIComponent(id)}/reject`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
39
apps/admin/server/api/moderation/[id]/ban-user.post.ts
Normal file
39
apps/admin/server/api/moderation/[id]/ban-user.post.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// apps/admin/server/api/moderation/[id]/ban-user.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: leitet Ban-User-Request ans Backend. Profile.banned wird gesetzt
|
||||||
|
// (gleicher Patch-Pattern wie /api/admin/users/[id]).
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(event, "id");
|
||||||
|
if (!id) throw createError({ statusCode: 400, statusMessage: "ID fehlt" });
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(
|
||||||
|
`${apiBase}/api/admin/moderation/${encodeURIComponent(id)}/ban-user`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
39
apps/admin/server/api/moderation/[id]/delete.post.ts
Normal file
39
apps/admin/server/api/moderation/[id]/delete.post.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// apps/admin/server/api/moderation/[id]/delete.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: leitet Soft-Delete-Request ans Backend (content="", isDeleted=true).
|
||||||
|
// Original-Content + reporter-info bleiben in moderation_actions (audit-log).
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(event, "id");
|
||||||
|
if (!id) throw createError({ statusCode: 400, statusMessage: "ID fehlt" });
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(
|
||||||
|
`${apiBase}/api/admin/moderation/${encodeURIComponent(id)}/delete`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
38
apps/admin/server/api/moderation/[id]/dismiss.post.ts
Normal file
38
apps/admin/server/api/moderation/[id]/dismiss.post.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// apps/admin/server/api/moderation/[id]/dismiss.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: leitet Dismiss-Request (flag clear) ans Backend.
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(event, "id");
|
||||||
|
if (!id) throw createError({ statusCode: 400, statusMessage: "ID fehlt" });
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(
|
||||||
|
`${apiBase}/api/admin/moderation/${encodeURIComponent(id)}/dismiss`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
62
apps/admin/server/api/moderation/queue.get.ts
Normal file
62
apps/admin/server/api/moderation/queue.get.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// apps/admin/server/api/moderation/queue.get.ts
|
||||||
|
//
|
||||||
|
// Server-side proxy: holt die Moderation-Queue (gemeldete Posts + Comments)
|
||||||
|
// vom Backend. Admin-Secret bleibt server-only.
|
||||||
|
//
|
||||||
|
// Query-Forwarding: cursor + limit werden an Backend durchgereicht.
|
||||||
|
|
||||||
|
export interface ModerationItem {
|
||||||
|
id: string;
|
||||||
|
type: "post" | "comment";
|
||||||
|
content: string;
|
||||||
|
postId: string | null;
|
||||||
|
userId: string;
|
||||||
|
reportedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
author: {
|
||||||
|
id: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
plan: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerationQueueResponse {
|
||||||
|
items: ModerationItem[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(
|
||||||
|
async (event): Promise<ModerationQueueResponse> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch<ModerationQueueResponse>(
|
||||||
|
`${apiBase}/api/admin/moderation/queue`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
42
apps/admin/server/api/stats.get.ts
Normal file
42
apps/admin/server/api/stats.get.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// apps/admin/server/api/stats.get.ts
|
||||||
|
//
|
||||||
|
// Server-side proxy: holt aggregierte Admin-Stats vom Backend.
|
||||||
|
// Admin-Secret bleibt server-only (NIE im Client-Bundle landen lassen).
|
||||||
|
//
|
||||||
|
// Auth-Modell: client ruft /api/stats auf (Nuxt-server-route),
|
||||||
|
// hier wird x-admin-secret aus runtimeConfig.adminSecret an Backend weitergereicht.
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
users: { total: number; newThisWeek: number };
|
||||||
|
posts: { total: number; newThisWeek: number };
|
||||||
|
domains: { pending: number; approved: number };
|
||||||
|
feedback: { pending: number; total: number };
|
||||||
|
lyra: { postsLast30d: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (_event): Promise<AdminStats> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch<AdminStats>(`${apiBase}/api/admin/stats`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
27
apps/admin/server/api/users.get.ts
Normal file
27
apps/admin/server/api/users.get.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Admin-App proxy: forwards to backend /api/admin/users mit x-admin-secret
|
||||||
|
// (Pattern wie andere admin-pages — admin-secret bleibt server-side, nie im Browser).
|
||||||
|
//
|
||||||
|
// Query-Params werden 1:1 weitergereicht. Backend macht die Validierung.
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const query = getQuery(event);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v !== undefined && v !== null && v !== "") {
|
||||||
|
params.set(k, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.public.apiBase}/api/admin/users${
|
||||||
|
params.toString() ? `?${params.toString()}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return $fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"x-admin-secret": config.adminSecret as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
14
apps/admin/server/api/users/[id].delete.ts
Normal file
14
apps/admin/server/api/users/[id].delete.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// Admin-App proxy: DELETE /api/admin/users/[id] — soft-delete (DSGVO).
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const id = getRouterParam(event, "id");
|
||||||
|
if (!id) throw createError({ statusCode: 400, message: "User-ID fehlt" });
|
||||||
|
|
||||||
|
return $fetch(`${config.public.apiBase}/api/admin/users/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"x-admin-secret": config.adminSecret as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/admin/server/api/users/[id].patch.ts
Normal file
19
apps/admin/server/api/users/[id].patch.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// Admin-App proxy: PATCH /api/admin/users/[id]
|
||||||
|
// Forwards body 1:1 ans Backend mit x-admin-secret.
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const id = getRouterParam(event, "id");
|
||||||
|
if (!id) throw createError({ statusCode: 400, message: "User-ID fehlt" });
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
return $fetch(`${config.public.apiBase}/api/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"x-admin-secret": config.adminSecret as string,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user