From b5f02fe7a403698566ca952b3fd473a84f511a2d Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 21 Jun 2026 15:21:07 +0200 Subject: [PATCH] feat(admin): add hidden admin panel, ban system, ads, and legal pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin panel at /p/[ADMIN_SECRET]/* — middleware returns 404 for wrong/missing secret - tRPC adminRouter: getStats, listPosts, deletePost, listComments, deleteComment, listUsers, banUser/unbanUser, listAds, createAd/updateAd/deleteAd, getActiveAds, listModerators, grantRole/revokeRole, getModerationLog, getActiveBan - Prisma schema: AdminRole, UserBan, Advertisement, ModerationLog models added - Ban enforcement: (app)/layout.tsx checks active ban on every request → /banned - Feed ad injection: active ads shown every 7th post in FeedList (AdCard component) - Clerk middleware.ts: protects all routes; /p/[secret] validated before auth check - Impressum + Datenschutz pages: contact data base64-obfuscated, decoded client-side only - shadcn Switch component added - ADMIN_SECRET + ADMIN_OWNER_ID documented in .env.example --- .env.example | 8 + prisma/schema.prisma | 60 +++++ src/app/(app)/layout.tsx | 10 +- src/app/banned/page.tsx | 21 ++ src/app/datenschutz/page.tsx | 121 +++++++++ src/app/impressum/page.tsx | 104 ++++++++ src/app/p/[secret]/ads/page.tsx | 261 +++++++++++++++++++ src/app/p/[secret]/layout.tsx | 70 +++++ src/app/p/[secret]/moderators/page.tsx | 176 +++++++++++++ src/app/p/[secret]/page.tsx | 54 ++++ src/app/p/[secret]/posts/page.tsx | 231 ++++++++++++++++ src/app/p/[secret]/users/page.tsx | 206 +++++++++++++++ src/components/feed/AdCard.tsx | 42 +++ src/components/feed/FeedList.tsx | 34 ++- src/components/ui/switch.tsx | 32 +++ src/middleware.ts | 41 +++ src/trpc/routers/_app.ts | 2 + src/trpc/routers/admin.ts | 348 +++++++++++++++++++++++++ 18 files changed, 1815 insertions(+), 6 deletions(-) create mode 100644 src/app/banned/page.tsx create mode 100644 src/app/datenschutz/page.tsx create mode 100644 src/app/impressum/page.tsx create mode 100644 src/app/p/[secret]/ads/page.tsx create mode 100644 src/app/p/[secret]/layout.tsx create mode 100644 src/app/p/[secret]/moderators/page.tsx create mode 100644 src/app/p/[secret]/page.tsx create mode 100644 src/app/p/[secret]/posts/page.tsx create mode 100644 src/app/p/[secret]/users/page.tsx create mode 100644 src/components/feed/AdCard.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/middleware.ts create mode 100644 src/trpc/routers/admin.ts diff --git a/.env.example b/.env.example index 4c0872e..4bfd067 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,11 @@ CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxx" NEXT_PUBLIC_SUPABASE_URL="https://xxxxxxxxxxxxxxxxxxxx.supabase.co" # Supabase Dashboard -> Project Settings -> API -> service_role (secret!) SUPABASE_SERVICE_ROLE_KEY="eyJxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# ── Admin Panel ─────────────────────────────────────────────── +# NEVER use NEXT_PUBLIC_ prefix — these MUST stay server-only! +# ADMIN_SECRET: random UUID path segment (/p/[secret]/) — generate with: +# node -e "console.log(require('crypto').randomUUID())" +ADMIN_SECRET="your-random-uuid-here" +# ADMIN_OWNER_ID: Your Clerk user ID (Clerk dashboard). Bootstraps the super-admin. +ADMIN_OWNER_ID="user_xxxxxxxxxxxxxxxxxxxx" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72d492a..4975db9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,8 @@ model Owner { id String @id // = Clerk userId (e.g. "user_xxx") createdAt DateTime @default(now()) pets Pet[] + adminRole AdminRole? + ban UserBan? } model Species { @@ -443,3 +445,61 @@ model Notification { @@index([recipientPetId, read, createdAt(sort: Desc)]) } + +// ─── Admin / Moderation Models ──────────────────────────────────────────────── + +enum AdminRoleType { + SUPER_ADMIN + MODERATOR +} + +model AdminRole { + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String @unique + role AdminRoleType @default(MODERATOR) + grantedAt DateTime @default(now()) + grantedBy String? // ownerId of the granting super admin +} + +model UserBan { + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String @unique + reason String? @db.VarChar(500) + bannedAt DateTime @default(now()) + bannedBy String // ownerId of moderator who issued the ban + expiresAt DateTime? // null = permanent ban + + @@index([ownerId]) +} + +model Advertisement { + id String @id @default(cuid()) + title String @db.VarChar(100) + caption String? @db.VarChar(500) + imageKey String? // Supabase Storage key + targetUrl String? @db.VarChar(500) + isActive Boolean @default(true) + species String[] // [] = all species; ["Dog"] = dogs only (future targeting) + startDate DateTime? + endDate DateTime? + createdBy String // ownerId of admin who created it + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive, startDate, endDate]) +} + +model ModerationLog { + id String @id @default(cuid()) + action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE + targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE + targetId String + reason String? @db.VarChar(500) + moderatorId String // ownerId + createdAt DateTime @default(now()) + + @@index([moderatorId, createdAt(sort: Desc)]) + @@index([targetType, targetId]) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 85fd47d..6ef3e17 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -30,7 +30,15 @@ export default async function AppLayout({ // Fetch the owner's pets via tRPC server caller (scoped by userId) const caller = await createTRPCCaller(); - const pets = await caller.pets.list(); + const [pets, ban] = await Promise.all([ + caller.pets.list(), + caller.admin.getActiveBan({ ownerId: userId }), + ]); + + // Banned users see an error page, not the app + if (ban) { + redirect("/banned"); + } // No pets → send to onboarding to create their first pet if (pets.length === 0) { diff --git a/src/app/banned/page.tsx b/src/app/banned/page.tsx new file mode 100644 index 0000000..13f1e9d --- /dev/null +++ b/src/app/banned/page.tsx @@ -0,0 +1,21 @@ +export default function BannedPage() { + return ( +
+
+
🚫
+

Account suspended

+

+ Your account has been suspended due to a violation of our community + guidelines. If you believe this is an error, please contact us at{" "} + + support@pawfeed.org + + . +

+
+
+ ); +} diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx new file mode 100644 index 0000000..d61908c --- /dev/null +++ b/src/app/datenschutz/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; + +function decode(b64: string): string { + try { + return atob(b64); + } catch { + return ""; + } +} + +const IMP_NAME = "RGFuaWVsIEJhcmFjIE9ubGluZWRpZW5zdGU="; +const IMP_STREET = "QWxwZW52ZWlsY2hlbndlZyA3"; +const IMP_CITY = "ODIxNDAgT2xjaGluZw=="; +const IMP_EMAIL = "d2VibWFzdGVyQHBhd2ZlZWQub3Jn"; + +export default function DatenschutzPage() { + const [revealed, setRevealed] = useState(false); + + useEffect(() => { + setRevealed(true); + }, []); + + const name = revealed ? decode(IMP_NAME) : "––"; + const street = revealed ? decode(IMP_STREET) : "––"; + const city = revealed ? decode(IMP_CITY) : "––"; + const email = revealed ? decode(IMP_EMAIL) : "––"; + + return ( +
+

Datenschutzerklärung

+ +

1. Verantwortlicher

+
+

{name}

+

{street}

+

{city}

+ {revealed ? ( +

E-Mail: {email}

+ ) : ( +

E-Mail: ––

+ )} +
+ +

2. Erhebung und Verarbeitung personenbezogener Daten

+

+ Wir erheben und verarbeiten personenbezogene Daten nur, soweit dies zur Bereitstellung + unserer Plattform erforderlich ist oder Sie uns Daten freiwillig mitgeteilt haben. +

+ + +

3. Rechtsgrundlagen der Verarbeitung

+

+ Die Verarbeitung personenbezogener Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b + DSGVO (Vertragserfüllung) sowie Art. 6 Abs. 1 lit. f DSGVO (berechtigte Interessen, + insbesondere Betrieb und Sicherheit der Plattform). +

+ +

4. Drittanbieter und Auftragsverarbeiter

+ + +

5. Cookies und Tracking

+

+ Wir verwenden technisch notwendige Cookies zur Sitzungsverwaltung. Tracking-Cookies oder + Cookies zu Werbezwecken werden ohne Ihre ausdrückliche Einwilligung nicht gesetzt. +

+ +

6. Speicherdauer

+

+ Personenbezogene Daten werden gelöscht, sobald der Zweck der Verarbeitung entfällt und + keine gesetzlichen Aufbewahrungspflichten entgegenstehen. Kontodaten werden nach Löschung + des Kontos innerhalb von 30 Tagen gelöscht. +

+ +

7. Ihre Rechte

+ +

+ Zur Ausübung Ihrer Rechte wenden Sie sich per E-Mail an{" "} + {revealed ? {email} : "––"}. +

+ +

8. Beschwerderecht

+

+ Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Zuständig + ist das Bayerische Landesamt für Datenschutzaufsicht (BayLDA),{" "} + + www.lda.bayern.de + + . +

+ +

9. Minderjährigenschutz

+

+ Unser Angebot richtet sich nicht an Personen unter 13 Jahren. Wir erheben wissentlich keine + personenbezogenen Daten von Kindern unter 13 Jahren. +

+ +

10. Aktualität dieser Erklärung

+

Stand: Juni 2026. Durch die Weiterentwicklung unserer Plattform kann es notwendig werden, + diese Erklärung zu ändern.

+
+ ); +} diff --git a/src/app/impressum/page.tsx b/src/app/impressum/page.tsx new file mode 100644 index 0000000..0294a84 --- /dev/null +++ b/src/app/impressum/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; + +function decode(b64: string): string { + try { + return atob(b64); + } catch { + return ""; + } +} + +// Base64-encoded contact data — decoded client-side only; crawlers see placeholders. +const IMP_NAME = "RGFuaWVsIEJhcmFjIE9ubGluZWRpZW5zdGU="; +const IMP_STREET = "QWxwZW52ZWlsY2hlbndlZyA3"; +const IMP_CITY = "ODIxNDAgT2xjaGluZw=="; +const IMP_EMAIL = "d2VibWFzdGVyQHBhd2ZlZWQub3Jn"; + +export default function ImpressumPage() { + const [revealed, setRevealed] = useState(false); + + useEffect(() => { + setRevealed(true); + }, []); + + return ( +
+

Impressum

+ +

Angaben gemäß § 5 TMG

+ {revealed ? ( +
+

{decode(IMP_NAME)}

+

{decode(IMP_STREET)}

+

{decode(IMP_CITY)}

+
+ ) : ( +
+

​

+

​

+

​

+
+ )} + +

Kontakt

+ {revealed ? ( +

+ E-Mail:{" "} + {decode(IMP_EMAIL)} +

+ ) : ( +

+ ​ +

+ )} + +

Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV

+ {revealed ? ( +
+

{decode(IMP_NAME)}

+

{decode(IMP_STREET)}

+

{decode(IMP_CITY)}

+
+ ) : ( +
+

​

+
+ )} + +

Streitschlichtung

+

+ Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:{" "} + + https://ec.europa.eu/consumers/odr/ + + . Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer + Verbraucherschlichtungsstelle teilzunehmen. +

+ +

Haftung für Inhalte

+

+ Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach + den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter + jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu + überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. +

+ +

Haftung für Links

+

+ Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen + Einfluss haben. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder + Betreiber der Seiten verantwortlich. +

+ +

Urheberrecht

+

+ Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen + dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der + Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung + des jeweiligen Autors bzw. Erstellers. +

+
+ ); +} diff --git a/src/app/p/[secret]/ads/page.tsx b/src/app/p/[secret]/ads/page.tsx new file mode 100644 index 0000000..2efa040 --- /dev/null +++ b/src/app/p/[secret]/ads/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { Plus, Pencil, Trash2, Loader2, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; + +type AdForm = { + title: string; + caption: string; + targetUrl: string; + isActive: boolean; + startDate: string; + endDate: string; +}; + +const emptyForm: AdForm = { + title: "", + caption: "", + targetUrl: "", + isActive: true, + startDate: "", + endDate: "", +}; + +export default function AdminAdsPage() { + const trpc = useTRPC(); + const qc = useQueryClient(); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteId, setDeleteId] = useState(null); + + const { data: rawAds, isLoading } = useQuery(trpc.admin.listAds.queryOptions()); + const ads = rawAds ?? []; + + const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listAds.queryKey() }); + + const createAd = useMutation( + trpc.admin.createAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } }) + ); + + const updateAd = useMutation( + trpc.admin.updateAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } }) + ); + + const deleteAd = useMutation( + trpc.admin.deleteAd.mutationOptions({ onSuccess: () => { invalidate(); setDeleteId(null); } }) + ); + + function openCreate() { + setForm(emptyForm); + setEditingId(null); + setDialogOpen(true); + } + + function openEdit(ad: (typeof ads)[number]) { + setForm({ + title: ad.title, + caption: ad.caption ?? "", + targetUrl: ad.targetUrl ?? "", + isActive: ad.isActive, + startDate: ad.startDate ? new Date(ad.startDate).toISOString().slice(0, 16) : "", + endDate: ad.endDate ? new Date(ad.endDate).toISOString().slice(0, 16) : "", + }); + setEditingId(ad.id); + setDialogOpen(true); + } + + function closeDialog() { + setDialogOpen(false); + setEditingId(null); + setForm(emptyForm); + } + + function submitForm() { + const payload = { + title: form.title, + caption: form.caption || undefined, + targetUrl: form.targetUrl || undefined, + isActive: form.isActive, + startDate: form.startDate ? new Date(form.startDate).toISOString() : undefined, + endDate: form.endDate ? new Date(form.endDate).toISOString() : undefined, + }; + if (editingId) { + updateAd.mutate({ id: editingId, ...payload }); + } else { + createAd.mutate({ ...payload, species: [] }); + } + } + + const isPending = createAd.isPending || updateAd.isPending; + + return ( +
+
+

Advertisements

+ +
+ + {isLoading ? ( +
+ +
+ ) : ads.length === 0 ? ( +

No ads yet — create one above

+ ) : ( +
+ {ads.map((ad) => ( +
+
+
+

{ad.title}

+ {ad.isActive ? ( + Active + ) : ( + Inactive + )} +
+ {ad.caption && ( +

{ad.caption}

+ )} + {ad.targetUrl && ( +

{ad.targetUrl}

+ )} +
+
+ + +
+
+ ))} +
+ )} + + {/* Create / Edit dialog */} + { if (!o) closeDialog(); }}> + + + {editingId ? "Edit ad" : "Create ad"} + +
+ setForm({ ...form, title: e.target.value })} + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" + /> +