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>
441 lines
14 KiB
Vue
441 lines
14 KiB
Vue
<template>
|
|
<div>
|
|
<h1 class="text-xl font-semibold text-white mb-1">User-Management</h1>
|
|
<p class="text-sm text-gray-500 mb-6">
|
|
User-Liste, Plan-Status, Ban / Soft-Delete. Anonym -- nur Nicknames, niemals E-Mail oder Klarname.
|
|
</p>
|
|
|
|
<!-- Filter-Bar -->
|
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
|
<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>
|
|
|
|
<!-- 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>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
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>
|