Add admin panel for merchant management

- POST/GET/DELETE /api/admin/users endpoints
- Admin UI at /admin with login, user list, create/delete
- Auth via ADMIN_API_KEY env var (separate from tenant auth)
- Tenants remain scoped to their own store only
This commit is contained in:
jwinterm
2026-03-10 11:25:02 -04:00
parent a8fcde59eb
commit d85e26fbbd
9 changed files with 658 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
const ADMIN_COOKIE = "wowcheckout_admin_key";
export type AdminLoginState = {
error: string | null;
};
export type AdminCreateUserState = {
error: string | null;
success: string | null;
apiKey: string | null;
webhookSecret: string | null;
storeId: string | null;
};
export type AdminDeleteUserState = {
error: string | null;
success: string | null;
deletedId: string | null;
};
async function getAdminKey(): Promise<string | null> {
return (await cookies()).get(ADMIN_COOKIE)?.value ?? null;
}
export async function adminLoginAction(
_prevState: AdminLoginState,
formData: FormData
): Promise<AdminLoginState> {
const key = String(formData.get("admin_key") ?? "").trim();
if (!key) {
return { error: "Admin key is required." };
}
const response = await fetch(`${apiBaseUrl}/api/admin/auth/verify`, {
method: "POST",
headers: { "X-Admin-Key": key },
cache: "no-store",
});
if (!response.ok) {
return { error: "Invalid admin key." };
}
const cookieStore = await cookies();
cookieStore.set(ADMIN_COOKIE, key, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
redirect("/admin");
}
export async function adminLogoutAction() {
const cookieStore = await cookies();
cookieStore.set(ADMIN_COOKIE, "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
redirect("/admin");
}
export async function adminCreateUserAction(
_prevState: AdminCreateUserState,
formData: FormData
): Promise<AdminCreateUserState> {
const adminKey = await getAdminKey();
if (!adminKey) {
return { error: "Not authenticated.", success: null, apiKey: null, webhookSecret: null, storeId: null };
}
const paymentAddress = String(formData.get("payment_address") ?? "").trim();
const viewKey = String(formData.get("view_key") ?? "").trim();
if (!paymentAddress || !viewKey) {
return { error: "Address and view key are required.", success: null, apiKey: null, webhookSecret: null, storeId: null };
}
const response = await fetch(`${apiBaseUrl}/api/admin/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Admin-Key": adminKey,
},
body: JSON.stringify({ payment_address: paymentAddress, view_key: viewKey }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Failed to create user.";
return { error: message, success: null, apiKey: null, webhookSecret: null, storeId: null };
}
const data = (await response.json()) as {
api_key: string;
webhook_secret: string;
store_id: string;
};
revalidatePath("/admin");
return {
error: null,
success: `Merchant created.`,
apiKey: data.api_key,
webhookSecret: data.webhook_secret,
storeId: data.store_id,
};
}
export async function adminDeleteUserAction(
_prevState: AdminDeleteUserState,
formData: FormData
): Promise<AdminDeleteUserState> {
const adminKey = await getAdminKey();
if (!adminKey) {
return { error: "Not authenticated.", success: null, deletedId: null };
}
const userId = String(formData.get("user_id") ?? "").trim();
if (!userId) {
return { error: "User ID missing.", success: null, deletedId: null };
}
const response = await fetch(`${apiBaseUrl}/api/admin/users/${encodeURIComponent(userId)}`, {
method: "DELETE",
headers: { "X-Admin-Key": adminKey },
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Failed to delete user.";
return { error: message, success: null, deletedId: null };
}
revalidatePath("/admin");
return { error: null, success: "Merchant deleted.", deletedId: userId };
}
export type AdminUser = {
id: string;
payment_address: string;
invoice_count: number;
created_at: string | null;
};
export async function fetchAdminUsers(): Promise<AdminUser[] | null> {
const adminKey = await getAdminKey();
if (!adminKey) return null;
const response = await fetch(`${apiBaseUrl}/api/admin/users`, {
headers: { "X-Admin-Key": adminKey },
cache: "no-store",
});
if (!response.ok) return null;
return response.json();
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useActionState } from "react";
import { adminLoginAction, type AdminLoginState } from "./actions";
const initialState: AdminLoginState = { error: null };
export function AdminLoginForm() {
const [state, formAction, pending] = useActionState(adminLoginAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="admin_key" className="block text-sm font-medium mb-1">
Admin API Key
</label>
<input
type="password"
id="admin_key"
name="admin_key"
className="w-full rounded border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
required
autoFocus
/>
</div>
{state.error && (
<p className="text-sm text-red-500">{state.error}</p>
)}
<button
type="submit"
disabled={pending}
className="w-full rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{pending ? "Verifying…" : "Sign in"}
</button>
</form>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { useActionState, useState } from "react";
import {
adminCreateUserAction,
adminDeleteUserAction,
adminLogoutAction,
type AdminCreateUserState,
type AdminDeleteUserState,
type AdminUser,
} from "./actions";
const createInitial: AdminCreateUserState = {
error: null,
success: null,
apiKey: null,
webhookSecret: null,
storeId: null,
};
const deleteInitial: AdminDeleteUserState = {
error: null,
success: null,
deletedId: null,
};
export function AdminPanel({ users }: { users: AdminUser[] }) {
const [createState, createAction, createPending] = useActionState(
adminCreateUserAction,
createInitial
);
const [deleteState, deleteAction, deletePending] = useActionState(
adminDeleteUserAction,
deleteInitial
);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const visibleUsers = users.filter(
(u) => u.id !== deleteState.deletedId
);
return (
<div className="min-h-screen px-4 py-12 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Admin — Merchants</h1>
<form action={adminLogoutAction}>
<button
type="submit"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Log out
</button>
</form>
</div>
{/* Add merchant form */}
<div className="mb-10 p-6 rounded-lg border border-border bg-card">
<h2 className="text-lg font-semibold mb-4">Add Merchant</h2>
<form action={createAction} className="space-y-3">
<div>
<label htmlFor="payment_address" className="block text-sm font-medium mb-1">
Primary Address
</label>
<textarea
id="payment_address"
name="payment_address"
rows={2}
className="w-full rounded border border-border bg-background px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
</div>
<div>
<label htmlFor="view_key" className="block text-sm font-medium mb-1">
Secret View Key
</label>
<input
type="text"
id="view_key"
name="view_key"
className="w-full rounded border border-border bg-background px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
</div>
<button
type="submit"
disabled={createPending}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{createPending ? "Creating…" : "Create Merchant"}
</button>
</form>
{createState.error && (
<p className="mt-3 text-sm text-red-500">{createState.error}</p>
)}
{createState.success && (
<div className="mt-3 p-4 rounded border border-green-500/30 bg-green-500/10 text-sm space-y-2">
<p className="font-medium text-green-600">{createState.success}</p>
<div className="space-y-1 font-mono text-xs">
<p><span className="font-semibold">Store ID:</span> {createState.storeId}</p>
<p><span className="font-semibold">API Key:</span> {createState.apiKey}</p>
<p><span className="font-semibold">Webhook Secret:</span> {createState.webhookSecret}</p>
</div>
<p className="text-xs text-muted-foreground">Save these credentials — the API key is shown only once.</p>
</div>
)}
</div>
{/* Merchant list */}
<div>
<h2 className="text-lg font-semibold mb-4">
Registered Merchants ({visibleUsers.length})
</h2>
{visibleUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">No merchants registered yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="py-2 pr-4 font-medium">Address</th>
<th className="py-2 pr-4 font-medium">Invoices</th>
<th className="py-2 pr-4 font-medium">Created</th>
<th className="py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{visibleUsers.map((user) => (
<tr key={user.id} className="border-b border-border/50">
<td className="py-3 pr-4">
<span className="font-mono text-xs break-all">
{user.payment_address.slice(0, 8)}…{user.payment_address.slice(-8)}
</span>
</td>
<td className="py-3 pr-4">{user.invoice_count}</td>
<td className="py-3 pr-4 text-muted-foreground">
{user.created_at
? new Date(user.created_at).toLocaleDateString()
: "—"}
</td>
<td className="py-3">
{confirmDelete === user.id ? (
<span className="flex items-center gap-2">
<form action={deleteAction}>
<input type="hidden" name="user_id" value={user.id} />
<button
type="submit"
disabled={deletePending}
className="text-xs text-red-500 hover:text-red-700 font-medium disabled:opacity-50"
>
Confirm
</button>
</form>
<button
type="button"
onClick={() => setConfirmDelete(null)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Cancel
</button>
</span>
) : (
<button
type="button"
onClick={() => setConfirmDelete(user.id)}
className="text-xs text-red-500 hover:text-red-700"
>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{deleteState.error && (
<p className="mt-3 text-sm text-red-500">{deleteState.error}</p>
)}
{deleteState.success && (
<p className="mt-3 text-sm text-green-600">{deleteState.success}</p>
)}
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { cookies } from "next/headers";
import { fetchAdminUsers } from "./actions";
import { AdminLoginForm } from "./admin-login-form";
import { AdminPanel } from "./admin-panel";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const cookieStore = await cookies();
const adminKey = cookieStore.get("wowcheckout_admin_key")?.value;
if (!adminKey) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
<h1 className="text-2xl font-bold mb-6 text-center">Admin</h1>
<AdminLoginForm />
</div>
</div>
);
}
const users = await fetchAdminUsers();
if (users === null) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
<h1 className="text-2xl font-bold mb-6 text-center">Admin</h1>
<p className="text-center text-red-500 mb-4">Invalid or expired admin key.</p>
<AdminLoginForm />
</div>
</div>
);
}
return <AdminPanel users={users} />;
}