feat: admin dashboard

This commit is contained in:
gosangam
2026-06-16 14:50:09 +05:30
commit a70c50254e
30 changed files with 5572 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# The BFF base URL (no trailing slash). The admin app calls ${NEXT_PUBLIC_BFF_URL}/api/v1/...
# Locally the BFF runs on :8080; in production point this at your API host.
NEXT_PUBLIC_BFF_URL=http://localhost:8080
# IMPORTANT — also set ADMIN_URL on the BFF to this app's origin so CORS allows
# credentialed requests from here:
# dev: ADMIN_URL=http://localhost:3001
# prod: ADMIN_URL=https://admin.prabhupadasays.org
+9
View File
@@ -0,0 +1,9 @@
/node_modules
/.next
/out
/build
*.tsbuildinfo
next-env.d.ts
.env
.env*.local
.DS_Store
+69
View File
@@ -0,0 +1,69 @@
# Prabhupāda Says — Admin
A separate Next.js 16 + Tailwind v4 admin panel that talks to the existing Go
BFF. It reuses the BFF's cookie session (no separate auth system) and every
action is gated server-side by the new `/api/v1/admin/*` endpoints, which
require `role = "admin"`.
## Features
- Email/password login (with the BFF's OTP step when required) + forgot/reset password
- Users — search, paginate, **send a notification** to an individual user
- Posts — search, paginate
- Collections — search, paginate
- Feedback — paginate, filter (all/open/resolved), **reply** (emails the submitter)
- Reports — posts / comments / accounts tabs, search, paginate, **resolve**
## Prerequisites
The BFF must be running with migration `0038_admin_panel` applied (it runs
automatically on BFF startup) and these env vars set **on the BFF**:
```
ADMIN_URL=http://localhost:3001 # this app's origin — added to CORS
# (production: ADMIN_URL=https://admin.prabhupadasays.org)
```
Without `ADMIN_URL`, the browser's credentialed requests from this origin are
blocked by CORS.
## Setup
```bash
cd spsays-admin
cp .env.example .env.local # set NEXT_PUBLIC_BFF_URL
yarn install
yarn dev # http://localhost:3001
```
## Make yourself an admin
The panel only admits users whose role is `admin`. Promote your account once,
directly in Postgres:
```sql
UPDATE users SET role = 'admin' WHERE email = 'you@example.com';
```
Then sign in at `/login` with that account's email + password.
## Production notes
- Deploy on its own subdomain, e.g. `admin.prabhupadasays.org`, and set
`ADMIN_URL` on the BFF to that origin. The session cookie is shared because
the BFF sets it on `.prabhupadasays.org`.
- The app sets `robots: noindex` and should additionally be access-restricted
at the edge if desired — the BFF's `RequireAdmin` is the real security
boundary, but defense in depth doesn't hurt.
## How it maps to the BFF
| Screen | Endpoint |
| ----------- | ------------------------------------------ |
| Login | `POST /auth/login` (+ `verify-login-otp`) |
| Forgot/Reset| `POST /auth/forgot-password` / `reset-password` |
| Users | `GET /admin/users`, `POST /admin/users/{id}/notify` |
| Posts | `GET /admin/posts` |
| Collections | `GET /admin/collections` |
| Feedback | `GET /admin/feedback`, `POST /admin/feedback/{id}/reply` |
| Reports | `GET /admin/reports?type=`, `POST /admin/reports/{type}/{id}/resolve` |
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useCallback, useState } from "react";
import { api, type AdminCollection } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { fmtDate } from "@/lib/utils";
export default function CollectionsPage() {
const [q, setQ] = useState("");
const fetcher = useCallback(
(limit: number, offset: number) => api.collections(q, limit, offset),
[q],
);
const list = usePagedList<AdminCollection>(fetcher, [q]);
return (
<ListShell
title="Collections"
total={list.total}
searchPlaceholder="Search title, description, or owner…"
onQueryChange={setQ}
list={list}
>
<Table>
<THead>
<TR>
<TH>Collection</TH>
<TH>Owner</TH>
<TH>Items</TH>
<TH>Visibility</TH>
<TH>Created</TH>
</TR>
</THead>
<tbody>
{list.items.length === 0 && !list.loading ? (
<EmptyRow cols={5} label="No collections found." />
) : (
list.items.map((c) => (
<TR key={c.id}>
<TD className="max-w-md">
<div className="font-medium">{c.title}</div>
{c.description ? (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{c.description}
</div>
) : null}
</TD>
<TD className="whitespace-nowrap">
<div className="font-medium">{c.owner_name}</div>
<div className="text-xs text-muted-foreground">
@{c.owner_handle}
</div>
</TD>
<TD>{c.item_count}</TD>
<TD>
<span
className={
c.is_public ? "text-foreground" : "text-muted-foreground"
}
>
{c.is_public ? "Public" : "Private"}
</span>
</TD>
<TD className="whitespace-nowrap">{fmtDate(c.created_at)}</TD>
</TR>
))
)}
</tbody>
</Table>
</ListShell>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { api, AdminError, type AdminFeedback } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/input";
import { cn, fmtDate } from "@/lib/utils";
const STATUSES = ["all", "open", "resolved"] as const;
type Status = (typeof STATUSES)[number];
export default function FeedbackPage() {
const [status, setStatus] = useState<Status>("all");
const fetcher = useCallback(
(limit: number, offset: number) => api.feedback(status, limit, offset),
[status],
);
const list = usePagedList<AdminFeedback>(fetcher, [status]);
const [reply, setReply] = useState<AdminFeedback | null>(null);
return (
<ListShell
title="Feedback"
total={list.total}
list={list}
toolbar={
<div className="flex gap-1 rounded-lg border border-border bg-card p-1">
{STATUSES.map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={cn(
"rounded-md px-3 py-1.5 text-sm capitalize transition-colors",
status === s
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
{s}
</button>
))}
</div>
}
>
<Table>
<THead>
<TR>
<TH>From</TH>
<TH>Message</TH>
<TH>Status</TH>
<TH>Received</TH>
<TH className="text-right">Actions</TH>
</TR>
</THead>
<tbody>
{list.items.length === 0 && !list.loading ? (
<EmptyRow cols={5} label="No feedback." />
) : (
list.items.map((f) => (
<TR key={f.id}>
<TD className="whitespace-nowrap">
{f.author_handle ? (
<>
<div className="font-medium">{f.author_name}</div>
<div className="text-xs text-muted-foreground">
@{f.author_handle}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">
{f.contact_email ?? "Anonymous"}
</div>
)}
</TD>
<TD className="max-w-md">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{f.category}
</div>
<div className="mt-0.5 whitespace-pre-line">{f.message}</div>
{f.reply_message ? (
<div className="mt-2 rounded-md bg-muted px-3 py-2 text-xs">
<span className="font-medium">Your reply: </span>
{f.reply_message}
</div>
) : null}
</TD>
<TD>
<StatusBadge f={f} />
</TD>
<TD className="whitespace-nowrap">{fmtDate(f.created_at)}</TD>
<TD className="text-right">
<Button size="sm" variant="outline" onClick={() => setReply(f)}>
{f.replied_at ? "Reply again" : "Reply"}
</Button>
</TD>
</TR>
))
)}
</tbody>
</Table>
<ReplyModal
feedback={reply}
onClose={() => setReply(null)}
onDone={() => {
setReply(null);
list.refresh();
}}
/>
</ListShell>
);
}
function StatusBadge({ f }: { f: AdminFeedback }) {
const label = f.replied_at ? "Replied" : f.resolved_at ? "Resolved" : "Open";
const tone = f.resolved_at || f.replied_at ? "text-primary" : "text-muted-foreground";
return <span className={cn("text-sm", tone)}>{label}</span>;
}
function ReplyModal({
feedback,
onClose,
onDone,
}: {
feedback: AdminFeedback | null;
onClose: () => void;
onDone: () => void;
}) {
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
async function send() {
if (!feedback || message.trim() === "") return;
setBusy(true);
try {
const { data } = (await api.replyFeedback(feedback.id, message.trim())) as {
data: { replied: boolean; emailed: boolean };
};
toast.success(
data?.emailed
? "Reply saved and emailed to the submitter."
: "Reply saved (no contact email on file to send to).",
);
setMessage("");
onDone();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Could not reply.");
} finally {
setBusy(false);
}
}
return (
<Modal open={!!feedback} onClose={onClose} title="Reply to feedback">
<div className="space-y-4">
{feedback ? (
<div className="rounded-md bg-muted px-3 py-2 text-sm">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{feedback.category}
</div>
<div className="mt-0.5 whitespace-pre-line">{feedback.message}</div>
</div>
) : null}
<label className="block space-y-1.5">
<span className="text-sm font-medium">Your reply</span>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={5}
maxLength={4000}
placeholder="Write your reply… (emailed to the submitter, marks this resolved)"
/>
</label>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={send} disabled={busy || message.trim() === ""}>
{busy ? "Sending…" : "Send reply"}
</Button>
</div>
</div>
</Modal>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Flag,
FolderClosed,
LayoutGrid,
LogOut,
MessageSquare,
Users,
} from "lucide-react";
import { api, type Me } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ConfirmProvider } from "@/components/confirm";
const NAV = [
{ href: "/users", label: "Users", icon: Users },
{ href: "/posts", label: "Posts", icon: LayoutGrid },
{ href: "/collections", label: "Collections", icon: FolderClosed },
{ href: "/feedback", label: "Feedback", icon: MessageSquare },
{ href: "/reports", label: "Reports", icon: Flag },
];
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const [me, setMe] = useState<Me | null>(null);
const [checked, setChecked] = useState(false);
// Admin gate: only an authenticated admin may see the dashboard. Anyone
// else is bounced to /login. The BFF independently enforces this on every
// /admin/* call, so this is UX, not the security boundary.
useEffect(() => {
let cancelled = false;
api
.me()
.then((u) => {
if (cancelled) return;
if (!u || u.role !== "admin") {
router.replace("/login");
return;
}
setMe(u);
setChecked(true);
})
.catch(() => {
if (!cancelled) router.replace("/login");
});
return () => {
cancelled = true;
};
}, [router]);
async function signOut() {
await api.logout().catch(() => {});
router.replace("/login");
}
if (!checked || !me) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
);
}
return (
<div className="flex min-h-screen">
<aside className="flex w-60 shrink-0 flex-col border-r border-border bg-card">
<div className="px-5 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-primary">
Prabhupāda Says
</p>
<p className="mt-0.5 text-sm font-semibold text-foreground">Admin</p>
</div>
<nav className="flex-1 space-y-1 px-3">
{NAV.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-4" />
{label}
</Link>
);
})}
</nav>
<div className="border-t border-border px-3 py-3">
<div className="px-3 pb-2 text-xs text-muted-foreground">
<p className="truncate font-medium text-foreground">{me.name}</p>
<p className="truncate">@{me.handle}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={signOut}
className="w-full justify-start text-muted-foreground"
>
<LogOut className="size-4" />
Sign out
</Button>
</div>
</aside>
<main className="flex-1 overflow-x-hidden px-6 py-6 lg:px-10">
<div className="mx-auto max-w-6xl">
<ConfirmProvider>{children}</ConfirmProvider>
</div>
</main>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// The dashboard root just lands on Users.
export default function DashboardIndex() {
redirect("/users");
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { api, AdminError, type AdminPost } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { useConfirm } from "@/components/confirm";
import { fmtDate } from "@/lib/utils";
export default function PostsPage() {
const [q, setQ] = useState("");
const fetcher = useCallback(
(limit: number, offset: number) => api.posts(q, limit, offset),
[q],
);
const list = usePagedList<AdminPost>(fetcher, [q]);
const confirm = useConfirm();
async function run(action: Promise<unknown>, successMsg: string) {
try {
await action;
toast.success(successMsg);
list.refresh();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Action failed.");
}
}
async function onDelete(p: AdminPost) {
if (
await confirm({
title: "Delete this post?",
message: "It will be removed from the app. You can restore it here later.",
confirmLabel: "Delete post",
destructive: true,
})
) {
run(api.deletePost(p.id), "Post deleted.");
}
}
return (
<ListShell
title="Posts"
total={list.total}
searchPlaceholder="Search caption, source, or author…"
onQueryChange={setQ}
list={list}
>
<Table>
<THead>
<TR>
<TH>Post</TH>
<TH>Author</TH>
<TH>Engagement</TH>
<TH>Created</TH>
<TH className="text-right">Actions</TH>
</TR>
</THead>
<tbody>
{list.items.length === 0 && !list.loading ? (
<EmptyRow cols={5} label="No posts found." />
) : (
list.items.map((p) => (
<TR key={p.id} className={p.deleted_at ? "opacity-60" : ""}>
<TD className="max-w-md">
<div className="line-clamp-2">{p.caption || "—"}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{p.source_title}
{p.deleted_at ? (
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive">
Deleted
</span>
) : p.is_hidden ? (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
Hidden
</span>
) : null}
</div>
</TD>
<TD className="whitespace-nowrap">
<div className="font-medium">{p.author_name}</div>
<div className="text-xs text-muted-foreground">
@{p.author_handle}
</div>
</TD>
<TD className="whitespace-nowrap text-xs text-muted-foreground">
{p.likes_count} likes · {p.comments_count} comments
</TD>
<TD className="whitespace-nowrap">{fmtDate(p.created_at)}</TD>
<TD className="text-right">
<div className="flex flex-wrap justify-end gap-2">
{p.deleted_at ? (
<Button
size="sm"
variant="outline"
onClick={() => run(api.restorePost(p.id), "Post restored.")}
>
Restore
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
run(
p.is_hidden
? api.unhidePost(p.id)
: api.hidePost(p.id),
p.is_hidden ? "Post unhidden." : "Post hidden.",
)
}
>
{p.is_hidden ? "Unhide" : "Hide"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(p)}
>
Delete
</Button>
</>
)}
</div>
</TD>
</TR>
))
)}
</tbody>
</Table>
</ListShell>
);
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { api, AdminError, type AdminReport, type ReportType } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { useConfirm } from "@/components/confirm";
import { cn, fmtDate } from "@/lib/utils";
const TYPES: { key: ReportType; label: string }[] = [
{ key: "post", label: "Posts" },
{ key: "comment", label: "Comments" },
{ key: "user", label: "Accounts" },
];
export default function ReportsPage() {
const [type, setType] = useState<ReportType>("post");
const [q, setQ] = useState("");
const fetcher = useCallback(
(limit: number, offset: number) => api.reports(type, q, limit, offset),
[type, q],
);
const list = usePagedList<AdminReport>(fetcher, [type, q]);
const confirm = useConfirm();
async function run(action: Promise<unknown>, successMsg: string) {
try {
await action;
toast.success(successMsg);
list.refresh();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Action failed.");
}
}
// Per-report moderation buttons. Destructive actions confirm first.
function actionsFor(r: AdminReport) {
if (r.type === "post") {
return (
<>
<Button size="sm" variant="outline" onClick={() => run(api.hidePost(r.target_id), "Post hidden.")}>
Hide
</Button>
<Button
size="sm"
variant="destructive"
onClick={async () => {
if (
await confirm({
title: "Delete this post?",
message:
"It will be removed from the app. You can restore it later from the Posts tab.",
confirmLabel: "Delete post",
destructive: true,
})
) {
run(api.deletePost(r.target_id), "Post deleted.");
}
}}
>
Delete
</Button>
</>
);
}
if (r.type === "comment") {
return (
<Button
size="sm"
variant="destructive"
onClick={async () => {
if (
await confirm({
title: "Delete this comment?",
message: "It will be removed from the thread (restorable).",
confirmLabel: "Delete comment",
destructive: true,
})
) {
run(api.deleteComment(r.target_id), "Comment deleted.");
}
}}
>
Delete
</Button>
);
}
return (
<Button
size="sm"
variant="destructive"
onClick={async () => {
if (
await confirm({
title: "Suspend this account?",
message: "They'll be signed out and unable to log in until you unban them.",
confirmLabel: "Suspend account",
destructive: true,
})
) {
run(api.banUser(r.target_id), "Account suspended.");
}
}}
>
Ban
</Button>
);
}
const targetLabel =
type === "user" ? "Reported account" : type === "comment" ? "Comment" : "Post";
return (
<ListShell
title="Reports"
total={list.total}
searchPlaceholder="Search content or reporter…"
onQueryChange={setQ}
list={list}
toolbar={
<div className="flex gap-1 rounded-lg border border-border bg-card p-1">
{TYPES.map((t) => (
<button
key={t.key}
onClick={() => setType(t.key)}
className={cn(
"rounded-md px-3 py-1.5 text-sm transition-colors",
type === t.key
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
}
>
<Table>
<THead>
<TR>
<TH>{targetLabel}</TH>
<TH>Reason</TH>
<TH>Reporter</TH>
<TH>Reported</TH>
<TH className="text-right">Actions</TH>
</TR>
</THead>
<tbody>
{list.items.length === 0 && !list.loading ? (
<EmptyRow cols={5} label="No reports." />
) : (
list.items.map((r) => (
<TR key={r.id} className={r.resolved_at ? "opacity-60" : ""}>
<TD className="max-w-xs">
{type === "user" ? (
<span className="font-medium">
{r.target_text ? `@${r.target_text}` : "—"}
</span>
) : (
<>
<div className="line-clamp-2">{r.target_text || "—"}</div>
{r.target_author_handle ? (
<div className="mt-0.5 text-xs text-muted-foreground">
by @{r.target_author_handle}
</div>
) : null}
</>
)}
</TD>
<TD className="max-w-xs">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{r.category}
</div>
{r.reason ? (
<div className="mt-0.5 line-clamp-2">{r.reason}</div>
) : null}
</TD>
<TD className="whitespace-nowrap text-xs text-muted-foreground">
{r.reporter_handle ? `@${r.reporter_handle}` : "—"}
</TD>
<TD className="whitespace-nowrap">{fmtDate(r.created_at)}</TD>
<TD className="text-right">
<div className="flex flex-wrap justify-end gap-2">
{actionsFor(r)}
{r.resolved_at ? (
<span className="self-center text-xs text-muted-foreground">
Resolved
</span>
) : (
<Button
size="sm"
variant="ghost"
onClick={() =>
run(api.resolveReport(r.type, r.id), "Report resolved.")
}
>
Resolve
</Button>
)}
</div>
</TD>
</TR>
))
)}
</tbody>
</Table>
</ListShell>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { api, AdminError, type AdminUser } from "@/lib/api";
import { usePagedList } from "@/lib/use-paged-list";
import { ListShell, EmptyRow } from "@/components/list-shell";
import { Table, THead, TR, TH, TD } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { useConfirm } from "@/components/confirm";
import { Input, Textarea } from "@/components/ui/input";
import { fmtDate } from "@/lib/utils";
export default function UsersPage() {
const [q, setQ] = useState("");
const fetcher = useCallback(
(limit: number, offset: number) => api.users(q, limit, offset),
[q],
);
const list = usePagedList<AdminUser>(fetcher, [q]);
const confirm = useConfirm();
const [notify, setNotify] = useState<AdminUser | null>(null);
async function toggleBan(u: AdminUser) {
const banning = !u.banned_at;
if (
banning &&
!(await confirm({
title: `Suspend @${u.handle}?`,
message: "They'll be signed out and unable to log in until you unban them.",
confirmLabel: "Suspend account",
destructive: true,
}))
) {
return;
}
try {
await (banning ? api.banUser(u.id) : api.unbanUser(u.id));
toast.success(banning ? "Account suspended." : "Account restored.");
list.refresh();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Action failed.");
}
}
return (
<ListShell
title="Users"
total={list.total}
searchPlaceholder="Search name, handle, or email…"
onQueryChange={setQ}
list={list}
>
<Table>
<THead>
<TR>
<TH>User</TH>
<TH>Email</TH>
<TH>Role</TH>
<TH>Joined</TH>
<TH className="text-right">Actions</TH>
</TR>
</THead>
<tbody>
{list.items.length === 0 && !list.loading ? (
<EmptyRow cols={5} label="No users found." />
) : (
list.items.map((u) => (
<TR key={u.id}>
<TD>
<div className="flex items-center gap-2">
<span className="font-medium">{u.name}</span>
{u.banned_at ? (
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-destructive">
Suspended
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">@{u.handle}</div>
</TD>
<TD>
{u.email ?? <span className="text-muted-foreground">—</span>}
</TD>
<TD className="capitalize">{u.role}</TD>
<TD className="whitespace-nowrap">{fmtDate(u.created_at)}</TD>
<TD className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setNotify(u)}>
Notify
</Button>
{u.role !== "admin" ? (
<Button
size="sm"
variant={u.banned_at ? "outline" : "destructive"}
onClick={() => toggleBan(u)}
>
{u.banned_at ? "Unban" : "Ban"}
</Button>
) : null}
</div>
</TD>
</TR>
))
)}
</tbody>
</Table>
<NotifyModal user={notify} onClose={() => setNotify(null)} />
</ListShell>
);
}
function NotifyModal({
user,
onClose,
}: {
user: AdminUser | null;
onClose: () => void;
}) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [busy, setBusy] = useState(false);
async function send() {
if (!user || body.trim() === "") return;
setBusy(true);
try {
await api.notifyUser(user.id, title.trim(), body.trim());
toast.success(`Notification sent to @${user.handle}`);
setTitle("");
setBody("");
onClose();
} catch (err) {
toast.error(err instanceof AdminError ? err.message : "Could not send.");
} finally {
setBusy(false);
}
}
return (
<Modal
open={!!user}
onClose={onClose}
title={user ? `Notify @${user.handle}` : "Notify"}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Sends an in-app notification, plus web push and email where available.
</p>
<label className="block space-y-1.5">
<span className="text-sm font-medium">Title (optional)</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
placeholder="A note from the team"
/>
</label>
<label className="block space-y-1.5">
<span className="text-sm font-medium">Message</span>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={5}
maxLength={4000}
placeholder="Write your message…"
/>
</label>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={send} disabled={busy || body.trim() === ""}>
{busy ? "Sending…" : "Send notification"}
</Button>
</div>
</div>
</Modal>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api, AdminError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AuthShell, Field } from "@/app/login/page";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await api.forgotPassword(email.trim());
// The BFF always responds 200 (it doesn't reveal whether an account
// exists), so we show the same confirmation regardless.
setSent(true);
} catch (err) {
setError(err instanceof AdminError ? err.message : "Something went wrong.");
} finally {
setBusy(false);
}
}
return (
<AuthShell title="Reset your password">
{sent ? (
<div className="space-y-4 text-sm text-muted-foreground">
<p>
If an account exists for <strong>{email}</strong>, we&apos;ve sent a
password-reset link. Check your inbox.
</p>
<Link href="/login" className="text-primary hover:underline">
Back to sign in
</Link>
</div>
) : (
<form onSubmit={onSubmit} className="space-y-4">
<Field label="Email">
<Input
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={busy} className="w-full">
{busy ? "Sending…" : "Send reset link"}
</Button>
<p className="text-center text-sm text-muted-foreground">
<Link href="/login" className="hover:text-foreground">
Back to sign in
</Link>
</p>
</form>
)}
</AuthShell>
);
}
+63
View File
@@ -0,0 +1,63 @@
@import "tailwindcss";
/*
* Admin theme — low-chroma navy/slate surfaces with a peacock-green accent,
* matching the Prabhupāda Says palette direction (hue lives in accents only).
*/
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: #f6f8f8;
--foreground: #14202a;
--card: #ffffff;
--card-foreground: #14202a;
--popover: #ffffff;
--popover-foreground: #14202a;
--primary: #0f7b6c;
--primary-foreground: #ffffff;
--muted: #eef1f2;
--muted-foreground: #5a6b73;
--accent: #e7f1ef;
--accent-foreground: #0f7b6c;
--destructive: #c0392b;
--destructive-foreground: #ffffff;
--border: #e1e6e7;
--input: #e1e6e7;
--ring: #0f7b6c;
}
* {
border-color: var(--border);
}
body {
background: var(--background);
color: var(--foreground);
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
+25
View File
@@ -0,0 +1,25 @@
import "./globals.css";
import type { Metadata } from "next";
import { Toaster } from "sonner";
export const metadata: Metadata = {
title: "Prabhupāda Says — Admin",
description: "Administration panel for Prabhupāda Says.",
// The admin panel must never be indexed.
robots: { index: false, follow: false },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Toaster richColors position="top-center" />
</body>
</html>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { api, AdminError, type Me } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [otp, setOtp] = useState("");
const [stage, setStage] = useState<"credentials" | "otp">("credentials");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Once authenticated, only admins may proceed. Non-admins are signed back
// out so an ordinary account can't sit in an authenticated-but-forbidden
// state against the admin origin.
async function gateAdmin(user: Me) {
if (user.role === "admin") {
router.replace("/users");
return;
}
await api.logout().catch(() => {});
setError("This account doesn't have admin access.");
setStage("credentials");
}
async function onCredentials(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const res = await api.login(email.trim(), password);
if (res.kind === "otp_required") {
setStage("otp");
toast("We emailed you a verification code.");
} else {
await gateAdmin(res.user);
}
} catch (err) {
setError(err instanceof AdminError ? err.message : "Could not sign in.");
} finally {
setBusy(false);
}
}
async function onOtp(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const user = await api.verifyLoginOtp(email.trim(), password, otp.trim());
await gateAdmin(user);
} catch (err) {
setError(err instanceof AdminError ? err.message : "Invalid code.");
} finally {
setBusy(false);
}
}
return (
<AuthShell title="Admin sign in">
{stage === "credentials" ? (
<form onSubmit={onCredentials} className="space-y-4">
<Field label="Email">
<Input
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field label="Password">
<Input
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={busy} className="w-full">
{busy ? "Signing in…" : "Sign in"}
</Button>
<p className="text-center text-sm text-muted-foreground">
<Link href="/forgot-password" className="hover:text-foreground">
Forgot your password?
</Link>
</p>
</form>
) : (
<form onSubmit={onOtp} className="space-y-4">
<p className="text-sm text-muted-foreground">
Enter the verification code we emailed to {email}.
</p>
<Field label="Verification code">
<Input
inputMode="numeric"
autoFocus
required
value={otp}
onChange={(e) => setOtp(e.target.value)}
/>
</Field>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={busy} className="w-full">
{busy ? "Verifying…" : "Verify & sign in"}
</Button>
<button
type="button"
onClick={() => {
setStage("credentials");
setOtp("");
setError(null);
}}
className="w-full text-center text-sm text-muted-foreground hover:text-foreground"
>
Back
</button>
</form>
)}
</AuthShell>
);
}
export function AuthShell({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="mb-6 text-center">
<p className="text-xs uppercase tracking-[0.2em] text-primary">
Prabhupāda Says
</p>
<h1 className="mt-1 text-xl font-semibold text-foreground">{title}</h1>
</div>
<div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
{children}
</div>
</div>
</main>
);
}
export function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="block space-y-1.5">
<span className="text-sm font-medium text-foreground">{label}</span>
{children}
</label>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { api, AdminError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AuthShell, Field } from "@/app/login/page";
export default function ResetPasswordPage() {
return (
<Suspense fallback={<AuthShell title="Reset your password">…</AuthShell>}>
<ResetInner />
</Suspense>
);
}
function ResetInner() {
const router = useRouter();
const token = useSearchParams().get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setError("Passwords don't match.");
return;
}
setBusy(true);
try {
await api.resetPassword(token, password);
toast.success("Password updated — please sign in.");
router.replace("/login");
} catch (err) {
setError(
err instanceof AdminError ? err.message : "Could not reset password.",
);
} finally {
setBusy(false);
}
}
if (!token) {
return (
<AuthShell title="Reset your password">
<div className="space-y-4 text-sm text-muted-foreground">
<p>This reset link is missing its token. Request a new one.</p>
<Link href="/forgot-password" className="text-primary hover:underline">
Request a reset link
</Link>
</div>
</AuthShell>
);
}
return (
<AuthShell title="Choose a new password">
<form onSubmit={onSubmit} className="space-y-4">
<Field label="New password">
<Input
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<Field label="Confirm password">
<Input
type="password"
autoComplete="new-password"
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</Field>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" disabled={busy} className="w-full">
{busy ? "Saving…" : "Update password"}
</Button>
</form>
</AuthShell>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import { Modal } from "@/components/ui/modal";
import { Button } from "@/components/ui/button";
type ConfirmOpts = {
title: string;
message?: string;
confirmLabel?: string;
destructive?: boolean;
};
type ConfirmFn = (opts: ConfirmOpts) => Promise<boolean>;
const ConfirmContext = createContext<ConfirmFn | null>(null);
/** Imperative confirm — `if (await confirm({...})) { … }`. Renders a styled
* modal instead of the browser's window.confirm. Provided by ConfirmProvider. */
export function useConfirm(): ConfirmFn {
const fn = useContext(ConfirmContext);
if (!fn) throw new Error("useConfirm must be used within a ConfirmProvider");
return fn;
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<{
opts: ConfirmOpts;
resolve: (v: boolean) => void;
} | null>(null);
const confirm = useCallback<ConfirmFn>(
(opts) => new Promise<boolean>((resolve) => setPending({ opts, resolve })),
[],
);
function settle(value: boolean) {
pending?.resolve(value);
setPending(null);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
<Modal
open={!!pending}
onClose={() => settle(false)}
title={pending?.opts.title ?? ""}
>
{pending ? (
<div className="space-y-5">
{pending.opts.message ? (
<p className="text-sm text-muted-foreground">
{pending.opts.message}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => settle(false)}>
Cancel
</Button>
<Button
variant={pending.opts.destructive ? "destructive" : "primary"}
onClick={() => settle(true)}
>
{pending.opts.confirmLabel ?? "Confirm"}
</Button>
</div>
</div>
) : null}
</Modal>
</ConfirmContext.Provider>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import * as React from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
type ListLike = {
loading: boolean;
error: string | null;
total: number;
page: number;
totalPages: number;
setPage: (p: number) => void;
};
/**
* Shared chrome for every admin list page: a title with a live total, an
* optional debounced search box, an optional toolbar (filters/tabs), the
* table (children), and a pagination footer. Keeps the resource pages tiny.
*/
export function ListShell({
title,
total,
searchPlaceholder,
onQueryChange,
toolbar,
list,
children,
}: {
title: string;
total: number;
searchPlaceholder?: string;
onQueryChange?: (q: string) => void;
toolbar?: React.ReactNode;
list: ListLike;
children: React.ReactNode;
}) {
const [raw, setRaw] = React.useState("");
// Debounce search input → parent query (300ms).
React.useEffect(() => {
if (!onQueryChange) return;
const t = setTimeout(() => onQueryChange(raw.trim()), 300);
return () => clearTimeout(t);
}, [raw, onQueryChange]);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-foreground">
{title}
<span className="ml-2 text-sm font-normal text-muted-foreground">
{total.toLocaleString()}
</span>
</h1>
{toolbar}
</div>
{onQueryChange ? (
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={raw}
onChange={(e) => setRaw(e.target.value)}
placeholder={searchPlaceholder ?? "Search…"}
className="pl-9"
/>
</div>
) : null}
{list.error ? (
<p className="text-sm text-destructive">{list.error}</p>
) : null}
<div className={list.loading ? "opacity-60 transition-opacity" : ""}>
{children}
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Page {list.page} of {list.totalPages}
{list.loading ? " · loading…" : ""}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={list.page <= 1 || list.loading}
onClick={() => list.setPage(list.page - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={list.page >= list.totalPages || list.loading}
onClick={() => list.setPage(list.page + 1)}
>
Next
</Button>
</div>
</div>
</div>
);
}
/** Centered empty-state row spanning the table. */
export function EmptyRow({ cols, label }: { cols: number; label: string }) {
return (
<tr>
<td
colSpan={cols}
className="px-4 py-10 text-center text-sm text-muted-foreground"
>
{label}
</td>
</tr>
);
}
+39
View File
@@ -0,0 +1,39 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type Variant = "primary" | "outline" | "ghost" | "destructive";
type Size = "sm" | "md";
const variants: Record<Variant, string> = {
primary: "bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-border bg-card text-foreground hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
};
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
};
export function Button({
variant = "primary",
size = "md",
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
size?: Size;
}) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
}
+38
View File
@@ -0,0 +1,38 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(function Input({ className, ...props }, ref) {
return (
<input
ref={ref}
className={cn(
"h-10 w-full rounded-lg border border-input bg-card px-3 text-sm text-foreground",
"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:opacity-50",
className,
)}
{...props}
/>
);
});
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(function Textarea({ className, ...props }, ref) {
return (
<textarea
ref={ref}
className={cn(
"w-full rounded-lg border border-input bg-card px-3 py-2 text-sm text-foreground",
"placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:opacity-50",
className,
)}
{...props}
/>
);
});
+55
View File
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
/** A minimal centered modal dialog. Closes on overlay click and Escape. */
export function Modal({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<Button
variant="ghost"
size="sm"
onClick={onClose}
aria-label="Close"
className="size-8 px-0"
>
<X className="size-4" />
</Button>
</div>
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Table({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-x-auto rounded-xl border border-border bg-card">
<table className="w-full border-collapse text-sm">{children}</table>
</div>
);
}
export function THead({ children }: { children: React.ReactNode }) {
return (
<thead className="border-b border-border bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
{children}
</thead>
);
}
export function TH({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return <th className={cn("px-4 py-3 font-medium", className)}>{children}</th>;
}
export function TR({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<tr className={cn("border-b border-border last:border-0 align-top", className)}>
{children}
</tr>
);
}
export function TD({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return <td className={cn("px-4 py-3 text-foreground", className)}>{children}</td>;
}
+14
View File
@@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({ baseDirectory: __dirname });
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+259
View File
@@ -0,0 +1,259 @@
/**
* Admin API client. Talks to the same Go BFF as the main app, reusing its
* cookie session: auth uses the shared /auth/* endpoints, and every admin
* action lives under /admin/* (gated server-side by RequireAdmin). All
* requests send credentials so the httpOnly session cookie travels.
*
* For this to work cross-origin, the BFF must list this app's origin in CORS
* via the ADMIN_URL env var (see .env.example).
*/
const BFF = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class AdminError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "AdminError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
meta?: { total?: number; page?: number; limit?: number; total_pages?: number };
};
async function call<T>(
path: string,
init?: RequestInit,
): Promise<{ data: T; meta?: Envelope<T>["meta"] }> {
const res = await fetch(`${BFF}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
let body: Envelope<T>;
try {
body = (await res.json()) as Envelope<T>;
} catch {
throw new AdminError(res.status, res.statusText || "Request failed");
}
if (!res.ok || body.success === false) {
throw new AdminError(
res.status,
body.error?.message || res.statusText || "Request failed",
);
}
return { data: body.data as T, meta: body.meta };
}
export type Paged<T> = { items: T[]; total: number };
async function listed<T>(path: string): Promise<Paged<T>> {
const { data, meta } = await call<T[]>(path);
const items = data ?? [];
return { items, total: meta?.total ?? items.length };
}
function qs(params: Record<string, string | number | undefined>): string {
const u = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") u.set(k, String(v));
}
const s = u.toString();
return s ? `?${s}` : "";
}
// ─── Types (mirror the BFF admin DTOs) ──────────────────────────────────────
export type Me = {
id: string;
email: string | null;
handle: string;
name: string;
role: string;
};
export type AdminUser = {
id: string;
email: string | null;
handle: string;
name: string;
avatar_url: string | null;
role: string;
plan: string;
email_verified_at: string | null;
banned_at: string | null;
created_at: string;
};
export type AdminPost = {
id: string;
user_id: string;
caption: string;
source_title: string;
source_url: string;
is_hidden: boolean;
deleted_at: string | null;
created_at: string;
author_name: string;
author_handle: string;
likes_count: number;
comments_count: number;
};
export type AdminCollection = {
id: string;
user_id: string;
title: string;
description: string | null;
is_public: boolean;
item_count: number;
created_at: string;
owner_name: string;
owner_handle: string;
};
export type AdminFeedback = {
id: string;
category: string;
message: string;
contact_email: string | null;
created_at: string;
resolved_at: string | null;
replied_at: string | null;
reply_message: string | null;
author_handle: string | null;
author_name: string | null;
author_email: string | null;
};
export type ReportType = "post" | "comment" | "user";
export type AdminReport = {
id: string;
type: ReportType;
category: string;
reason: string;
created_at: string;
resolved_at: string | null;
reporter_handle: string | null;
reporter_name: string | null;
target_id: string;
target_text: string | null;
target_author_handle: string | null;
};
// Login can complete immediately (session) or require an emailed OTP (the
// BFF's 30-day-stale gate). The admin login form handles both.
export type LoginResult =
| { kind: "session"; user: Me }
| { kind: "otp_required" };
// ─── API surface ────────────────────────────────────────────────────────────
export const api = {
// Auth (shared endpoints)
async login(email: string, password: string): Promise<LoginResult> {
const { data } = await call<LoginResult>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
return data;
},
async verifyLoginOtp(
email: string,
password: string,
otp: string,
): Promise<Me> {
const { data } = await call<{ user: Me }>("/auth/verify-login-otp", {
method: "POST",
body: JSON.stringify({ email, password, otp }),
});
return data.user;
},
async me(): Promise<Me | null> {
try {
const { data } = await call<Me>("/auth/me");
return data;
} catch (e) {
if (e instanceof AdminError && e.status === 401) return null;
throw e;
}
},
async logout(): Promise<void> {
await call("/auth/logout", { method: "POST" });
},
async forgotPassword(email: string): Promise<void> {
await call("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
},
async resetPassword(token: string, password: string): Promise<void> {
await call("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
},
// Admin resources
users(q: string, limit: number, offset: number) {
return listed<AdminUser>(`/admin/users${qs({ q, limit, offset })}`);
},
notifyUser(id: string, title: string, body: string) {
return call(`/admin/users/${id}/notify`, {
method: "POST",
body: JSON.stringify({ title, body }),
});
},
banUser(id: string) {
return call(`/admin/users/${id}/ban`, { method: "POST" });
},
unbanUser(id: string) {
return call(`/admin/users/${id}/ban`, { method: "DELETE" });
},
posts(q: string, limit: number, offset: number) {
return listed<AdminPost>(`/admin/posts${qs({ q, limit, offset })}`);
},
hidePost(id: string) {
return call(`/admin/posts/${id}/hide`, { method: "POST" });
},
unhidePost(id: string) {
return call(`/admin/posts/${id}/hide`, { method: "DELETE" });
},
deletePost(id: string) {
return call(`/admin/posts/${id}`, { method: "DELETE" });
},
restorePost(id: string) {
return call(`/admin/posts/${id}/restore`, { method: "POST" });
},
deleteComment(id: string) {
return call(`/admin/comments/${id}`, { method: "DELETE" });
},
restoreComment(id: string) {
return call(`/admin/comments/${id}/restore`, { method: "POST" });
},
collections(q: string, limit: number, offset: number) {
return listed<AdminCollection>(`/admin/collections${qs({ q, limit, offset })}`);
},
feedback(status: string, limit: number, offset: number) {
return listed<AdminFeedback>(`/admin/feedback${qs({ status, limit, offset })}`);
},
replyFeedback(id: string, message: string) {
return call(`/admin/feedback/${id}/reply`, {
method: "POST",
body: JSON.stringify({ message }),
});
},
reports(type: ReportType, q: string, limit: number, offset: number) {
return listed<AdminReport>(`/admin/reports${qs({ type, q, limit, offset })}`);
},
resolveReport(type: ReportType, id: string) {
return call(`/admin/reports/${type}/${id}/resolve`, { method: "POST" });
},
};
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Paged } from "@/lib/api";
const PAGE_SIZE = 25;
export type PagedListState<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
loading: boolean;
error: string | null;
setPage: (p: number) => void;
refresh: () => void;
};
/**
* Shared search + offset-pagination logic for the admin list pages. `fetcher`
* receives (limit, offset) plus whatever extra deps the caller closes over
* (search query, status filter, report type); pass those in `deps` so a change
* resets to page 1 and refetches. A request token guards against out-of-order
* responses when the user types/pages quickly.
*/
export function usePagedList<T>(
fetcher: (limit: number, offset: number) => Promise<Paged<T>>,
deps: unknown[],
pageSize = PAGE_SIZE,
): PagedListState<T> {
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const token = useRef(0);
// Reset to page 1 whenever the external filters change.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setPage(1), deps);
const load = useCallback(() => {
const my = ++token.current;
setLoading(true);
setError(null);
fetcher(pageSize, (page - 1) * pageSize)
.then((res) => {
if (my !== token.current) return;
setItems(res.items);
setTotal(res.total);
})
.catch((e) => {
if (my !== token.current) return;
setError(e instanceof Error ? e.message : "Could not load.");
})
.finally(() => {
if (my === token.current) setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, ...deps]);
useEffect(() => load(), [load]);
return {
items,
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
loading,
error,
setPage,
refresh: load,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Format an ISO timestamp as a short, locale-stable date-time for tables. */
export function fmtDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// The admin panel renders avatars/post images served from R2/CDN. Allow any
// https host since those URLs are admin-trusted content references.
images: {
remotePatterns: [{ protocol: "https", hostname: "**" }],
},
};
export default nextConfig;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "spsays-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+3297
View File
File diff suppressed because it is too large Load Diff