Rebuild the frontend as a product rather than an internal tool
The UI worked but read as an operator console: no public face, no way to describe or sell the thing, and no account surface beyond the node list. This adds the missing halves and reorganises what was already there around the incident, which is the unit of value the rest of the pipeline is built to produce. A shared design system replaces per-page styling: components/ui (Button, Card, Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the Tailwind config, and light-mode tokens in globals.css. The existing html:not(.dark) remap mechanism is extended rather than replaced -- a parallel theming system would have been two sources of truth for the same colours. Public marketing pages (/, /features, /pricing, /faq) load without a session. middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX redirect and is still NOT an authorisation boundary, which the comment there says explicitly. Real enforcement is unchanged and still lives server-side in c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher instead of by route group, because a route group would have collided on / and forced most of app/ to move for no behavioural gain. Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and lib/apiKeys.ts define the data model and the screens consume it, but every mutating call throws with a message naming the backend route that has to exist first, and the sample data is labelled as sample. Nothing here can charge anyone or mint a real credential -- picking a payment processor and holding its keys is a decision for a human, and a half-wired checkout is worse than an obviously absent one. The severity work from the c2-core change lands here too. severity is now a filter and sort dimension on the incident list rather than decoration, since a busy dispatch channel is only readable if you can collapse it to moderate and above. routine gets a muted treatment because it is the majority of traffic, legacy "unknown" still renders nothing, and TypeBadge handles the new "other" incident type. Severity rendering moved into lib/severity.tsx so the incident list, incident detail and call rows cannot drift apart. Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and admin. They already share the palette and stay coherent, and rewriting them would have buried the parts that actually needed to change. No colour tokens were renamed, so nothing regressed there. Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification was possible and none was done. No new environment variables.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listApiKeys, createApiKey, revokeApiKey, type ApiKeyRecord } from "@/lib/apiKeys";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated: (r: ApiKeyRecord) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rawKey, setRawKey] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const { record, rawKey } = await createApiKey(name);
|
||||
onCreated(record);
|
||||
setRawKey(rawKey);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copy() {
|
||||
if (!rawKey) return;
|
||||
navigator.clipboard?.writeText(rawKey).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
||||
}
|
||||
|
||||
if (rawKey) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
||||
<h2 className="text-white font-semibold">Key created</h2>
|
||||
<p className="text-xs text-gray-400">
|
||||
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
||||
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
||||
</p>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={copy} fullWidth>{copied ? "Copied!" : "Copy key"}</Button>
|
||||
<Button onClick={onClose} fullWidth>Done</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md">
|
||||
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||||
<input
|
||||
required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Ops dashboard integration"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={saving} fullWidth>{saving ? "Creating…" : "Create key"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApiKeysSettingsPage() {
|
||||
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
useEffect(() => { listApiKeys().then(setKeys).finally(() => setLoading(false)); }, []);
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
await revokeApiKey(id);
|
||||
setKeys((prev) => prev.map((k) => (k.key_id === id ? { ...k, revoked: true } : k)));
|
||||
}
|
||||
|
||||
const active = keys.filter((k) => !k.revoked);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
||||
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
||||
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
|
||||
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
||||
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to
|
||||
end. See that file for the exact backend routes a real integration needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateKeyModal onClose={() => setShowCreate(false)} onCreated={(r) => setKeys((prev) => [...prev, r])} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
||||
</div>
|
||||
|
||||
{!loading && keys.length === 0 ? (
|
||||
<EmptyState title="No API keys yet" description="Create one to authenticate external integrations against the DRB API." />
|
||||
) : (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Label</th>
|
||||
<th className="px-4 py-3 text-left">Key</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Last used</th>
|
||||
<th className="px-4 py-3 text-left">Status</th>
|
||||
<th className="px-4 py-3 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.key_id} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3 text-white">{k.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!k.revoked && (
|
||||
<button onClick={() => handleRevoke(k.key_id)} className="text-xs text-red-500 hover:text-red-400 transition-colors">
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
PLANS, getPlan, getCurrentSubscription, getUsageSummary, getInvoices,
|
||||
createCheckoutSession, createBillingPortalSession,
|
||||
type Subscription, type UsageSummary, type Invoice, type PlanId,
|
||||
} from "@/lib/billing";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function StatusBanner({ sub }: { sub: Subscription }) {
|
||||
if (sub.status === "trialing" && sub.trialEndsAt) {
|
||||
return (
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-indigo-300">
|
||||
Trial active — ends {fmtDate(sub.trialEndsAt)}. Add a payment method to keep your plan after that.
|
||||
</p>
|
||||
<Badge tone="brand">Trial</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.status === "past_due") {
|
||||
return (
|
||||
<div className="bg-red-600/10 border border-red-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-red-400">
|
||||
Payment failed on your last invoice. Update your payment method to avoid losing access.
|
||||
</p>
|
||||
<Badge tone="danger">Past due</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.status === "canceled") {
|
||||
return (
|
||||
<div className="bg-yellow-600/10 border border-yellow-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-yellow-400">Your subscription is canceled. Reactivate to restore full access.</p>
|
||||
<Badge tone="warning">Canceled</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function UsageBar({ label, used, limit }: { label: string; used: number; limit: number | "unlimited" }) {
|
||||
const pct = limit === "unlimited" ? 0 : Math.min(100, Math.round((used / Math.max(limit, 1)) * 100));
|
||||
const nearLimit = limit !== "unlimited" && used / limit >= 0.9;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span className="text-xs text-gray-400 font-mono">{label}</span>
|
||||
<span className="text-xs font-mono text-gray-300">
|
||||
{used} / {limit === "unlimited" ? "∞" : limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
||||
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
|
||||
paid: "success",
|
||||
open: "warning",
|
||||
void: "neutral",
|
||||
uncollectible: "danger",
|
||||
};
|
||||
|
||||
export default function BillingSettingsPage() {
|
||||
const [sub, setSub] = useState<Subscription | null>(null);
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busyPlan, setBusyPlan] = useState<PlanId | null>(null);
|
||||
const [portalBusy, setPortalBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getCurrentSubscription(), getUsageSummary(), getInvoices()])
|
||||
.then(([s, u, i]) => { setSub(s); setUsage(u); setInvoices(i); })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleChoosePlan(planId: PlanId) {
|
||||
setBusyPlan(planId);
|
||||
setActionError(null);
|
||||
try {
|
||||
const { url } = await createCheckoutSession(planId, sub?.interval ?? "monthly");
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyPlan(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManageBilling() {
|
||||
setPortalBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const { url } = await createBillingPortalSession();
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setPortalBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !sub || !usage) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const plan = getPlan(sub.planId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
||||
</p>
|
||||
|
||||
<StatusBanner sub={sub} />
|
||||
|
||||
{actionError && (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm">{actionError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Current plan"
|
||||
subtitle={sub.cancelAtPeriodEnd ? `Cancels ${sub.currentPeriodEnd ? fmtDate(sub.currentPeriodEnd) : "at period end"}` : sub.currentPeriodEnd ? `Renews ${fmtDate(sub.currentPeriodEnd)}` : undefined}
|
||||
action={<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name}</Badge>}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
||||
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-gray-800">
|
||||
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
||||
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Change plan" subtitle="Upgrading takes effect immediately; downgrading takes effect at the end of the current period." />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{PLANS.map((p) => {
|
||||
const isCurrent = p.id === sub.planId;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
|
||||
>
|
||||
<p className="text-white font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
|
||||
<p className="text-white text-lg font-bold font-mono mt-3">
|
||||
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant={isCurrent ? "secondary" : "primary"}
|
||||
disabled={isCurrent || busyPlan === p.id}
|
||||
onClick={() => handleChoosePlan(p.id)}
|
||||
fullWidth
|
||||
>
|
||||
{isCurrent ? "Current plan" : busyPlan === p.id ? "Redirecting…" : p.priceMonthlyUsd === null ? "Contact sales" : "Switch"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Invoice history" />
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm">No invoices yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{invoices.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-gray-200 text-sm">{inv.description}</p>
|
||||
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/settings/organization", label: "Organization" },
|
||||
{ href: "/settings/members", label: "Members" },
|
||||
{ href: "/settings/nodes", label: "Node Ownership" },
|
||||
{ href: "/settings/api-keys", label: "API Keys" },
|
||||
{ href: "/settings/billing", label: "Billing" },
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isAdmin, loading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !isAdmin) router.replace("/dashboard");
|
||||
}, [loading, isAdmin, router]);
|
||||
|
||||
if (loading || !isAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Organization profile, team access, node ownership, API keys, and billing."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
||||
{TABS.map((t) => (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
||||
pathname === t.href || pathname.startsWith(t.href + "/")
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { UserRecord, UserRole } from "@/lib/types";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonRow } from "@/components/ui/Skeleton";
|
||||
|
||||
const ROLE_TONE: Record<UserRole, "brand" | "success" | "neutral"> = {
|
||||
admin: "brand",
|
||||
operator: "success",
|
||||
viewer: "neutral",
|
||||
};
|
||||
|
||||
const ROLE_LABEL: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
|
||||
|
||||
function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (u: UserRecord) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<UserRole>("viewer");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await c2api.createUser({ email, role });
|
||||
onCreated(created);
|
||||
if (created.invite_link) setInviteLink(created.invite_link);
|
||||
else onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (inviteLink) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-semibold">Member invited</h2>
|
||||
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||
</div>
|
||||
<Button onClick={onClose} fullWidth>Done</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md">
|
||||
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||
<input
|
||||
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
placeholder="teammate@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||||
<select
|
||||
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="admin">Admin — full access</option>
|
||||
<option value="operator">Operator — owns nodes</option>
|
||||
<option value="viewer">Viewer — read-only</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" disabled={saving} fullWidth>{saving ? "Sending…" : "Send invite"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MembersSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [savingUid, setSavingUid] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setUsers(await c2api.listUsers());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleRoleChange(u: UserRecord, role: UserRole) {
|
||||
setSavingUid(u.uid);
|
||||
try {
|
||||
const updated = await c2api.updateUser(u.uid, { role, owned_node_ids: role === "operator" ? u.owned_node_ids : [] });
|
||||
setUsers((prev) => prev.map((x) => (x.uid === u.uid ? { ...x, ...updated } : x)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingUid(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showInvite && (
|
||||
<InviteModal onClose={() => setShowInvite(false)} onCreated={(u) => setUsers((prev) => [...prev, u])} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!loading && users.length === 0 ? (
|
||||
<EmptyState title="No members yet" description="Invite your team to give them dashboard access." />
|
||||
) : (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Member</th>
|
||||
<th className="px-4 py-3 text-left">Role</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
|
||||
<th className="px-4 py-3 text-left hidden md:table-cell">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading
|
||||
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
||||
: users.map((u) => (
|
||||
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-white">{u.display_name || u.email}</p>
|
||||
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.uid === user?.uid ? (
|
||||
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABEL[u.role]}</Badge>
|
||||
) : (
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={savingUid === u.uid}
|
||||
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">
|
||||
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{u.disabled ? <Badge tone="danger">Disabled</Badge> : <Badge tone="success">Active</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { UserRecord } from "@/lib/types";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonRow } from "@/components/ui/Skeleton";
|
||||
|
||||
const UNASSIGNED = "__unassigned__";
|
||||
|
||||
export default function NodeOwnershipSettingsPage() {
|
||||
const { nodes, loading: nodesLoading } = useNodes();
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingNodeId, setSavingNodeId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
c2api.listUsers()
|
||||
.then(setUsers)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoadingUsers(false));
|
||||
}, []);
|
||||
|
||||
// Ownership (owned_node_ids) is only meaningful for operators elsewhere in the
|
||||
// app (see Admin → Users); admins already have full access regardless.
|
||||
const assignable = useMemo(() => users.filter((u) => u.role === "operator"), [users]);
|
||||
|
||||
const ownerByNode = useMemo(() => {
|
||||
const map = new Map<string, UserRecord>();
|
||||
for (const u of users) {
|
||||
if (u.role !== "operator") continue;
|
||||
for (const nodeId of u.owned_node_ids) map.set(nodeId, u);
|
||||
}
|
||||
return map;
|
||||
}, [users]);
|
||||
|
||||
const reassign = useCallback(async (nodeId: string, newUid: string) => {
|
||||
setSavingNodeId(nodeId);
|
||||
setError(null);
|
||||
try {
|
||||
const prevOwner = ownerByNode.get(nodeId);
|
||||
// Remove from previous owner, if any and different from the new one.
|
||||
if (prevOwner && prevOwner.uid !== newUid) {
|
||||
const next = prevOwner.owned_node_ids.filter((id) => id !== nodeId);
|
||||
await c2api.updateUser(prevOwner.uid, { owned_node_ids: next });
|
||||
setUsers((all) => all.map((u) => (u.uid === prevOwner.uid ? { ...u, owned_node_ids: next } : u)));
|
||||
}
|
||||
// Add to new owner, if one was selected.
|
||||
if (newUid !== UNASSIGNED) {
|
||||
const newOwner = users.find((u) => u.uid === newUid);
|
||||
if (newOwner && !newOwner.owned_node_ids.includes(nodeId)) {
|
||||
const next = [...newOwner.owned_node_ids, nodeId];
|
||||
await c2api.updateUser(newOwner.uid, { owned_node_ids: next });
|
||||
setUsers((all) => all.map((u) => (u.uid === newOwner.uid ? { ...u, owned_node_ids: next } : u)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingNodeId(null);
|
||||
}
|
||||
}, [ownerByNode, users]);
|
||||
|
||||
const loading = nodesLoading || loadingUsers;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
||||
</p>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Node</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
|
||||
<th className="px-4 py-3 text-left">Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
||||
) : nodes.length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
|
||||
) : (
|
||||
nodes.map((n) => {
|
||||
const owner = ownerByNode.get(n.node_id);
|
||||
return (
|
||||
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
||||
{n.name}
|
||||
</Link>
|
||||
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={owner?.uid ?? UNASSIGNED}
|
||||
disabled={savingNodeId === n.node_id}
|
||||
onChange={(e) => reassign(n.node_id, e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
|
||||
>
|
||||
<option value={UNASSIGNED}>Unassigned</option>
|
||||
{assignable.map((u) => (
|
||||
<option key={u.uid} value={u.uid}>{u.display_name || u.email}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
|
||||
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
const { nodes } = useNodes();
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
const [sub, setSub] = useState<Subscription | null>(null);
|
||||
const [orgName, setOrgName] = useState("My Organization");
|
||||
|
||||
useEffect(() => {
|
||||
c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
|
||||
getCurrentSubscription().then(setSub);
|
||||
}, []);
|
||||
|
||||
const plan = sub ? getPlan(sub.planId) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Organization profile"
|
||||
subtitle="Basic identity for this DRB account."
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
||||
<input
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" disabled title="Organization profile isn't persisted server-side yet">
|
||||
Save changes
|
||||
</Button>
|
||||
<span className="text-xs text-gray-600 font-mono">
|
||||
Preview only — no backend endpoint stores this yet.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Overview"
|
||||
action={
|
||||
plan ? (
|
||||
<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name} plan</Badge>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-16" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6">
|
||||
<StatTile label="Nodes" value={nodes.length} />
|
||||
<StatTile label="Members" value={memberCount ?? "—"} />
|
||||
<StatTile
|
||||
label="Status"
|
||||
value={sub ? sub.status.replace("_", " ") : "—"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm">
|
||||
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Manage plan & billing →
|
||||
</Link>
|
||||
<Link href="/settings/members" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Manage members →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
||||
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
||||
Delete organization
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled title="Not available in this build — contact support">
|
||||
Transfer ownership
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SettingsIndexPage() {
|
||||
redirect("/settings/organization");
|
||||
}
|
||||
Reference in New Issue
Block a user