"use client"; import { useCallback, useState } from "react"; import { Mail } from "lucide-react"; import { toast } from "sonner"; import { api, AdminError, type AdminUser, type UserLimitsInput, } from "@/lib/api"; import { GoogleIcon, FacebookIcon } from "@/components/ui/provider-icons"; 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 { RowMenu, type MenuItem } from "@/components/ui/menu"; import { Avatar } from "@/components/ui/avatar"; import { useConfirm } from "@/components/confirm"; import { Input } from "@/components/ui/input"; import { RichTextarea } from "@/components/ui/rich-textarea"; import { fmtDate } from "@/lib/utils"; import { DiscipleLinkModal } from "./disciple-link-modal"; export default function UsersPage() { const [q, setQ] = useState(""); const fetcher = useCallback( (limit: number, offset: number) => api.users(q, limit, offset), [q], ); const list = usePagedList(fetcher, [q]); const confirm = useConfirm(); const [notify, setNotify] = useState(null); const [limitsUser, setLimitsUser] = useState(null); const [discipleUser, setDiscipleUser] = useState(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."); } } async function removeDiscipleLink(u: AdminUser) { if ( !(await confirm({ title: `Remove disciple link from @${u.handle}?`, message: "Their profile badge and the highlight in the disciples directory will be removed. You can re-link them at any time.", confirmLabel: "Remove link", destructive: true, })) ) { return; } try { await api.unlinkDisciple(u.id); toast.success("Disciple link removed."); list.refresh(); } catch (err) { toast.error(err instanceof AdminError ? err.message : "Action failed."); } } function menuItems(u: AdminUser): MenuItem[] { const items: MenuItem[] = [ { label: "Send notification", onClick: () => setNotify(u) }, { label: "Edit limits", onClick: () => setLimitsUser(u) }, { label: u.disciple_id ? "Change disciple link" : "Link direct disciple", onClick: () => setDiscipleUser(u), }, ]; if (u.disciple_id) { items.push({ label: "Remove disciple link", onClick: () => removeDiscipleLink(u), }); } // No ban option for fellow admins (avoid locking each other out). if (u.role !== "admin") { items.push({ label: u.banned_at ? "Unban account" : "Ban account", onClick: () => toggleBan(u), destructive: !u.banned_at, }); } return items; } return ( {list.items.length === 0 && !list.loading ? ( ) : ( list.items.map((u) => ( )) )}
User Email Posts Joined Actions
{u.name} {u.banned_at ? ( Suspended ) : null}
@{u.handle}
{u.disciple_id ? (
{u.disciple_name ?? "Direct disciple"}
) : null}
{u.email ?? ( — )}
{u.post_count} {fmtDate(u.created_at)}
setNotify(null)} /> setLimitsUser(null)} onSaved={list.refresh} /> setDiscipleUser(null)} onSaved={list.refresh} />
); } function AuthIcons({ user }: { user: AdminUser }) { const hasGoogle = user.providers.includes("google"); const hasFacebook = user.providers.includes("facebook"); if (!user.has_password && !hasGoogle && !hasFacebook) { return —; } return (
{user.has_password ? ( // Tooltip shows the email on hover; clicking copies it to the clipboard. ) : null} {hasGoogle ? : null} {hasFacebook ? : null}
); } // An effective limit of -1 means "unlimited" (premium default) — shown as an // empty field. Saving an empty field sends null, which resets that limit to // the plan default on the server. function limitToField(v: number | undefined): string { return v === undefined || v < 0 ? "" : String(v); } function LimitsModal({ user, onClose, onSaved, }: { user: AdminUser | null; onClose: () => void; onSaved: () => void; }) { const [bookmark, setBookmark] = useState(""); const [pub, setPub] = useState(""); const [priv, setPriv] = useState(""); const [busy, setBusy] = useState(false); // Re-seed the fields whenever a different user's modal opens. const [seededFor, setSeededFor] = useState(null); if (user && seededFor !== user.id) { setSeededFor(user.id); setBookmark(limitToField(user.bookmark_limit)); setPub(limitToField(user.public_collection_limit)); setPriv(limitToField(user.private_collection_limit)); } if (!user && seededFor !== null) setSeededFor(null); // "" → null (reset to plan default); a non-negative integer → that cap. function parseField(raw: string): number | null | undefined { const t = raw.trim(); if (t === "") return null; const n = Number(t); if (!Number.isInteger(n) || n < 0) return undefined; // invalid return n; } async function save() { if (!user) return; const b = parseField(bookmark); const p = parseField(pub); const v = parseField(priv); if (b === undefined || p === undefined || v === undefined) { toast.error("Limits must be whole numbers (0 or more)."); return; } const limits: UserLimitsInput = { bookmark_limit: b, public_collection_limit: p, private_collection_limit: v, }; setBusy(true); try { await api.setUserLimits(user.id, limits); toast.success(`Limits updated for @${user.handle}`); onSaved(); onClose(); } catch (err) { toast.error(err instanceof AdminError ? err.message : "Could not update."); } finally { setBusy(false); } } return (

How many of each this user can save. Leave a field blank to use their plan default.

); } function LimitField({ label, value, onChange, }: { label: string; value: string; onChange: (v: string) => void; }) { return ( ); } 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 (

Sends an in-app notification, plus web push and email where available.

); }