6f8a5fe969
Gaps found while writing the user manual: - Forgotten passwords: owners and managers can create a one-time password reset link from Staff (managers for their own cashiers). It expires after 24 hours; the old password works until it is used, and using it signs the account out everywhere. Reset and invite links share the invite page, which now says which it is. An owner who forgets their password gets a link from the platform operator (Platform console, "Owner password link"). The login page's "Forgot password?" explains this. - Change password: any signed-in user, from the dashboard, the till or the platform console. Needs the current password; other devices are signed out. - Sign out on the dashboard and the platform console (the till already had it), through one shared helper that also clears the till's cached session. - A Branches link on the owner's dashboard; the page was only reachable by typing its address. - Owners can move a manager or cashier to another branch from Staff. A moved manager stops being the old branch's manager and becomes the new branch's if it has none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiFetch, ApiError } from "@/lib/api";
|
|
import { AuthUser } from "@/lib/types";
|
|
import { useToast } from "@/components/ui/Toast";
|
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
|
import AccountLinks from "@/components/AccountLinks";
|
|
|
|
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
|
|
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
|
|
|
interface BizRow {
|
|
id: string;
|
|
name: string;
|
|
tier: TierId;
|
|
tierName: string;
|
|
priceKsh: number;
|
|
subscriptionStatus: SubStatus;
|
|
trialEndsAt: string | null;
|
|
currentPeriodEnd: string | null;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
userCount: number;
|
|
branchCount: number;
|
|
maxUsers: number | null;
|
|
maxBranches: number | null;
|
|
owner: { name: string; email: string; phoneNumber: string } | null;
|
|
}
|
|
|
|
interface Stats {
|
|
total: number;
|
|
active: number;
|
|
trialing: number;
|
|
suspended: number;
|
|
mrr: number;
|
|
}
|
|
|
|
const TIER_OPTIONS: TierId[] = ["STARTER", "GROWTH", "ENTERPRISE"];
|
|
const STATUS_OPTIONS: SubStatus[] = ["TRIALING", "ACTIVE", "PAST_DUE", "SUSPENDED"];
|
|
|
|
function ksh(n: number) {
|
|
return "KSh " + n.toLocaleString("en-KE");
|
|
}
|
|
|
|
function fmtDate(d: string | null) {
|
|
if (!d) return "—";
|
|
return new Date(d).toLocaleDateString("en-KE", { year: "numeric", month: "short", day: "numeric" });
|
|
}
|
|
|
|
const STATUS_STYLES: Record<SubStatus, string> = {
|
|
ACTIVE: "bg-confirm-500/15 text-confirm-600",
|
|
TRIALING: "bg-flow-500/15 text-flow-600",
|
|
PAST_DUE: "bg-cash-500/15 text-cash-600",
|
|
SUSPENDED: "bg-alert-500/15 text-alert-600",
|
|
};
|
|
|
|
export default function PlatformPage() {
|
|
const router = useRouter();
|
|
const { show } = useToast();
|
|
const [ready, setReady] = useState(false);
|
|
const [rows, setRows] = useState<BizRow[]>([]);
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [savingId, setSavingId] = useState<string | null>(null);
|
|
const [ownerLink, setOwnerLink] = useState<{ business: string; name: string; email: string; url: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
let me: AuthUser;
|
|
try {
|
|
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
|
me = result.user;
|
|
} catch {
|
|
router.replace("/login");
|
|
return;
|
|
}
|
|
if (!me.isPlatformAdmin) {
|
|
router.replace(me.role === "DELIVERY" ? "/terminal" : "/admin/dashboard");
|
|
return;
|
|
}
|
|
setReady(true);
|
|
await load();
|
|
setLoading(false);
|
|
})();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
async function load() {
|
|
const result = await apiFetch<{ businesses: BizRow[]; stats: Stats }>("/api/platform/businesses");
|
|
setRows(result.businesses);
|
|
setStats(result.stats);
|
|
}
|
|
|
|
async function patch(id: string, update: Record<string, unknown>, label: string) {
|
|
setSavingId(id);
|
|
try {
|
|
await apiFetch("/api/platform/businesses/" + id, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(update),
|
|
});
|
|
show(label, "success");
|
|
await load();
|
|
} catch (err) {
|
|
const message = err instanceof ApiError ? err.message : "Update failed";
|
|
show(message, "error");
|
|
}
|
|
setSavingId(null);
|
|
}
|
|
|
|
// A one-time password link for a business's owner (an owner who forgot
|
|
// their password has nobody inside the business who can reset it).
|
|
async function ownerReset(b: BizRow) {
|
|
if (!window.confirm(`Create a password link for the owner of ${b.name}? Send it only to the owner, by a channel you know is theirs.`)) {
|
|
return;
|
|
}
|
|
try {
|
|
const r = await apiFetch<{ inviteUrl: string; email: string; name: string }>(
|
|
"/api/platform/businesses/" + b.id + "/owner-reset",
|
|
{ method: "POST" }
|
|
);
|
|
setOwnerLink({ business: b.name, name: r.name, email: r.email, url: r.inviteUrl });
|
|
} catch (err) {
|
|
show(err instanceof ApiError ? err.message : "Could not create the link", "error");
|
|
}
|
|
}
|
|
|
|
if (!ready) {
|
|
return (
|
|
<main className="min-h-screen bg-depth-950 pb-12">
|
|
<header className="px-5 pb-8 pt-8">
|
|
<div className="mx-auto h-14 max-w-5xl" />
|
|
</header>
|
|
<div className="mx-auto max-w-5xl px-5">
|
|
<SkeletonList rows={4} />
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="min-h-screen bg-depth-950 pb-16 text-white">
|
|
<header className="border-b border-white/10 px-5 pb-6 pt-8">
|
|
<div className="mx-auto max-w-5xl">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-flow-400">Operator</p>
|
|
<h1 className="font-display text-2xl font-bold">Platform Command Center</h1>
|
|
<p className="mt-1 text-sm text-white/60">
|
|
Every business on your platform, across all tenants. Change plans, extend billing periods,
|
|
and suspend accounts here.
|
|
</p>
|
|
<div className="mt-3 flex items-center gap-4">
|
|
<a href="/admin/dashboard" className="text-sm text-sand-200/70 underline underline-offset-2 hover:text-white">
|
|
Your business dashboard
|
|
</a>
|
|
<AccountLinks />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="mx-auto max-w-5xl space-y-6 px-5 pt-6">
|
|
{stats && (
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
|
<StatCard label="Businesses" value={String(stats.total)} />
|
|
<StatCard label="Active" value={String(stats.active)} accent="text-confirm-400" />
|
|
<StatCard label="Trialing" value={String(stats.trialing)} accent="text-flow-400" />
|
|
<StatCard label="Suspended" value={String(stats.suspended)} accent="text-alert-400" />
|
|
<StatCard label="MRR (active)" value={ksh(stats.mrr)} accent="text-flow-300" />
|
|
</div>
|
|
)}
|
|
|
|
{ownerLink && (
|
|
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm">
|
|
<p className="font-medium text-white">
|
|
Password link for {ownerLink.name} ({ownerLink.email}), owner of {ownerLink.business}
|
|
</p>
|
|
<p className="mt-1 text-xs text-white/60">
|
|
It works once and expires within 24 hours (7 days if they never set a password). Whoever opens it can take
|
|
over the account: send it only to the owner, by a channel you know is theirs.
|
|
</p>
|
|
<input
|
|
readOnly
|
|
value={ownerLink.url}
|
|
onFocus={(e) => e.target.select()}
|
|
aria-label="Owner password link"
|
|
className="mt-3 w-full rounded-lg border border-white/20 bg-depth-900 px-3 py-2 font-mono text-xs text-white"
|
|
/>
|
|
<button onClick={() => setOwnerLink(null)} className="mt-2 text-xs text-white/60 underline">
|
|
Done
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<SkeletonList rows={4} />
|
|
) : rows.length === 0 ? (
|
|
<div className="rounded-xl2 border border-white/10 bg-white/5 p-8 text-center text-white/60">
|
|
No businesses have signed up yet.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{rows.map((b) => (
|
|
<BusinessCard
|
|
key={b.id}
|
|
b={b}
|
|
saving={savingId === b.id}
|
|
onPatch={patch}
|
|
onOwnerReset={ownerReset}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, accent }: { label: string; value: string; accent?: string }) {
|
|
return (
|
|
<div className="rounded-xl2 border border-white/10 bg-white/5 p-4">
|
|
<p className="text-xs uppercase tracking-wide text-white/50">{label}</p>
|
|
<p className={"mt-1 font-display text-xl font-bold " + (accent ?? "text-white")}>{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BusinessCard({
|
|
b,
|
|
saving,
|
|
onPatch,
|
|
onOwnerReset,
|
|
}: {
|
|
b: BizRow;
|
|
saving: boolean;
|
|
onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
|
|
onOwnerReset: (b: BizRow) => void;
|
|
}) {
|
|
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
|
|
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
|
|
return (
|
|
<div className="rounded-xl2 border border-white/10 bg-white/5 p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="font-display text-lg font-semibold text-white">{b.name}</h2>
|
|
<span className={"rounded-full px-2.5 py-0.5 text-xs font-medium " + STATUS_STYLES[b.subscriptionStatus]}>
|
|
{b.subscriptionStatus}
|
|
</span>
|
|
</div>
|
|
{b.owner && (
|
|
<p className="mt-0.5 text-xs text-white/50">
|
|
{b.owner.name} · {b.owner.email} · {b.owner.phoneNumber}
|
|
</p>
|
|
)}
|
|
<p className="mt-0.5 text-xs text-white/40">Joined {fmtDate(b.createdAt)}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-display text-base font-semibold text-flow-300">
|
|
{b.tierName} · {ksh(b.priceKsh)}/mo
|
|
</p>
|
|
<p className="mt-0.5 text-xs text-white/50">
|
|
Trial ends {fmtDate(b.trialEndsAt)} · Paid thru {fmtDate(b.currentPeriodEnd)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap gap-4 text-sm">
|
|
<span className={overBranches ? "text-alert-400" : "text-white/70"}>
|
|
Branches: {b.branchCount}
|
|
{b.maxBranches === null ? " / ∞" : " / " + b.maxBranches}
|
|
</span>
|
|
<span className={overUsers ? "text-alert-400" : "text-white/70"}>
|
|
Users: {b.userCount}
|
|
{b.maxUsers === null ? " / ∞" : " / " + b.maxUsers}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-end gap-3 border-t border-white/10 pt-4">
|
|
<div>
|
|
<label className="mb-1 block text-xs text-white/50">Plan</label>
|
|
<select
|
|
defaultValue={b.tier}
|
|
disabled={saving}
|
|
onChange={(e) =>
|
|
onPatch(b.id, { tier: e.target.value }, b.name + " moved to " + e.target.value)
|
|
}
|
|
className="rounded-xl border border-white/15 bg-depth-900 px-3 py-2 text-sm text-white"
|
|
>
|
|
{TIER_OPTIONS.map((t) => (
|
|
<option key={t} value={t}>
|
|
{t}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-white/50">Status</label>
|
|
<select
|
|
defaultValue={b.subscriptionStatus}
|
|
disabled={saving}
|
|
onChange={(e) =>
|
|
onPatch(b.id, { subscriptionStatus: e.target.value }, b.name + " set to " + e.target.value)
|
|
}
|
|
className="rounded-xl border border-white/15 bg-depth-900 px-3 py-2 text-sm text-white"
|
|
>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={() => onPatch(b.id, { extendDays: 30 }, b.name + " extended 30 days (now ACTIVE)")}
|
|
className="tap-target rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 disabled:opacity-60"
|
|
>
|
|
{saving ? "Saving…" : "Mark paid +30 days"}
|
|
</button>
|
|
{b.subscriptionStatus !== "SUSPENDED" ? (
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={() => onPatch(b.id, { subscriptionStatus: "SUSPENDED" }, b.name + " suspended")}
|
|
className="tap-target rounded-xl2 border border-alert-500/40 px-4 text-sm font-medium text-alert-300 disabled:opacity-60"
|
|
>
|
|
Suspend
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={() => onPatch(b.id, { subscriptionStatus: "ACTIVE" }, b.name + " reactivated")}
|
|
className="tap-target rounded-xl2 border border-confirm-500/40 px-4 text-sm font-medium text-confirm-300 disabled:opacity-60"
|
|
>
|
|
Reactivate
|
|
</button>
|
|
)}
|
|
{b.owner && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOwnerReset(b)}
|
|
className="tap-target rounded-xl2 border border-white/20 px-4 text-sm font-medium text-white/80"
|
|
>
|
|
Owner password link
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |