commit a70c50254ea0ff0f9aa2d449839f0bb77619cb72 Author: gosangam Date: Tue Jun 16 14:50:09 2026 +0530 feat: admin dashboard diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92578d2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..978fdc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/node_modules +/.next +/out +/build +*.tsbuildinfo +next-env.d.ts +.env +.env*.local +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bbea5d --- /dev/null +++ b/README.md @@ -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` | diff --git a/app/(dashboard)/collections/page.tsx b/app/(dashboard)/collections/page.tsx new file mode 100644 index 0000000..ca4c3ca --- /dev/null +++ b/app/(dashboard)/collections/page.tsx @@ -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(fetcher, [q]); + + return ( + + + + + + + + + + + + + {list.items.length === 0 && !list.loading ? ( + + ) : ( + list.items.map((c) => ( + + + + + + + + )) + )} + +
CollectionOwnerItemsVisibilityCreated
+
{c.title}
+ {c.description ? ( +
+ {c.description} +
+ ) : null} +
+
{c.owner_name}
+
+ @{c.owner_handle} +
+
{c.item_count} + + {c.is_public ? "Public" : "Private"} + + {fmtDate(c.created_at)}
+
+ ); +} diff --git a/app/(dashboard)/feedback/page.tsx b/app/(dashboard)/feedback/page.tsx new file mode 100644 index 0000000..3f8a33a --- /dev/null +++ b/app/(dashboard)/feedback/page.tsx @@ -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("all"); + const fetcher = useCallback( + (limit: number, offset: number) => api.feedback(status, limit, offset), + [status], + ); + const list = usePagedList(fetcher, [status]); + const [reply, setReply] = useState(null); + + return ( + + {STATUSES.map((s) => ( + + ))} + + } + > + + + + + + + + + + + + {list.items.length === 0 && !list.loading ? ( + + ) : ( + list.items.map((f) => ( + + + + + + + + )) + )} + +
FromMessageStatusReceivedActions
+ {f.author_handle ? ( + <> +
{f.author_name}
+
+ @{f.author_handle} +
+ + ) : ( +
+ {f.contact_email ?? "Anonymous"} +
+ )} +
+
+ {f.category} +
+
{f.message}
+ {f.reply_message ? ( +
+ Your reply: + {f.reply_message} +
+ ) : null} +
+ + {fmtDate(f.created_at)} + +
+ + setReply(null)} + onDone={() => { + setReply(null); + list.refresh(); + }} + /> +
+ ); +} + +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 {label}; +} + +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 ( + +
+ {feedback ? ( +
+
+ {feedback.category} +
+
{feedback.message}
+
+ ) : null} +