feat(admin): add hidden admin panel, ban system, ads, and legal pages

- 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
This commit is contained in:
admin committed 2026-06-21 15:21:07 +02:00
1 parent e407064866
commit b5f02fe7a4
18 files changed
+1815 -6

No files matched your search

+9 -1
View File
@@ -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) {
+21
View File
@@ -0,0 +1,21 @@
export default function BannedPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md w-full text-center space-y-4">
<div className="text-5xl">🚫</div>
<h1 className="text-2xl font-bold">Account suspended</h1>
<p className="text-muted-foreground">
Your account has been suspended due to a violation of our community
guidelines. If you believe this is an error, please contact us at{" "}
<a
href="mailto:support@pawfeed.org"
className="underline underline-offset-2"
>
support@pawfeed.org
</a>
.
</p>
</div>
</div>
);
}
+121
View File
@@ -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 (
<div className="max-w-2xl mx-auto px-4 py-12 prose prose-sm dark:prose-invert">
<h1>Datenschutzerklärung</h1>
<h2>1. Verantwortlicher</h2>
<address className="not-italic">
<p>{name}</p>
<p>{street}</p>
<p>{city}</p>
{revealed ? (
<p>E-Mail: <a href={`mailto:${email}`}>{email}</a></p>
) : (
<p>E-Mail: ––</p>
)}
</address>
<h2>2. Erhebung und Verarbeitung personenbezogener Daten</h2>
<p>
Wir erheben und verarbeiten personenbezogene Daten nur, soweit dies zur Bereitstellung
unserer Plattform erforderlich ist oder Sie uns Daten freiwillig mitgeteilt haben.
</p>
<ul>
<li>Kontodaten (E-Mail-Adresse, Passwort-Hash) zur Authentifizierung</li>
<li>Profilangaben (Name des Haustiers, Tierart, Fotos, Biografie)</li>
<li>Von Ihnen erstellte Inhalte (Beiträge, Kommentare, Nachrichten)</li>
<li>Nutzungsdaten (IP-Adresse, Zeitstempel, Seitenaufrufe)</li>
</ul>
<h2>3. Rechtsgrundlagen der Verarbeitung</h2>
<p>
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).
</p>
<h2>4. Drittanbieter und Auftragsverarbeiter</h2>
<ul>
<li><strong>Clerk Inc.</strong> – Authentifizierungsdienst (USA). Datenübertragung auf Basis von EU-SCC.</li>
<li><strong>Supabase Inc.</strong> – Datenbankhosting (AWS EU-West). AVV abgeschlossen.</li>
<li><strong>Cloudflare Inc.</strong> – Medienspeicherung und CDN (USA/EU). AVV abgeschlossen.</li>
<li><strong>Mux Inc.</strong> – Videoverarbeitung (USA). SCC-Basis.</li>
</ul>
<h2>5. Cookies und Tracking</h2>
<p>
Wir verwenden technisch notwendige Cookies zur Sitzungsverwaltung. Tracking-Cookies oder
Cookies zu Werbezwecken werden ohne Ihre ausdrückliche Einwilligung nicht gesetzt.
</p>
<h2>6. Speicherdauer</h2>
<p>
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.
</p>
<h2>7. Ihre Rechte</h2>
<ul>
<li>Auskunft (Art. 15 DSGVO)</li>
<li>Berichtigung (Art. 16 DSGVO)</li>
<li>Löschung (Art. 17 DSGVO)</li>
<li>Einschränkung der Verarbeitung (Art. 18 DSGVO)</li>
<li>Datenübertragbarkeit (Art. 20 DSGVO)</li>
<li>Widerspruchsrecht (Art. 21 DSGVO)</li>
</ul>
<p>
Zur Ausübung Ihrer Rechte wenden Sie sich per E-Mail an{" "}
{revealed ? <a href={`mailto:${email}`}>{email}</a> : "––"}.
</p>
<h2>8. Beschwerderecht</h2>
<p>
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Zuständig
ist das Bayerische Landesamt für Datenschutzaufsicht (BayLDA),{" "}
<a href="https://www.lda.bayern.de" target="_blank" rel="noopener noreferrer">
www.lda.bayern.de
</a>
.
</p>
<h2>9. Minderjährigenschutz</h2>
<p>
Unser Angebot richtet sich nicht an Personen unter 13 Jahren. Wir erheben wissentlich keine
personenbezogenen Daten von Kindern unter 13 Jahren.
</p>
<h2>10. Aktualität dieser Erklärung</h2>
<p>Stand: Juni 2026. Durch die Weiterentwicklung unserer Plattform kann es notwendig werden,
diese Erklärung zu ändern.</p>
</div>
);
}
+104
View File
@@ -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 (
<div className="max-w-2xl mx-auto px-4 py-12 prose prose-sm dark:prose-invert">
<h1>Impressum</h1>
<h2>Angaben gemäß § 5 TMG</h2>
{revealed ? (
<address className="not-italic">
<p>{decode(IMP_NAME)}</p>
<p>{decode(IMP_STREET)}</p>
<p>{decode(IMP_CITY)}</p>
</address>
) : (
<address className="not-italic opacity-0 select-none" aria-hidden>
<p>&#8203;</p>
<p>&#8203;</p>
<p>&#8203;</p>
</address>
)}
<h2>Kontakt</h2>
{revealed ? (
<p>
E-Mail:{" "}
<a href={`mailto:${decode(IMP_EMAIL)}`}>{decode(IMP_EMAIL)}</a>
</p>
) : (
<p className="opacity-0 select-none" aria-hidden>
&#8203;
</p>
)}
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
{revealed ? (
<address className="not-italic">
<p>{decode(IMP_NAME)}</p>
<p>{decode(IMP_STREET)}</p>
<p>{decode(IMP_CITY)}</p>
</address>
) : (
<address className="not-italic opacity-0 select-none" aria-hidden>
<p>&#8203;</p>
</address>
)}
<h2>Streitschlichtung</h2>
<p>
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:{" "}
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer">
https://ec.europa.eu/consumers/odr/
</a>
. Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen.
</p>
<h2>Haftung für Inhalte</h2>
<p>
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.
</p>
<h2>Haftung für Links</h2>
<p>
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.
</p>
<h2>Urheberrecht</h2>
<p>
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.
</p>
</div>
);
}
+261
View File
@@ -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<AdForm>(emptyForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-zinc-100">Advertisements</h1>
<Button size="sm" onClick={openCreate} className="bg-orange-500 hover:bg-orange-600 text-white">
<Plus className="h-4 w-4 mr-1.5" />
New ad
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : ads.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">No ads yet — create one above</p>
) : (
<div className="space-y-2">
{ads.map((ad) => (
<div
key={ad.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-zinc-100 truncate">{ad.title}</p>
{ad.isActive ? (
<Badge className="bg-green-700 text-green-100 text-[10px]">Active</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">Inactive</Badge>
)}
</div>
{ad.caption && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{ad.caption}</p>
)}
{ad.targetUrl && (
<p className="text-xs text-zinc-600 truncate">{ad.targetUrl}</p>
)}
</div>
<div className="flex gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
onClick={() => openEdit(ad)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300"
onClick={() => setDeleteId(ad.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* Create / Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "Edit ad" : "Create ad"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="Title *"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<Textarea
placeholder="Caption"
value={form.caption}
onChange={(e) => setForm({ ...form, caption: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
rows={3}
/>
<Input
placeholder="Target URL (https://…)"
value={form.targetUrl}
onChange={(e) => setForm({ ...form, targetUrl: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-zinc-500">Start date</label>
<Input
type="datetime-local"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-500">End date</label>
<Input
type="datetime-local"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Switch
checked={form.isActive}
onCheckedChange={(v: boolean) => setForm({ ...form, isActive: v })}
/>
<span className="text-sm text-zinc-300">Active</span>
</div>
</div>
<DialogFooter className="gap-2 mt-2">
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
<X className="h-4 w-4 mr-1.5" />
Cancel
</Button>
<Button
onClick={submitForm}
disabled={isPending || !form.title.trim()}
className="bg-orange-500 hover:bg-orange-600 text-white"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Check className="h-4 w-4 mr-1.5" />
)}
{editingId ? "Save" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Delete ad</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">Delete this advertisement permanently?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteAd.mutate({ id: deleteId! })}
disabled={deleteAd.isPending}
>
{deleteAd.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@clerk/nextjs/server";
import Link from "next/link";
import { createTRPCCaller } from "@/trpc/server";
import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText } from "lucide-react";
interface AdminLayoutProps {
children: React.ReactNode;
params: Promise<{ secret: string }>;
}
export default async function AdminLayout({ children, params }: AdminLayoutProps) {
const { secret } = await params;
// Double-check secret (middleware already validated, this is defense-in-depth)
const adminSecret = process.env.ADMIN_SECRET;
if (!adminSecret || secret !== adminSecret) {
notFound();
}
const { userId } = await auth();
if (!userId) {
redirect("/log-in");
}
// Allow ADMIN_OWNER_ID (bootstrapping) or any user with an AdminRole in the DB
const isSuperOwner = process.env.ADMIN_OWNER_ID === userId;
if (!isSuperOwner) {
const caller = await createTRPCCaller();
const roles = await caller.admin.listModerators();
const hasRole = roles.some((r) => r.ownerId === userId);
if (!hasRole) {
notFound();
}
}
const base = `/p/${secret}`;
const nav = [
{ href: base, label: "Dashboard", Icon: LayoutDashboard },
{ href: `${base}/posts`, label: "Posts", Icon: FileText },
{ href: `${base}/users`, label: "Users", Icon: Users },
{ href: `${base}/ads`, label: "Ads", Icon: Megaphone },
{ href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck },
];
return (
<div className="flex min-h-screen bg-zinc-950 text-zinc-100">
{/* Sidebar */}
<aside className="w-56 shrink-0 border-r border-zinc-800 flex flex-col py-6 px-4 gap-1">
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-widest px-2 mb-3">
PawFeed Admin
</p>
{nav.map(({ href, label, Icon }) => (
<Link
key={href}
href={href}
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 transition-colors"
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
))}
</aside>
{/* Content */}
<main className="flex-1 overflow-auto p-8">{children}</main>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AdminRoleType } from "@prisma/client";
export default function AdminModeratorsPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [newOwnerId, setNewOwnerId] = useState("");
const [newRole, setNewRole] = useState<AdminRoleType>(AdminRoleType.MODERATOR);
const [revokeTarget, setRevokeTarget] = useState<string | null>(null);
const { data: mods = [], isLoading } = useQuery(trpc.admin.listModerators.queryOptions());
const invalidate = () =>
void qc.invalidateQueries({ queryKey: trpc.admin.listModerators.queryKey() });
const grantRole = useMutation(
trpc.admin.grantRole.mutationOptions({
onSuccess: () => {
invalidate();
setDialogOpen(false);
setNewOwnerId("");
setNewRole(AdminRoleType.MODERATOR);
},
})
);
const revokeRole = useMutation(
trpc.admin.revokeRole.mutationOptions({
onSuccess: () => {
invalidate();
setRevokeTarget(null);
},
})
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Moderators</h1>
<p className="text-xs text-zinc-500 mt-0.5">Only super-admins can manage roles</p>
</div>
<Button size="sm" onClick={() => setDialogOpen(true)} className="bg-zinc-700 hover:bg-zinc-600">
<Plus className="h-4 w-4 mr-1.5" />
Grant role
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : mods.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">No moderators yet</p>
) : (
<div className="space-y-2">
{mods.map((mod) => {
const pet = mod.owner.pets[0];
return (
<div
key={mod.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm text-zinc-100 font-mono truncate">{mod.ownerId}</p>
<Badge
className={
mod.role === AdminRoleType.SUPER_ADMIN
? "bg-orange-700 text-orange-100 text-[10px]"
: "bg-zinc-700 text-zinc-200 text-[10px]"
}
>
{mod.role}
</Badge>
</div>
{pet && <p className="text-xs text-zinc-500 mt-0.5">Pet: {pet.name}</p>}
<p className="text-xs text-zinc-600 mt-0.5">
Granted {new Date(mod.grantedAt).toLocaleDateString()}
{mod.grantedBy && ` by ${mod.grantedBy.slice(0, 12)}…`}
</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 shrink-0"
onClick={() => setRevokeTarget(mod.ownerId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
{/* Grant role dialog */}
<Dialog
open={dialogOpen}
onOpenChange={(o) => { if (!o) { setDialogOpen(false); setNewOwnerId(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Grant role</DialogTitle>
</DialogHeader>
<p className="text-xs text-zinc-500">
User must already have an account. Paste their Clerk user ID (starts with{" "}
<code className="text-orange-400">user_</code>).
</p>
<Input
placeholder="user_xxxxxxxxxxxxxxxxxxxx"
value={newOwnerId}
onChange={(e) => setNewOwnerId(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 font-mono text-sm"
/>
<Select value={newRole} onValueChange={(v) => setNewRole(v as AdminRoleType)}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-zinc-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value={AdminRoleType.MODERATOR}>Moderator</SelectItem>
<SelectItem value={AdminRoleType.SUPER_ADMIN}>Super Admin</SelectItem>
</SelectContent>
</Select>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => grantRole.mutate({ ownerId: newOwnerId.trim(), role: newRole })}
disabled={grantRole.isPending || !newOwnerId.trim()}
className="bg-zinc-700 hover:bg-zinc-600"
>
{grantRole.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Grant
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revoke confirm */}
<Dialog open={!!revokeTarget} onOpenChange={(o) => { if (!o) setRevokeTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Revoke role</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">Remove admin/moderator access for this user?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setRevokeTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => revokeRole.mutate({ ownerId: revokeTarget! })}
disabled={revokeRole.isPending}
>
{revokeRole.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Revoke
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Users, PawPrint, FileText, ShieldOff, Megaphone, Flag } from "lucide-react";
export default function AdminDashboard() {
const trpc = useTRPC();
const { data: stats, isLoading } = useQuery(trpc.admin.getStats.queryOptions());
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Dashboard</h1>
<p className="text-sm text-zinc-500 mt-1">Platform overview</p>
</div>
{isLoading ? (
<div className="text-zinc-500 text-sm">Loading…</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatCard icon={Users} label="Owners" value={stats?.owners ?? 0} />
<StatCard icon={PawPrint} label="Pets" value={stats?.pets ?? 0} />
<StatCard icon={FileText} label="Posts" value={stats?.posts ?? 0} />
<StatCard icon={ShieldOff} label="Banned users" value={stats?.bans ?? 0} color="text-red-400" />
<StatCard icon={Megaphone} label="Active ads" value={stats?.activeAds ?? 0} color="text-orange-400" />
<StatCard icon={Flag} label="Open reports" value={stats?.reports ?? 0} color="text-yellow-400" />
</div>
)}
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
color = "text-zinc-100",
}: {
icon: React.ElementType;
label: string;
value: number;
color?: string;
}) {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-5 py-4 flex items-center gap-4">
<Icon className={`h-5 w-5 shrink-0 ${color}`} />
<div>
<p className={`text-2xl font-bold ${color}`}>{value.toLocaleString()}</p>
<p className="text-xs text-zinc-500 mt-0.5">{label}</p>
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useState } from "react";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Trash2, MessageSquare, Loader2, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { getMediaUrl } from "@/lib/media-url";
export default function AdminPostsPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [commentsPostId, setCommentsPostId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; type: "post" | "comment" } | null>(null);
const [reason, setReason] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(
trpc.admin.listPosts.infiniteQueryOptions(
{ limit: 20, search: search || undefined },
{ getNextPageParam: (p) => p.nextCursor, initialCursor: undefined }
)
);
const posts = data?.pages.flatMap((p) => p.posts) ?? [];
const { data: comments } = useQuery(
trpc.admin.listComments.queryOptions(
{ postId: commentsPostId ?? "" },
{ enabled: !!commentsPostId }
)
);
const deletePost = useMutation(
trpc.admin.deletePost.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() });
setDeleteTarget(null);
setReason("");
},
})
);
const deleteComment = useMutation(
trpc.admin.deleteComment.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({
queryKey: trpc.admin.listComments.queryKey({ postId: commentsPostId ?? "" }),
});
setDeleteTarget(null);
setReason("");
},
})
);
function confirmDelete() {
if (!deleteTarget) return;
if (deleteTarget.type === "post") {
deletePost.mutate({ postId: deleteTarget.id, reason: reason || undefined });
} else {
deleteComment.mutate({ commentId: deleteTarget.id, reason: reason || undefined });
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-zinc-100">Posts</h1>
<Input
placeholder="Search caption…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64 bg-zinc-900 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : (
<div className="space-y-2">
{posts.map((post) => {
const thumb = post.images[0];
return (
<div
key={post.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
{thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(thumb.storageKey) ?? ""}
alt=""
className="h-12 w-12 rounded-lg object-cover shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-lg bg-zinc-800 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-100 truncate">
{post.caption ?? <em className="text-zinc-500">No caption</em>}
</p>
<p className="text-xs text-zinc-500">
{post.pet.name} · {post._count.reactions} 🐾 · {post._count.comments} 💬
{post._count.reports > 0 && (
<span className="ml-2 text-yellow-400">
⚑ {post._count.reports} report{post._count.reports !== 1 ? "s" : ""}
</span>
)}
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
onClick={() => setCommentsPostId(post.id)}
>
<MessageSquare className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300"
onClick={() => setDeleteTarget({ id: post.id, type: "post" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
{hasNextPage && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="border-zinc-700 text-zinc-300"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<ChevronDown className="h-4 w-4 mr-2" />
)}
Load more
</Button>
</div>
)}
</div>
)}
{/* Comments dialog */}
<Dialog
open={!!commentsPostId}
onOpenChange={(o) => { if (!o) setCommentsPostId(null); }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-lg">
<DialogHeader>
<DialogTitle>Comments</DialogTitle>
</DialogHeader>
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
{(comments ?? []).length === 0 && (
<p className="text-sm text-zinc-500 text-center py-4">No comments</p>
)}
{(comments ?? []).map((c) => (
<div
key={c.id}
className="flex items-start gap-3 rounded-lg border border-zinc-800 px-3 py-2"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-zinc-300">{c.pet.name}</p>
<p className="text-sm text-zinc-100 mt-0.5">{c.body}</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 shrink-0"
onClick={() => setDeleteTarget({ id: c.id, type: "comment" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</DialogContent>
</Dialog>
{/* Confirm delete dialog */}
<Dialog
open={!!deleteTarget}
onOpenChange={(o) => { if (!o) { setDeleteTarget(null); setReason(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
Delete this {deleteTarget?.type}? This action cannot be undone.
</p>
<Input
placeholder="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deletePost.isPending || deleteComment.isPending}
>
{(deletePost.isPending || deleteComment.isPending) && (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { ShieldOff, ShieldCheck, Loader2, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
export default function AdminUsersPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [banTarget, setBanTarget] = useState<string | null>(null);
const [unbanTarget, setUnbanTarget] = useState<string | null>(null);
const [banReason, setBanReason] = useState("");
const [banExpiry, setBanExpiry] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(
trpc.admin.listUsers.infiniteQueryOptions(
{ limit: 20 },
{ getNextPageParam: (p) => p.nextCursor, initialCursor: undefined }
)
);
const users = data?.pages.flatMap((p) => p.users) ?? [];
const banMutation = useMutation(
trpc.admin.banUser.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
setBanTarget(null);
setBanReason("");
setBanExpiry("");
},
})
);
const unbanMutation = useMutation(
trpc.admin.unbanUser.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
setUnbanTarget(null);
},
})
);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-zinc-100">Users</h1>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : (
<div className="space-y-2">
{users.map((user) => (
<div
key={user.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm text-zinc-100 font-mono truncate">{user.id}</p>
{user.adminRole && (
<Badge variant="secondary" className="text-[10px] shrink-0">
{user.adminRole.role}
</Badge>
)}
{user.ban && (
<Badge variant="destructive" className="text-[10px] shrink-0">
BANNED
</Badge>
)}
</div>
<p className="text-xs text-zinc-500 mt-0.5">
{user._count.pets} pet{user._count.pets !== 1 ? "s" : ""}
{user.pets.length > 0 && " · " + user.pets.map((p) => p.name).join(", ")}
</p>
{user.ban?.reason && (
<p className="text-xs text-red-400 mt-0.5">Reason: {user.ban.reason}</p>
)}
</div>
<div className="shrink-0">
{user.ban ? (
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
onClick={() => setUnbanTarget(user.id)}
>
<ShieldCheck className="h-4 w-4 mr-1.5" />
Unban
</Button>
) : (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300"
onClick={() => setBanTarget(user.id)}
>
<ShieldOff className="h-4 w-4 mr-1.5" />
Ban
</Button>
)}
</div>
</div>
))}
{hasNextPage && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="border-zinc-700 text-zinc-300"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<ChevronDown className="h-4 w-4 mr-2" />
)}
Load more
</Button>
</div>
)}
</div>
)}
{/* Ban dialog */}
<Dialog
open={!!banTarget}
onOpenChange={(o) => { if (!o) { setBanTarget(null); setBanReason(""); setBanExpiry(""); } }}
>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Ban user</DialogTitle>
</DialogHeader>
<p className="text-xs text-zinc-500 font-mono break-all">{banTarget}</p>
<Input
placeholder="Reason (optional)"
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<div className="space-y-1">
<label className="text-xs text-zinc-500">
Expires (optional — leave empty for permanent)
</label>
<Input
type="datetime-local"
value={banExpiry}
onChange={(e) => setBanExpiry(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100"
/>
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setBanTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() =>
banMutation.mutate({
ownerId: banTarget!,
reason: banReason || undefined,
expiresAt: banExpiry ? new Date(banExpiry).toISOString() : undefined,
})
}
disabled={banMutation.isPending}
>
{banMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Confirm ban
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Unban confirm */}
<Dialog open={!!unbanTarget} onOpenChange={(o) => { if (!o) setUnbanTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Unban user</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">Remove the ban for this user?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setUnbanTarget(null)}>
Cancel
</Button>
<Button
onClick={() => unbanMutation.mutate({ ownerId: unbanTarget! })}
disabled={unbanMutation.isPending}
className="bg-zinc-700 hover:bg-zinc-600"
>
{unbanMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Unban
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { ExternalLink } from "lucide-react";
type AdCardAd = {
id: string;
title: string;
caption: string | null;
targetUrl: string | null;
imageKey: string | null;
};
export function AdCard({ ad }: { ad: AdCardAd }) {
const inner = (
<div className="rounded-2xl border border-orange-200/30 bg-orange-50/5 px-5 py-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
Sponsored
</span>
{ad.targetUrl && <ExternalLink className="h-3 w-3 text-muted-foreground ml-auto" />}
</div>
<p className="text-sm font-semibold text-foreground">{ad.title}</p>
{ad.caption && (
<p className="text-sm text-muted-foreground leading-relaxed">{ad.caption}</p>
)}
</div>
);
if (ad.targetUrl) {
return (
<a
href={ad.targetUrl}
target="_blank"
rel="noopener noreferrer sponsored"
className="block no-underline hover:opacity-90 transition-opacity"
aria-label={`Sponsored: ${ad.title}`}
>
{inner}
</a>
);
}
return inner;
}
+29 -5
View File
@@ -3,13 +3,15 @@
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { useTRPC } from "@/trpc/client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useActivePet } from "@/context/ActivePetContext";
import { PostCard } from "@/components/feed/PostCard";
import { AdCard } from "@/components/feed/AdCard";
import { SkeletonCard } from "@/components/feed/SkeletonCard";
import { EmptyFeed } from "@/components/feed/EmptyFeed";
import { WelcomeCard } from "@/components/feed/WelcomeCard";
import { Sparkles, Clock } from "lucide-react";
const AD_INTERVAL = 7;
type FeedMode = "chrono" | "algo";
@@ -62,8 +64,26 @@ export function FeedList() {
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const { data: activeAds } = useQuery(trpc.admin.getActiveAds.queryOptions());
const ads = activeAds ?? [];
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
// Build interleaved feed: inject one ad every AD_INTERVAL posts (cycle through ads)
type Post = (typeof allPosts)[number];
type Ad = (typeof ads)[number];
type FeedItem = { kind: "post"; post: Post } | { kind: "ad"; ad: Ad; key: string };
const feedItems: FeedItem[] = [];
let adIndex = 0;
allPosts.forEach((post, i) => {
feedItems.push({ kind: "post", post });
if (ads.length > 0 && (i + 1) % AD_INTERVAL === 0) {
feedItems.push({ kind: "ad", ad: ads[adIndex % ads.length]!, key: `ad-${i}-${adIndex}` });
adIndex++;
}
});
if (isLoading) {
return (
<div className="flex flex-col gap-8 py-4">
@@ -109,11 +129,15 @@ export function FeedList() {
{/* New-user welcome card */}
{activePetId && <WelcomeCard petId={activePetId} />}
{/* Feed posts */}
{/* Feed posts + injected ads */}
<div className="flex flex-col gap-8">
{allPosts.map((post) => (
<PostCard key={post.id} post={{ ...post, createdAt: post.createdAt }} />
))}
{feedItems.map((item) =>
item.kind === "post" ? (
<PostCard key={item.post.id} post={{ ...item.post, createdAt: item.post.createdAt }} />
) : (
<AdCard key={item.key} ad={item.ad} />
)
)}
</div>
<div ref={sentinelRef} aria-hidden="true" className="h-4" />
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+41
View File
@@ -0,0 +1,41 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
const isPublicRoute = createRouteMatcher([
"/log-in(.*)",
"/sign-up(.*)",
"/forgot-password(.*)",
"/reset-password(.*)",
"/verify-email(.*)",
"/health-card/(.*)",
"/impressum",
"/datenschutz",
"/api/webhooks/(.*)",
]);
export default clerkMiddleware(async (auth, request) => {
const { pathname } = request.nextUrl;
// Admin panel: validate secret path segment before anything else.
// Returns 404 (not 403) — the panel's existence must not be revealed.
if (pathname.startsWith("/p/")) {
const segments = pathname.split("/");
const providedSecret = segments[2] ?? "";
const adminSecret = process.env.ADMIN_SECRET ?? "";
if (!adminSecret || providedSecret !== adminSecret) {
return new NextResponse(null, { status: 404 });
}
}
if (!isPublicRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
+2
View File
@@ -17,6 +17,7 @@ import { notificationsRouter } from "@/trpc/routers/notifications";
import { videosRouter } from "@/trpc/routers/videos";
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
import { healthRouter } from "@/trpc/routers/health";
import { adminRouter } from "@/trpc/routers/admin";
export const appRouter = router({
pets: petsRouter,
@@ -38,6 +39,7 @@ export const appRouter = router({
conversations: conversationsRouter,
messages: messagesRouter,
health: healthRouter,
admin: adminRouter,
});
// Export the type for use in the client
+348
View File
@@ -0,0 +1,348 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AdminRoleType, type PrismaClient } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
async function assertAdmin(
prisma: PrismaClient,
userId: string,
requireSuperAdmin = false
) {
const isSuperOwner = process.env.ADMIN_OWNER_ID === userId;
if (isSuperOwner && !requireSuperAdmin) return;
if (isSuperOwner) return;
const role = await prisma.adminRole.findUnique({ where: { ownerId: userId } });
if (!role) throw new TRPCError({ code: "FORBIDDEN" });
if (requireSuperAdmin && role.role !== AdminRoleType.SUPER_ADMIN) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
export const adminRouter = router({
// ─── Ban status check (no admin role required) ───────────────────────────────
getActiveBan: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
// Only the owner themselves or an admin should query ban status
if (input.ownerId !== ctx.userId) {
const isAdmin =
process.env.ADMIN_OWNER_ID === ctx.userId ||
(await ctx.prisma.adminRole.findUnique({ where: { ownerId: ctx.userId } })) !== null;
if (!isAdmin) throw new TRPCError({ code: "FORBIDDEN" });
}
const ban = await ctx.prisma.userBan.findUnique({ where: { ownerId: input.ownerId } });
if (!ban) return null;
// Check if a temporary ban has expired
if (ban.expiresAt && ban.expiresAt < new Date()) {
await ctx.prisma.userBan.delete({ where: { id: ban.id } }).catch(() => {});
return null;
}
return ban;
}),
// ─── Dashboard ───────────────────────────────────────────────────────────────
getStats: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const [owners, pets, posts, bans, ads, reports] = await Promise.all([
ctx.prisma.owner.count(),
ctx.prisma.pet.count(),
ctx.prisma.post.count(),
ctx.prisma.userBan.count(),
ctx.prisma.advertisement.count({ where: { isActive: true } }),
ctx.prisma.report.count(),
]);
return { owners, pets, posts, bans, activeAds: ads, reports };
}),
// ─── Post moderation ─────────────────────────────────────────────────────────
listPosts: protectedProcedure
.input(
z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(50).default(20),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const posts = await ctx.prisma.post.findMany({
where: input.search
? { caption: { contains: input.search, mode: "insensitive" } }
: undefined,
include: {
pet: { select: { id: true, name: true, avatarKey: true, ownerId: true } },
images: { orderBy: { position: "asc" }, take: 1 },
_count: { select: { reactions: true, comments: true, reports: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
});
const hasNext = posts.length > input.limit;
const items = hasNext ? posts.slice(0, input.limit) : posts;
return { posts: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
}),
deletePost: protectedProcedure
.input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
await ctx.prisma.post.delete({ where: { id: input.postId } });
await ctx.prisma.moderationLog.create({
data: {
action: "DELETE_POST",
targetType: "POST",
targetId: input.postId,
reason: input.reason ?? null,
moderatorId: ctx.userId,
},
});
return { success: true };
}),
listComments: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.comment.findMany({
where: { postId: input.postId },
include: { pet: { select: { id: true, name: true, avatarKey: true } } },
orderBy: { createdAt: "asc" },
});
}),
deleteComment: protectedProcedure
.input(z.object({ commentId: z.string().min(1), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
await ctx.prisma.comment.delete({ where: { id: input.commentId } });
await ctx.prisma.moderationLog.create({
data: {
action: "DELETE_COMMENT",
targetType: "COMMENT",
targetId: input.commentId,
reason: input.reason ?? null,
moderatorId: ctx.userId,
},
});
return { success: true };
}),
// ─── Users / bans ────────────────────────────────────────────────────────────
listUsers: protectedProcedure
.input(z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(20) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const owners = await ctx.prisma.owner.findMany({
include: {
pets: { select: { id: true, name: true, avatarKey: true }, take: 3 },
ban: true,
adminRole: true,
_count: { select: { pets: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
});
const hasNext = owners.length > input.limit;
const items = hasNext ? owners.slice(0, input.limit) : owners;
return { users: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
}),
banUser: protectedProcedure
.input(
z.object({
ownerId: z.string().min(1),
reason: z.string().max(500).optional(),
expiresAt: z.string().datetime().optional(),
})
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
if (input.ownerId === ctx.userId)
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot ban yourself." });
await ctx.prisma.userBan.upsert({
where: { ownerId: input.ownerId },
create: {
ownerId: input.ownerId,
reason: input.reason ?? null,
bannedBy: ctx.userId,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
},
update: {
reason: input.reason ?? null,
bannedBy: ctx.userId,
bannedAt: new Date(),
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
},
});
await ctx.prisma.moderationLog.create({
data: {
action: "BAN_USER",
targetType: "USER",
targetId: input.ownerId,
reason: input.reason ?? null,
moderatorId: ctx.userId,
},
});
return { success: true };
}),
unbanUser: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
await ctx.prisma.userBan.delete({ where: { ownerId: input.ownerId } });
await ctx.prisma.moderationLog.create({
data: {
action: "UNBAN_USER",
targetType: "USER",
targetId: input.ownerId,
reason: null,
moderatorId: ctx.userId,
},
});
return { success: true };
}),
// ─── Reports ─────────────────────────────────────────────────────────────────
listReports: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.report.findMany({
include: {
reporter: { select: { id: true, name: true } },
post: { select: { id: true, caption: true, petId: true } },
},
orderBy: { createdAt: "desc" },
take: 50,
});
}),
// ─── Advertisements ──────────────────────────────────────────────────────────
listAds: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.advertisement.findMany({ orderBy: { createdAt: "desc" } });
}),
createAd: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(100),
caption: z.string().max(500).optional(),
imageKey: z.string().optional(),
targetUrl: z.string().url().or(z.literal("")).optional(),
isActive: z.boolean().default(true),
species: z.array(z.string()).default([]),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
})
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.advertisement.create({
data: {
title: input.title,
caption: input.caption ?? null,
imageKey: input.imageKey ?? null,
targetUrl: input.targetUrl || null,
isActive: input.isActive,
species: input.species,
startDate: input.startDate ? new Date(input.startDate) : null,
endDate: input.endDate ? new Date(input.endDate) : null,
createdBy: ctx.userId,
},
});
}),
updateAd: protectedProcedure
.input(
z.object({
id: z.string().min(1),
title: z.string().min(1).max(100).optional(),
caption: z.string().max(500).nullable().optional(),
imageKey: z.string().nullable().optional(),
targetUrl: z.string().url().or(z.literal("")).nullable().optional(),
isActive: z.boolean().optional(),
species: z.array(z.string()).optional(),
startDate: z.string().datetime().nullable().optional(),
endDate: z.string().datetime().nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const { id, targetUrl, startDate, endDate, ...rest } = input;
return ctx.prisma.advertisement.update({
where: { id },
data: {
...rest,
targetUrl: targetUrl === "" ? null : targetUrl,
startDate: startDate ? new Date(startDate) : startDate === null ? null : undefined,
endDate: endDate ? new Date(endDate) : endDate === null ? null : undefined,
},
});
}),
deleteAd: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
await ctx.prisma.advertisement.delete({ where: { id: input.id } });
return { success: true };
}),
getActiveAds: protectedProcedure.query(async ({ ctx }) => {
const now = new Date();
return ctx.prisma.advertisement.findMany({
where: {
isActive: true,
OR: [{ startDate: null }, { startDate: { lte: now } }],
AND: [{ OR: [{ endDate: null }, { endDate: { gte: now } }] }],
},
orderBy: { createdAt: "desc" },
});
}),
// ─── Moderator management (super-admin only) ─────────────────────────────────
listModerators: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.adminRole.findMany({
include: {
owner: {
include: { pets: { select: { name: true, avatarKey: true }, take: 1 } },
},
},
orderBy: { grantedAt: "desc" },
});
}),
grantRole: protectedProcedure
.input(z.object({ ownerId: z.string().min(1), role: z.nativeEnum(AdminRoleType) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, true);
return ctx.prisma.adminRole.upsert({
where: { ownerId: input.ownerId },
create: { ownerId: input.ownerId, role: input.role, grantedBy: ctx.userId },
update: { role: input.role, grantedBy: ctx.userId, grantedAt: new Date() },
});
}),
revokeRole: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, true);
await ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } });
return { success: true };
}),
getModerationLog: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(50) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.moderationLog.findMany({
orderBy: { createdAt: "desc" },
take: input.limit,
});
}),
});