// dashboard.jsx — the signed-in customer dashboard and its per-instance card. // Loaded from index.html as an ordered `text/babel` classic script (position 5 of 8). // Classic scripts share one global scope — see docs/engineering/Patterns.md §9. // ─── Customer Dashboard ────────────────────────────────────────────────────── function Dashboard() { const [user, setUser] = useState(null); const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(true); const [showUpgrade, setShowUpgrade] = useState(false); const [planLoading, setPlanLoading] = useState(false); const [billingMsg, setBillingMsg] = useState(""); const [usage, setUsage] = useState(null); const fetchData = useCallback(async () => { try { const [u, inst, usageData] = await Promise.all([ apiCall("/auth/me"), apiCall("/instances"), apiCall("/billing/usage").catch(() => null), ]); setUser(u); setInstances(inst.instances || []); if (usageData) setUsage(usageData); } catch { window.location.hash = "#/login"; } finally { setLoading(false); } }, []); useEffect(() => { fetchData(); const interval = setInterval(fetchData, 15000); return () => clearInterval(interval); }, [fetchData]); const handleLogout = async () => { // Only the server can clear the HttpOnly session cookie, so wait for it // before leaving: navigating first could race a cookie-authenticated // request against the clear. A failure is logged but never blocks the // local logout. try { const res = await fetch(`${API_BASE}/auth/logout`, { method: "POST", credentials: "same-origin" }); if (!res.ok) console.warn("logout failed: HTTP", res.status); } catch (err) { console.warn("logout failed:", err); } localStorage.removeItem("zs_token"); window.location.hash = "#/"; }; const handleChangePlan = async (planId) => { if (planLoading) return; setPlanLoading(true); setBillingMsg(""); try { const res = await apiCall("/billing/plan", { method: "PUT", body: JSON.stringify({ plan: planId }), }); if (res.status === "updated") { setBillingMsg(`Plan changed to ${res.plan}. Refreshing…`); await fetchData(); setTimeout(() => setBillingMsg(""), 3000); } else { setBillingMsg("You are already on this plan."); setTimeout(() => setBillingMsg(""), 2000); } } catch (e) { setBillingMsg("Failed to change plan: " + e.message); } finally { setPlanLoading(false); } }; const handleManageBilling = async () => { try { const res = await apiCall("/billing/portal"); if (!res.available) { setBillingMsg(res.message || "Billing portal is not available."); setTimeout(() => setBillingMsg(""), 4000); return; } // /billing/portal is a read-only state endpoint and never returns a // checkout URL — one has to be asked for explicitly. Without this the // button was a dead end, which an awaiting-payment customer cannot // afford: paying is the only way to lift their device cap (#583). const checkout = await apiCall("/billing/checkout", { method: "POST" }); if (checkout.checkout_url) { window.open(checkout.checkout_url, "_blank", "noopener"); } else { setBillingMsg("Could not start checkout. Please try again."); setTimeout(() => setBillingMsg(""), 4000); } } catch (e) { setBillingMsg("Billing: " + e.message); setTimeout(() => setBillingMsg(""), 4000); } }; if (loading) { return (
Loading...
); } return (
{/* Top nav */} {/* Content */}

Your Instances

{user?.company_name || "Your organization"} — {user?.plan_name || "Business"} plan

{instances.length === 0 ? (
🚀

Your server is being provisioned

This usually takes less than 60 seconds. The page will refresh automatically when your instance is ready.

) : ( instances.map((inst) => ( )) )} {/* Billing section */}

Billing & Plan

{/* ── Device Usage Meter (Opportunity #4) ── */} {usage && (
Device Usage {usage.device_count} / {usage.max_devices === 0 ? "∞" : usage.max_devices} devices
{[ [usage.mac_count, "Mac"], [usage.ios_count, "iOS"], [usage.linux_count, "Linux"], [usage.android_count, "Android"], ].filter(([n]) => n > 0).map(([n, label]) => n + " " + label).join(" · ") || "No devices enrolled"} {usage.at_limit && Limit reached — upgrade to enroll more} {usage.near_limit && !usage.at_limit && Approaching limit}
{/* The cap above is the free tier's while a paid plan is unpaid — say so, or it just looks like the plan isn't being honoured. */} {usage.awaiting_payment && (
Your {user?.plan_name || usage.plan} plan's device limit applies once your first payment is confirmed. Until then the free plan's limit is in effect.
)}
)}
Current Plan
{user?.plan_name || "Business"}
Status
{user?.subscription_status === "trialing" ? "Free Trial" : user?.subscription_status === "active" ? "Active" : "Pending"}
Next Invoice
{user?.trial_ends_at ? new Date(user.trial_ends_at).toLocaleDateString() : "—"}
{billingMsg && (
{billingMsg}
)} {showUpgrade && (
{PLANS.map((plan) => (
{plan.popular && user?.plan !== plan.id && (
POPULAR
)}
{plan.name}
{plan.devices}
{plan.isFree ? ( Free ) : ( <>{plan.currency}{plan.price}/mo )}
{user?.plan === plan.id ? (
✓ Current plan
) : ( )}
))}
)}
); } // ─── Instance Card ─────────────────────────────────────────────────────────── function InstanceCard({ instance: inst, onRefresh }) { const [showCreds, setShowCreds] = useState(false); const [copied, setCopied] = useState(""); // admin_password / api_key are stripped from the /instances list. Fetch them // only for an explicit reveal or dashboard handoff, not on every page load. const [creds, setCreds] = useState(null); const [rotating, setRotating] = useState(false); const [rotationMsg, setRotationMsg] = useState(""); const loadCreds = async () => { if (creds) return creds; const next = await apiCall(`/instances/${inst.id}/credentials`); setCreds(next); return next; }; // rotateAPIKey invalidates the current key and recreates the MDM container on // the new one. The platform DB, the instance .env, and the running container // converge on the new key (#718); the regression test in // api_key_rotation_regression_test.go proves the three platform↔tenant paths // keep authenticating after a rotation (#605). Confirmed because every // integration holding the old key (CLI, Terraform, billing sync, vendor CSR) // must be updated before the next call. // // The backend's handleRotateInstanceAPIKey also has a recovery path: when the // instance is in its post-rotation-error state (status_detail prefixed with // postRotationHealthFailurePrefix), a POST retries the container recreate // without minting a new key. isPostRotationError surfaces that state so the // dashboard can offer the retry, and confirmForRotation() picks a message // that matches what the backend will actually do. const isPostRotationError = () => inst.status === "error" && !!inst.status_detail && inst.status_detail.startsWith("post-rotation health check failed"); const confirmForRotation = () => isPostRotationError() ? confirm("Retry rotation recovery? This re-runs the container recreate without minting a new key.") : confirm("Rotate this instance's API key? The current key stops working immediately, the MDM container restarts briefly, and anything using the old key (CLI, Terraform, billing sync, push-cert signing) must be updated to the new one."); const rotateAPIKey = async () => { if (rotating) return; if (!confirmForRotation()) return; setRotating(true); setRotationMsg(""); try { await apiCall(`/instances/${inst.id}/rotate-api-key`, { method: "POST" }); // The rotation succeeded — the old key is dead. Drop the cached creds so a // later reveal re-fetches; the credentials GET below is the fast path that // keeps the user from having to toggle. If that GET fails (transient // network, brief restart), the rotation still stands and the message // says so, rather than reporting a failure that didn't happen — and the // stale dead key is never left on screen. setCreds(null); try { const next = await apiCall(`/instances/${inst.id}/credentials`); setCreds(next); setRotationMsg("API key rotated. Copy the new key above — the old one no longer works."); setTimeout(() => setRotationMsg(""), 8000); } catch (credErr) { setRotationMsg("API key rotated, but the new key could not be fetched. Close and re-open credentials to see it."); } } catch (e) { setRotationMsg(e.message || "Rotation failed."); } finally { setRotating(false); } }; const copy = (text, label) => { navigator.clipboard.writeText(text); setCopied(label); setTimeout(() => setCopied(""), 2000); }; const statusLabels = { provisioning: "Provisioning…", active: "Active", suspended: "Suspended", pending_payment: "Awaiting Payment", }; return (
MDM

{inst.slug}

{statusLabels[inst.status] || inst.status}
{inst.status === "active" && ( )}
{inst.status === "error" && inst.status_detail && (
Provisioning failed:{" "} {inst.status_detail} {isPostRotationError() && (
{rotationMsg && ( {rotationMsg} )}
)}
)}
copy(inst.server_url, "url")} copied={copied === "url"} /> copy(`${inst.server_url}/enroll`, "enroll")} copied={copied === "enroll"} />
{showCreds && (
copy(inst.admin_email, "admin_email")} copied={copied === "admin_email"} /> copy(creds?.admin_password || "", "admin_pass")} copied={copied === "admin_pass"} /> copy(creds?.api_key || "", "api_key")} copied={copied === "api_key"} /> {inst.status === "active" && (
{rotationMsg && ( {rotationMsg} )}
)}
These are the initial credentials set during provisioning. Change the password after your first login.
)}
); }