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
+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>
);
}