From 68fe8afab241224c83159fc02236219bfa8467be Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 9 May 2026 15:47:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=202=20Frontend=20=E2=80=94?= =?UTF-8?q?=20Domains/Stats/Users/Moderation=20pages=20+=20responsive=20la?= =?UTF-8?q?yout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/admin/pages/domains.vue | 327 ++++++++++- apps/admin/pages/moderation.vue | 516 +++++++++++++++++- apps/admin/pages/stats.vue | 239 +++++++- apps/admin/pages/users.vue | 435 ++++++++++++++- .../server/api/domain-submissions.get.ts | 34 ++ .../domain-submissions/[id]/approve.post.ts | 41 ++ .../domain-submissions/[id]/reject.post.ts | 41 ++ .../api/moderation/[id]/ban-user.post.ts | 39 ++ .../server/api/moderation/[id]/delete.post.ts | 39 ++ .../api/moderation/[id]/dismiss.post.ts | 38 ++ apps/admin/server/api/moderation/queue.get.ts | 62 +++ apps/admin/server/api/stats.get.ts | 42 ++ apps/admin/server/api/users.get.ts | 27 + apps/admin/server/api/users/[id].delete.ts | 14 + apps/admin/server/api/users/[id].patch.ts | 19 + 15 files changed, 1890 insertions(+), 23 deletions(-) create mode 100644 apps/admin/server/api/domain-submissions.get.ts create mode 100644 apps/admin/server/api/domain-submissions/[id]/approve.post.ts create mode 100644 apps/admin/server/api/domain-submissions/[id]/reject.post.ts create mode 100644 apps/admin/server/api/moderation/[id]/ban-user.post.ts create mode 100644 apps/admin/server/api/moderation/[id]/delete.post.ts create mode 100644 apps/admin/server/api/moderation/[id]/dismiss.post.ts create mode 100644 apps/admin/server/api/moderation/queue.get.ts create mode 100644 apps/admin/server/api/stats.get.ts create mode 100644 apps/admin/server/api/users.get.ts create mode 100644 apps/admin/server/api/users/[id].delete.ts create mode 100644 apps/admin/server/api/users/[id].patch.ts diff --git a/apps/admin/pages/domains.vue b/apps/admin/pages/domains.vue index 692d8e2..aed9184 100644 --- a/apps/admin/pages/domains.vue +++ b/apps/admin/pages/domains.vue @@ -1,14 +1,329 @@ diff --git a/apps/admin/pages/moderation.vue b/apps/admin/pages/moderation.vue index 81a680d..130ba2c 100644 --- a/apps/admin/pages/moderation.vue +++ b/apps/admin/pages/moderation.vue @@ -1,14 +1,518 @@ diff --git a/apps/admin/pages/stats.vue b/apps/admin/pages/stats.vue index 0a445c7..a3229b9 100644 --- a/apps/admin/pages/stats.vue +++ b/apps/admin/pages/stats.vue @@ -1,14 +1,243 @@ diff --git a/apps/admin/pages/users.vue b/apps/admin/pages/users.vue index b26a22a..e7159bd 100644 --- a/apps/admin/pages/users.vue +++ b/apps/admin/pages/users.vue @@ -1,17 +1,440 @@ diff --git a/apps/admin/server/api/domain-submissions.get.ts b/apps/admin/server/api/domain-submissions.get.ts new file mode 100644 index 0000000..67e7403 --- /dev/null +++ b/apps/admin/server/api/domain-submissions.get.ts @@ -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", + }); + } +}); diff --git a/apps/admin/server/api/domain-submissions/[id]/approve.post.ts b/apps/admin/server/api/domain-submissions/[id]/approve.post.ts new file mode 100644 index 0000000..de90471 --- /dev/null +++ b/apps/admin/server/api/domain-submissions/[id]/approve.post.ts @@ -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", + }); + } +}); diff --git a/apps/admin/server/api/domain-submissions/[id]/reject.post.ts b/apps/admin/server/api/domain-submissions/[id]/reject.post.ts new file mode 100644 index 0000000..c68ac53 --- /dev/null +++ b/apps/admin/server/api/domain-submissions/[id]/reject.post.ts @@ -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", + }); + } +}); diff --git a/apps/admin/server/api/moderation/[id]/ban-user.post.ts b/apps/admin/server/api/moderation/[id]/ban-user.post.ts new file mode 100644 index 0000000..21720ee --- /dev/null +++ b/apps/admin/server/api/moderation/[id]/ban-user.post.ts @@ -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", + }); + } +}); diff --git a/apps/admin/server/api/moderation/[id]/delete.post.ts b/apps/admin/server/api/moderation/[id]/delete.post.ts new file mode 100644 index 0000000..995de37 --- /dev/null +++ b/apps/admin/server/api/moderation/[id]/delete.post.ts @@ -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", + }); + } +}); diff --git a/apps/admin/server/api/moderation/[id]/dismiss.post.ts b/apps/admin/server/api/moderation/[id]/dismiss.post.ts new file mode 100644 index 0000000..4466e92 --- /dev/null +++ b/apps/admin/server/api/moderation/[id]/dismiss.post.ts @@ -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", + }); + } +}); diff --git a/apps/admin/server/api/moderation/queue.get.ts b/apps/admin/server/api/moderation/queue.get.ts new file mode 100644 index 0000000..d7d6f15 --- /dev/null +++ b/apps/admin/server/api/moderation/queue.get.ts @@ -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 => { + 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( + `${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", + }); + } + }, +); diff --git a/apps/admin/server/api/stats.get.ts b/apps/admin/server/api/stats.get.ts new file mode 100644 index 0000000..98537fc --- /dev/null +++ b/apps/admin/server/api/stats.get.ts @@ -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 => { + 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/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", + }); + } +}); diff --git a/apps/admin/server/api/users.get.ts b/apps/admin/server/api/users.get.ts new file mode 100644 index 0000000..a0717ad --- /dev/null +++ b/apps/admin/server/api/users.get.ts @@ -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, + }, + }); +}); diff --git a/apps/admin/server/api/users/[id].delete.ts b/apps/admin/server/api/users/[id].delete.ts new file mode 100644 index 0000000..97f4a9e --- /dev/null +++ b/apps/admin/server/api/users/[id].delete.ts @@ -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, + }, + }); +}); diff --git a/apps/admin/server/api/users/[id].patch.ts b/apps/admin/server/api/users/[id].patch.ts new file mode 100644 index 0000000..0ecdfb3 --- /dev/null +++ b/apps/admin/server/api/users/[id].patch.ts @@ -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, + }); +});