Files
shadow-gunbuilder-ai-proto/app/admin/beta-invites/page.tsx
T

441 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Button, Field, Input } from "@/components/ui/form";
type AdminBetaRequestDto = {
id: number;
uuid: string;
email: string;
displayName?: string | null;
invited: boolean;
verified: boolean;
active: boolean;
createdAt?: string;
updatedAt?: string;
};
type PageResponse<T> = {
content: T[];
number: number;
size: number;
totalElements: number;
totalPages: number;
};
// Expected invite response shape (adjust fields if your DTO differs)
type AdminInviteResponse = {
ok?: boolean;
email?: string;
// backend returns this
inviteUrl?: string;
// keep these optional if you ever add them later
magicUrl?: string;
token?: string;
};
async function fetchJson(path: string, init: RequestInit = {}) {
const res = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
cache: "no-store",
});
const text = await res.text().catch(() => "");
const data = text
? (() => {
try {
return JSON.parse(text);
} catch {
return text;
}
})()
: null;
if (!res.ok) {
const msg =
typeof data === "string"
? data
: data?.message || data?.error || `Request failed (${res.status})`;
throw new Error(msg);
}
return data;
}
function Badge({
ok,
yes = "Yes",
no = "No",
}: {
ok: boolean;
yes?: string;
no?: string;
}) {
return (
<span
className={[
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
ok
? "border border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border border-zinc-700 bg-zinc-950/40 text-zinc-400",
].join(" ")}
>
{ok ? yes : no}
</span>
);
}
export default function AdminBetaInvitesPage() {
// ------------------------------
// Batch invites
// ------------------------------
const [dryRun, setDryRun] = useState(true);
const [limit, setLimit] = useState("25");
const [tokenMinutes, setTokenMinutes] = useState("30");
const [loadingBatch, setLoadingBatch] = useState(false);
const [batchResult, setBatchResult] = useState<any>(null);
const [batchError, setBatchError] = useState<string | null>(null);
async function runInvites() {
setLoadingBatch(true);
setBatchError(null);
setBatchResult(null);
try {
const qs = new URLSearchParams({
dryRun: String(dryRun),
limit: String(limit || "0"),
tokenMinutes: String(tokenMinutes || "30"),
});
const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
method: "POST",
});
setBatchResult(data);
await loadRequests();
} catch (e: any) {
setBatchError(e?.message ?? "Failed");
} finally {
setLoadingBatch(false);
}
}
// ------------------------------
// Requests table
// ------------------------------
const [page, setPage] = useState(0);
const size = 25;
const [loadingList, setLoadingList] = useState(false);
const [listError, setListError] = useState<string | null>(null);
const [requests, setRequests] =
useState<PageResponse<AdminBetaRequestDto> | null>(null);
// per-row loading state
const [rowBusy, setRowBusy] = useState<Record<number, boolean>>({});
// last invite response (for visibility / debug)
const [lastInvite, setLastInvite] = useState<AdminInviteResponse | null>(
null
);
async function loadRequests() {
setLoadingList(true);
setListError(null);
try {
const data = (await fetchJson(
`/api/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" }
)) as PageResponse<AdminBetaRequestDto>;
setRequests(data);
} catch (e: any) {
setListError(e?.message ?? "Failed to load beta requests");
} finally {
setLoadingList(false);
}
}
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
method: "POST",
})) as AdminInviteResponse;
}
async function onSendInviteEmail(userId: number) {
setListError(null);
setLastInvite(null);
setRowBusy((m) => ({ ...m, [userId]: true }));
try {
const resp = await inviteSingle(userId);
setLastInvite(resp);
await loadRequests();
} catch (e: any) {
setListError(e?.message ?? "Failed to send invite");
} finally {
setRowBusy((m) => ({ ...m, [userId]: false }));
}
}
async function onCopyInviteLink(userId: number) {
setListError(null);
setLastInvite(null);
setRowBusy((m) => ({ ...m, [userId]: true }));
try {
const resp = await inviteSingle(userId);
setLastInvite(resp);
await loadRequests();
// build URL
const url =
resp.inviteUrl || // what your backend returns
resp.magicUrl ||
(resp.token
? `${window.location.origin}/beta/magic?token=${resp.token}`
: null);
if (!url) {
throw new Error(
`Invite response missing inviteUrl. Backend should return {"inviteUrl": "..."}`
);
}
await navigator.clipboard.writeText(url);
} catch (e: any) {
setListError(e?.message ?? "Failed to copy invite link");
} finally {
setRowBusy((m) => ({ ...m, [userId]: false }));
}
}
useEffect(() => {
loadRequests();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">Beta Access</h1>
<p className="mt-1 text-sm text-zinc-400">
Review pending beta requests and send invite links (batch or
per-user).
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
{/* Left: Batch Invites */}
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Batch Invites
</h2>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender.
</p>
</div>
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
{dryRun ? "Dry run" : "Live send"}
</span>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
Dry run (do everything except actually send)
</label>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Limit" htmlFor="limit">
<Input
id="limit"
value={limit}
onChange={(e) => setLimit(e.target.value)}
/>
</Field>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
/>
</Field>
</div>
<Button onClick={runInvites} disabled={loadingBatch}>
{loadingBatch ? "Running…" : "Send Beta Invites"}
</Button>
{batchError && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{batchError}
</div>
)}
{batchResult && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(batchResult, null, 2)}
</pre>
)}
</div>
{/* Right: Requests List */}
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Beta Requests
</h2>
<p className="mt-1 text-sm text-zinc-400">
Users with role <span className="text-zinc-200">BETA</span> and{" "}
<span className="text-zinc-200">is_active=false</span>.
</p>
</div>
<Button
onClick={loadRequests}
disabled={loadingList}
className="w-auto px-6"
>
{loadingList ? "Refreshing…" : "Refresh"}
</Button>
</div>
{listError && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{listError}
</div>
)}
<div className="overflow-x-auto rounded-md border border-zinc-800">
{" "}
<table className="min-w-[680px] w-full text-left text-sm">
<thead className="bg-black/30 text-xs text-zinc-500">
<tr className="border-b border-zinc-800">
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Invited</th>
<th className="px-3 py-2">Verified</th>
<th className="px-3 py-2">Active</th>
<th className="px-3 py-2 text-right w-[190px] whitespace-nowrap">
Actions
</th>
</tr>
</thead>
<tbody className="text-zinc-200">
{requests?.content?.length ? (
requests.content.map((u) => {
const busy = !!rowBusy[u.id];
return (
<tr key={u.id} className="border-b border-zinc-900">
<td className="px-3 py-2">
<div className="font-medium">{u.email}</div>
<div className="text-xs text-zinc-500">
{u.displayName ?? "—"} #{u.id}
</div>
</td>
<td className="px-3 py-2">
<Badge ok={u.invited} yes="Invited" no="—" />
</td>
<td className="px-3 py-2">
<Badge ok={u.verified} yes="Verified" no="—" />
</td>
<td className="px-3 py-2">
<Badge ok={u.active} yes="Active" no="Inactive" />
</td>
<td className="px-3 py-2 w-[190px]">
<div className="flex justify-end gap-2">
<button
onClick={() => onCopyInviteLink(u.id)}
disabled={busy}
className="rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-900 disabled:opacity-60"
title="Dev helper: generate invite and copy URL (also triggers invite generation server-side)"
>
{busy ? "Working…" : "Copy link (dev)"}
</button>
<button
onClick={() => onSendInviteEmail(u.id)}
disabled={busy}
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 hover:bg-amber-500/15 disabled:opacity-60"
title="Send invite email to this user"
>
{busy ? "Working…" : "Send invite email"}
</button>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td className="px-3 py-6 text-sm text-zinc-400" colSpan={5}>
{loadingList ? "Loading…" : "No pending beta requests."}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* pager */}
<div className="flex items-center justify-between text-xs text-zinc-500">
<button
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
disabled={!requests || requests.number <= 0 || loadingList}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Prev
</button>
<span>
Page {requests ? requests.number + 1 : 1} of{" "}
{requests ? requests.totalPages : 1} {" "}
{requests ? requests.totalElements : 0} total
</span>
<button
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
disabled={
!requests ||
loadingList ||
requests.number >= requests.totalPages - 1
}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
{/* optional debug panel */}
{lastInvite && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(lastInvite, null, 2)}
</pre>
)}
</div>
</div>
</div>
);
}