ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { usePathname } from "next/navigation";
/* The console's left rail: where you are, who you are, and the way out. Plain anchors rather
* than Next links on purpose — every page under /ops is force-dynamic and behind a cookie, and a
* prefetched copy fetched before a state change would be the stale one. */
const NAV = [
{ href: "/ops", label: "Overview" },
{ href: "/ops/facilities", label: "Facilities" },
{ href: "/ops/controls", label: "Controls" },
{ href: "/ops/security", label: "Your sign-in" },
];
export function Rail({ name, email, role, viaSso, totpEnabled }: { name: string; email: string; role: string; viaSso: boolean; totpEnabled: boolean }) {
const path = usePathname() || "/ops";
const active = (href: string) => (href === "/ops" ? path === "/ops" : path.startsWith(href));
return (
<aside style={{ width: 212, flex: "none", borderRight: "2px solid var(--color-text)", background: "var(--color-surface)", padding: "20px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 16px 18px" }}>
<div style={{ width: 14, height: 14, background: "var(--color-accent)" }} />
<b style={{ fontSize: 17, fontWeight: 800 }}>ThreadCount ops</b>
</div>
<div style={{ padding: "0 16px 14px", fontSize: 11.5, color: "var(--color-neutral-600)", borderBottom: "1px solid var(--color-divider)", marginBottom: 10 }}>
<b style={{ display: "block", color: "var(--color-neutral-800)", fontWeight: 600 }}>Signed in as {name}</b>
{email} · {role.toLowerCase()} · {viaSso ? "single sign-on" : "password"}
</div>
<nav style={{ display: "flex", flexDirection: "column" }} aria-label="Console">
{NAV.map((n) => (
<a key={n.href} href={n.href} aria-current={active(n.href) ? "page" : undefined}
style={{ padding: "9px 16px", fontWeight: 600, textDecoration: "none", color: active(n.href) ? "var(--color-text)" : "var(--color-neutral-700)", background: active(n.href) ? "var(--color-neutral-200)" : "transparent", borderLeft: `3px solid ${active(n.href) ? "var(--color-accent)" : "transparent"}` }}>
{n.label}
</a>
))}
</nav>
{!totpEnabled && (
<div style={{ margin: "14px 12px 0", padding: "8px 10px", borderLeft: "4px solid var(--color-accent)", background: "var(--color-bg)", fontSize: 12, lineHeight: 1.5 }}>
Second factor not yet enrolled. <a href="/ops/security" style={{ fontWeight: 700 }}>Set it up.</a>
</div>
)}
<form action="/api/ops/auth/logout" method="post" style={{ padding: "18px 16px 0" }}>
<button className="btn" type="submit" style={{ width: "100%" }}>Sign out</button>
</form>
</aside>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
/* The switches and the demo reset. Each control posts one action and reloads, so what the page
* shows is always what the server holds — the row, with the environment's override on top. */
type Sw = { signupsDisabled: boolean; demoDisabled: boolean; plansLive: boolean; signupsByEnv: boolean; demoByEnv: boolean; plansByEnv: boolean; updatedAt: string | null };
export default function Controls({ sw, owner, demoAgoMin }: { sw: Sw; owner: boolean; demoAgoMin: number | null }) {
const [err, setErr] = useState("");
const [busy, setBusy] = useState("");
async function act(action: string, extra: Record<string, unknown> = {}) {
setBusy(action + (extra.key || "")); setErr("");
try {
const r = await fetch("/api/ops/controls", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, ...extra }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return; }
window.location.reload();
} catch {
setErr("No connection — check the network and try again.");
} finally {
setBusy("");
}
}
const row = (label: string, state: string, locked: boolean, button: React.ReactNode) => (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700 }}>{label}</div>
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)" }}>{state}{locked ? " · locked by the environment on the box" : ""}</div>
</div>
{button}
</div>
);
return (
<div className="tc-panel-body" style={{ fontSize: 13 }}>
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginBottom: 8 }}>{err}</div>}
{row("Sign-ups", sw.signupsDisabled || sw.signupsByEnv ? "Closed — the create-account form is hidden and the endpoint refuses" : "Open", sw.signupsByEnv,
<button type="button" className="btn" disabled={!owner || sw.signupsByEnv || !!busy} onClick={() => act("switch", { key: "signupsDisabled", value: !sw.signupsDisabled })}>
{sw.signupsDisabled ? "Open sign-ups" : "Close sign-ups"}
</button>)}
{row("Demo", sw.demoDisabled || sw.demoByEnv ? "Out of service — /demo says so and the entry endpoint answers 404" : `In service${demoAgoMin === null ? "" : ` · rebuilt ${demoAgoMin} min ago`}`, sw.demoByEnv,
<button type="button" className="btn" disabled={!owner || sw.demoByEnv || !!busy} onClick={() => act("switch", { key: "demoDisabled", value: !sw.demoDisabled })}>
{sw.demoDisabled ? "Put demo in service" : "Take demo out of service"}
</button>)}
{row("Plans", sw.plansLive || sw.plansByEnv ? "Live — new sign-ups start on Hosted Small and Settings shows the Plan tab" : "Off — every new facility is grandfathered: free, with everything, for good", sw.plansByEnv,
<button type="button" className="btn" disabled={!owner || sw.plansByEnv || !!busy} onClick={() => { if (sw.plansLive || window.confirm("Turn plans on? Every facility created from now on is capped at 60 staff records until it buys, and the sixty days' notice to existing rooms must already have run.")) void act("switch", { key: "plansLive", value: !sw.plansLive }); }}>
{sw.plansLive ? "Turn plans off" : "Turn plans on"}
</button>)}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 0" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700 }}>Reset the demo now</div>
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)" }}>Drops and rebuilds the shared facility, as the twenty-minute clock does. Visitors mid-session see fresh data.</div>
</div>
<button type="button" className="btn" disabled={!!busy} onClick={() => { if (window.confirm("Rebuild the demo facility now?")) void act("demo.reset"); }}>{busy === "demo.reset" ? "Rebuilding…" : "Reset demo"}</button>
</div>
{!owner && <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 6 }}>Switches are an owners to flip.</div>}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { requireOperator } from "@/lib/ops/session";
import { overview } from "@/lib/ops/projections";
import { switches } from "@/lib/switches";
import Controls from "./Controls";
/* Controls: the two platform switches and the demo reset. Reads the switch row through the
* product's own reader so what is shown is exactly what the sign-up route and the demo see —
* the row, with the environment's override on top. */
export const dynamic = "force-dynamic";
export default async function OpsControls() {
const op = await requireOperator();
const [sw, o] = await Promise.all([switches(), overview()]);
const fmt = (d: Date) => d.toLocaleString("en-AU", { timeZone: "Australia/Brisbane", dateStyle: "medium", timeStyle: "short" });
return (
<>
<div className="page-head">
<div>
<div className="sec" style={{ border: 0, padding: 0 }}>Controls</div>
<h1 className="h1">Switches</h1>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
{sw.row.updatedAt ? `Last changed ${fmt(sw.row.updatedAt)}` : "Never changed from here"} · every change is written to the trail
</div>
</div>
</div>
<div className="tc-panel" style={{ marginTop: 20, maxWidth: 720 }}>
<div className="tc-panel-head"><span>Platform</span><span className="tc-panel-aside">{o.facilities} facilities · {o.live} live</span></div>
<Controls
sw={{ signupsDisabled: sw.row.signupsDisabled, demoDisabled: sw.row.demoDisabled, plansLive: sw.row.plansLive, signupsByEnv: sw.signupsByEnv, demoByEnv: sw.demoByEnv, plansByEnv: sw.plansByEnv, updatedAt: sw.row.updatedAt?.toISOString() ?? null }}
owner={op.role === "OWNER"}
demoAgoMin={o.demoRebuiltAgoMin}
/>
</div>
<div className="tc-panel" style={{ marginTop: 24, maxWidth: 720 }}>
<div className="tc-panel-head"><span>Deleting a facility</span></div>
<div className="tc-panel-body" style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>
Lives on the facilitys own page, at the bottom. Owner, the facilitys exact name typed, and a live code from your second factor. It cannot be undone.
</div>
</div>
</>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
/* The one thing on the console that cannot be undone. Owner only (the server checks too), the
* facility's exact name typed, and a live code from the operator's own second factor. */
export default function Danger({ facilityId, name, owner, totpEnabled }: { facilityId: string; name: string; owner: boolean; totpEnabled: boolean }) {
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState("");
const [code, setCode] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true); setErr("");
try {
const r = await fetch("/api/ops/controls", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "facility.delete", facilityId, confirm, code }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return; }
window.location.assign("/ops/facilities?deleted=1");
} catch {
setErr("No connection — check the network and try again.");
} finally {
setBusy(false);
}
}
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 4 };
if (!owner) return <div className="tc-panel-body" style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Only an owner can delete a facility.</div>;
if (!totpEnabled) return <div className="tc-panel-body" style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Enrol a second factor under <a href="/ops/security">Your sign-in</a> before deleting anything.</div>;
if (!open) {
return (
<div className="tc-panel-body" style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, color: "var(--color-neutral-700)" }}>Deletes every record, image and account belonging to this facility. There is no undo beyond the database dumps on the box.</div>
<button type="button" className="btn" onClick={() => setOpen(true)}>Delete facility</button>
</div>
);
}
return (
<form onSubmit={submit} className="tc-panel-body" style={{ fontSize: 13, display: "grid", gap: 10 }}>
<div>
<label htmlFor="danger-confirm" style={label}>Type the facility name exactly</label>
<input id="danger-confirm" className="input" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={name} autoComplete="off" autoFocus />
</div>
<div>
<label htmlFor="danger-code" style={label}>Code from your authenticator</label>
<input id="danger-code" className="input" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} style={{ maxWidth: 160 }} />
</div>
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontWeight: 600, color: "var(--color-accent-700)" }}>{err}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" className="btn btn-primary" disabled={busy || confirm.trim() !== name || code.replace(/\s+/g, "").length < 6}>{busy ? "Deleting…" : "Delete this facility"}</button>
<button type="button" className="btn" onClick={() => { setOpen(false); setConfirm(""); setCode(""); setErr(""); }} disabled={busy}>Cancel</button>
</div>
</form>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import type { PlanView } from "@/lib/ops/projections";
/* The billing desk for one facility. What the columns say, what they mean today, and the five
* acts from lib/ops/controls.ts. No money moves here: an invoice is raised in the accounting tool
* and its payment recorded with "Record a payment". Every act reloads, so the panel always shows
* what the server holds. */
const PLAN_OPTS: [string, string][] = [["", "No plan set"], ["hosted_small", "Hosted Small · free, 60 staff records"], ["hosted_facility", "Hosted Facility"], ["health_service", "Health Service"], ["private", "Private"]];
const STATE_WORDS: Record<PlanView["state"], string> = {
grandfathered: "Free — grandfathered, everything, for good",
free: "Free",
trial: "Trial",
active: "Paid",
grace: "Grace — period ended, still writable",
read_only: "Read-only",
};
export default function Plan({ facilityId, view, owner }: { facilityId: string; view: PlanView; owner: boolean }) {
const [editing, setEditing] = useState(false);
const [p, setP] = useState(view.plan);
const [n, setN] = useState(view.planNote);
const [g, setG] = useState(view.grandfathered);
const [err, setErr] = useState("");
const [busy, setBusy] = useState("");
async function act(body: Record<string, unknown>, tag: string) {
setBusy(tag); setErr("");
try {
const r = await fetch("/api/ops/controls", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "plan", facilityId, ...body }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return; }
window.location.reload();
} catch {
setErr("No connection — check the network and try again.");
} finally {
setBusy("");
}
}
const fmt = (d: Date | string | null) => d ? new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" }) : "—";
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 4 };
const dl: React.CSSProperties = { display: "grid", gridTemplateColumns: "auto 1fr", gap: "6px 16px", fontSize: 13, margin: "10px 0 0" };
const dt: React.CSSProperties = { color: "var(--color-neutral-600)" };
const dd: React.CSSProperties = { margin: 0, fontWeight: 600, fontVariantNumeric: "tabular-nums" };
const b = (text: string, body: Record<string, unknown>, opts: { confirm?: string; disabled?: boolean } = {}) => (
<button type="button" className="btn" disabled={!!busy || opts.disabled} onClick={() => { if (!opts.confirm || window.confirm(opts.confirm)) void act(body, text); }}>{busy === text ? "…" : text}</button>
);
return (
<div className="tc-panel-body" style={{ fontSize: 13 }}>
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontWeight: 600, color: "var(--color-accent-700)", marginBottom: 8 }}>{err}</div>}
<div style={{ display: "flex", alignItems: "baseline", gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 16 }}>{view.label}{view.grandfathered ? " · grandfathered" : ""}</div>
<div style={{ color: view.readOnly ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: view.readOnly ? 700 : 400 }}>{STATE_WORDS[view.state]}</div>
</div>
{!editing && <button type="button" className="btn" onClick={() => setEditing(true)}>Edit</button>}
</div>
<dl style={dl}>
<dt style={dt}>Recorded as</dt><dd style={dd}>{view.planStatus}</dd>
<dt style={dt}>Trial ends</dt><dd style={dd}>{fmt(view.trialEndsAt)}</dd>
<dt style={dt}>Paid until</dt><dd style={dd}>{fmt(view.paidUntil)}</dd>
{view.graceEndsAt && <><dt style={dt}>Grace ends</dt><dd style={dd}>{fmt(view.graceEndsAt)}</dd></>}
<dt style={dt}>Staff ceiling</dt><dd style={dd}>{view.maxStaff === null ? "none" : view.maxStaff}</dd>
<dt style={dt}>Billing contact</dt><dd style={dd}>on the record reveal above</dd>
</dl>
{view.planNote && <div style={{ color: "var(--color-neutral-700)", marginTop: 10, whiteSpace: "pre-wrap" }}>{view.planNote}</div>}
{editing && (
<form onSubmit={(e) => { e.preventDefault(); void act({ act: "set", plan: p, planNote: n, grandfathered: g }, "Save"); }} style={{ display: "grid", gap: 10, marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--color-divider)" }}>
<div>
<label htmlFor="plan-code" style={label}>Plan</label>
<select id="plan-code" className="input" value={p} onChange={(e) => setP(e.target.value)}>
{PLAN_OPTS.map(([v, t]) => <option key={v} value={v}>{t}</option>)}
</select>
</div>
<div>
<label htmlFor="plan-note" style={label}>Note</label>
<textarea id="plan-note" className="input" rows={3} maxLength={400} value={n} onChange={(e) => setN(e.target.value)} placeholder="PO number, agreed terms, renewal — no contacts here; reveal those above." />
</div>
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13 }}>
<input type="checkbox" checked={g} onChange={(e) => setG(e.target.checked)} disabled={!owner} />
<span>Grandfathered free with everything, for good{owner ? "" : " (an owners to change)"}</span>
</label>
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" className="btn btn-primary" disabled={!!busy}>{busy === "Save" ? "Saving…" : "Save"}</button>
<button type="button" className="btn" onClick={() => { setEditing(false); setP(view.plan); setN(view.planNote); setG(view.grandfathered); setErr(""); }} disabled={!!busy}>Cancel</button>
</div>
</form>
)}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--color-divider)" }}>
{b(view.state === "trial" ? "Extend trial 30 days" : "Start 60-day trial", { act: "trial", days: view.state === "trial" ? 30 : 60 })}
{b("Record a payment · 12 months", { act: "paid", months: 12 }, { confirm: "Record a payment covering twelve months? The facility becomes Paid from today, or from the end of the current paid period." })}
{view.readOnly
? b("Make writable", { act: "readonly", on: false })
: b("Set read-only", { act: "readonly", on: true }, { confirm: "Set this facility read-only? Every write is refused until it is lifted; reports, exports and the backup keep working." })}
{(view.planStatus === "trial" || view.planStatus === "active") && b("Set free", { act: "free" }, { confirm: "Back to free on the current plan, clearing the trial and paid dates?" })}
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 8 }}>
Nothing here moves money. Raise the invoice in the accounting tool, then record it. A period that ends runs a fortnight of grace before read-only; grandfathered rooms never go read-only on their own.
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
/* The reveal control: a button, a reason, a confirmation. On success the page reloads and the
* server renders the contacts through the reveal role — the values never travel in this response. */
export default function Reveal({ facilityId, minutes }: { facilityId: string; minutes: number }) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true); setErr("");
try {
const r = await fetch("/api/ops/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ facilityId, reason }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return; }
window.location.reload();
} catch {
setErr("No connection — check the network and try again.");
} finally {
setBusy(false);
}
}
if (!open) {
return <button type="button" className="btn" onClick={() => setOpen(true)} style={{ marginTop: 12 }}>Reveal contacts</button>;
}
return (
<form onSubmit={submit} style={{ marginTop: 12, display: "grid", gap: 8 }}>
<label htmlFor="reveal-reason" style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
Reason goes in the trail and the email
</label>
<textarea
id="reveal-reason"
className="input"
rows={3}
maxLength={400}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Coordinator emailed support about a restore; calling back."
autoFocus
required
/>
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)" }}>{err}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" className="btn btn-primary" disabled={busy || reason.trim().length < 8}>{busy ? "Revealing…" : `Reveal for ${minutes} minutes`}</button>
<button type="button" className="btn" onClick={() => { setOpen(false); setErr(""); }} disabled={busy}>Cancel</button>
</div>
</form>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { notFound } from "next/navigation";
import { requireOperator } from "@/lib/ops/session";
import { facility } from "@/lib/ops/projections";
import { revealedContacts, REVEAL_MINUTES } from "@/lib/ops/reveal";
import Reveal from "./Reveal";
import Plan from "./Plan";
import Danger from "./Danger";
/* One facility: configuration, counts, activity. Contacts are masked — the ops_ro role cannot read
* them, and the projection never asks. They appear only while this operator holds an unexpired
* reveal grant, read through the separate reveal role (lib/ops/reveal.ts).
* Nothing about a wearer is reachable from here at any level. */
export const dynamic = "force-dynamic";
const dl: React.CSSProperties = { display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px", fontSize: 13, margin: 0 };
const dt: React.CSSProperties = { color: "var(--color-neutral-600)" };
const dd: React.CSSProperties = { margin: 0, fontWeight: 600, fontVariantNumeric: "tabular-nums" };
const masked: React.CSSProperties = { fontFamily: "ui-monospace, Menlo, monospace", letterSpacing: "0.02em", color: "var(--color-neutral-700)" };
export default async function OpsFacility({ params }: { params: Promise<{ id: string }> }) {
const op = await requireOperator();
const { id } = await params;
const f = await facility(id);
if (!f) notFound();
const rv = f.isDemo ? null : await revealedContacts(op.id, id);
const fmt = (d: Date) => d.toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
const hm = (d: Date) => d.toLocaleTimeString("en-AU", { timeZone: "Australia/Brisbane", hour: "2-digit", minute: "2-digit" });
const n = (x: number) => x.toLocaleString();
const c = f.counts;
return (
<>
<div className="page-head">
<div>
<div className="sec" style={{ border: 0, padding: 0 }}>Facility{f.isDemo ? " · demo" : ""}</div>
<h1 className="h1">{f.name}</h1>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
Created {fmt(f.createdAt)} · {f.timezone} · rev {n(f.rev)} · {f.backupAgeDays === null ? "never exported" : `last export ${f.backupAgeDays} days ago`}
</div>
</div>
<a className="btn" href="/ops/facilities">All facilities</a>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 24, marginTop: 20 }}>
<div className="tc-panel">
<div className="tc-panel-head"><span>Contacts</span><span className="tc-panel-aside">{rv ? `revealed until ${hm(rv.expiresAt)}` : "masked"}</span></div>
<div className="tc-panel-body" style={{ fontSize: 13 }}>
{rv ? (
<>
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Coordinator</div><span style={{ fontWeight: 600 }}>{rv.coordinator || "—"}</span></div>
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Email</div><span style={{ fontWeight: 600 }}>{rv.coordinatorEmail || "—"}</span></div>
<div style={{ padding: "6px 0" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Phone</div><span style={{ fontWeight: 600 }}>{rv.coordinatorPhone || "—"}</span></div>
<div style={{ borderTop: "2px solid var(--color-text)", marginTop: 12, paddingTop: 10, fontSize: 12.5, color: "var(--color-neutral-700)", display: "flex", gap: 8 }}>
<span style={{ color: "var(--color-accent)", fontWeight: 800 }}></span>
<span>{`Revealed ${hm(rv.at)} for ${REVEAL_MINUTES} minutes, in the trail and emailed. Reason: ${rv.reason}`}</span>
</div>
</>
) : (
<>
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Coordinator</div><span style={masked}></span></div>
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Email</div><span style={masked}>@</span></div>
<div style={{ padding: "6px 0" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Phone</div><span style={masked}> </span></div>
<div style={{ borderTop: "2px solid var(--color-text)", marginTop: 12, paddingTop: 10, fontSize: 12.5, color: "var(--color-neutral-700)", display: "flex", gap: 8 }}>
<span style={{ color: "var(--color-accent)", fontWeight: 800 }}></span>
<span>The console&rsquo;s database role cannot read these. Revealing them is a separate act: it asks for a reason, lasts thirty minutes, is written to the operator trail and emails you a copy. Nothing about a wearer can be revealed at any level.</span>
</div>
{!f.isDemo && <Reveal facilityId={f.id} minutes={REVEAL_MINUTES} />}
</>
)}
</div>
</div>
<div className="tc-panel">
<div className="tc-panel-head"><span>Configuration</span></div>
<dl className="tc-panel-body" style={dl}>
<dt style={dt}>Ceiling</dt><dd style={dd}>{f.config.capSets} sets held at any time</dd>
<dt style={dt}>Starting kit</dt><dd style={dd}>{f.config.initialSets} sets</dd>
<dt style={dt}>Staff groups</dt><dd style={dd}>{f.config.groups} named · {f.config.fteGroups} on the FTE table · {f.config.kitGroups} on the starting kit</dd>
<dt style={dt}>Reorder default</dt><dd style={dd}>{f.config.defaultReorder}</dd>
<dt style={dt}>GL account</dt><dd style={dd}>{f.config.glAccountSet ? "set" : "not set"}</dd>
<dt style={dt}>Journal line</dt><dd style={dd}>{f.config.journalDesc}</dd>
<dt style={dt}>Barcode lookup</dt><dd style={dd}>{f.config.barcodeLookup ? "on" : "off"}</dd>
<dt style={dt}>Slip organisation</dt><dd style={dd}>{f.config.slipOrgSet ? "set" : "not set"}</dd>
<dt style={dt}>Single sign-on</dt><dd style={dd}>{`${f.config.sso}${f.config.ssoStaff ? " · staff too" : ""}`}</dd>
</dl>
</div>
</div>
<div className="tc-panel" style={{ marginTop: 24 }}>
<div className="tc-panel-head"><span>Counts</span><span className="tc-panel-aside">no records readable</span></div>
<dl className="tc-panel-body" style={{ ...dl, gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))" }}>
{/* Whole phrases, not `{value} word`: React separates adjacent text and expressions with
comment nodes, so "1 admin" would render as "1<!-- --> admin" and never match a grep. */}
<div><dt style={dt}>Coordinators</dt><dd style={dd}>{`${n(c.users)} · ${c.admins} admin · ${c.usersWith2fa} with 2FA${c.inactiveUsers ? ` · ${c.inactiveUsers} inactive` : ""}`}</dd></div>
<div><dt style={dt}>Staff register</dt><dd style={dd}>{`${n(c.staff)} · ${n(c.activeStaff)} active`}</dd></div>
<div><dt style={dt}>Staff accounts</dt><dd style={dd}>{`${n(c.staffAccounts)} · ${n(c.accountsSeen7d)} seen this week`}</dd></div>
<div><dt style={dt}>Catalogue</dt><dd style={dd}>{n(c.items)}</dd></div>
<div><dt style={dt}>Issues</dt><dd style={dd}>{n(c.issues)}</dd></div>
<div><dt style={dt}>Orders</dt><dd style={dd}>{n(c.orders)}</dd></div>
<div><dt style={dt}>Requests</dt><dd style={dd}>{n(c.requests)}</dd></div>
<div><dt style={dt}>Events, 24h</dt><dd style={dd}>{n(c.events24h)}</dd></div>
</dl>
</div>
{!f.isDemo && (
<>
<div className="tc-panel" style={{ marginTop: 24 }}>
<div className="tc-panel-head"><span>Plan</span><span className="tc-panel-aside">the billing desk · every act in the trail</span></div>
<Plan facilityId={f.id} view={f.planView} owner={op.role === "OWNER"} />
</div>
<div className="tc-panel" style={{ marginTop: 24, borderColor: "var(--color-accent)" }}>
<div className="tc-panel-head"><span>Danger zone</span><span className="tc-panel-aside">cannot be undone</span></div>
<Danger facilityId={f.id} name={f.name} owner={op.role === "OWNER"} totpEnabled={op.totpEnabled} />
</div>
</>
)}
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { requireOperator } from "@/lib/ops/session";
import { facilities } from "@/lib/ops/projections";
/* Every facility: counts, dates, state. No customer records are reachable from this screen, and
* no contact appears on it at all — a reveal is a separate, trailed act on the facility's own page. */
export const dynamic = "force-dynamic";
const PILL: Record<string, { text: string; color: string }> = {
live: { text: "Live", color: "#2f6b4f" },
dormant: { text: "Dormant", color: "var(--color-neutral-600)" },
unconfigured: { text: "Never set up", color: "var(--color-neutral-600)" },
demo: { text: "Demo", color: "var(--color-neutral-600)" },
};
function Pill({ state }: { state: string }) {
const p = PILL[state] || PILL.dormant;
return <span style={{ display: "inline-block", fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", textTransform: "uppercase", padding: "2px 7px", border: `1.5px solid ${p.color}`, color: p.color }}>{p.text}</span>;
}
export default async function OpsFacilities() {
await requireOperator();
const rows = await facilities();
const real = rows.filter((f) => !f.isDemo);
const demo = rows.filter((f) => f.isDemo);
const fmt = (d: Date) => d.toLocaleDateString("en-AU", { day: "numeric", month: "short" });
const n = (x: number) => x.toLocaleString();
const th = (t: string, right = false) => <th style={{ textAlign: right ? "right" : "left", fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)", padding: "9px 16px", borderBottom: "2px solid var(--color-text)", whiteSpace: "nowrap" }}>{t}</th>;
const td = (c: React.ReactNode, right = false) => <td style={{ textAlign: right ? "right" : "left", padding: "9px 16px", borderBottom: "1px solid var(--color-divider)", whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums" }}>{c}</td>;
return (
<>
<div className="page-head">
<div>
<div className="sec" style={{ border: 0, padding: 0 }}>Facilities</div>
<h1 className="h1">Every facility</h1>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>Counts and dates only. No customer records are reachable from this screen.</div>
</div>
</div>
<div className="tc-panel" style={{ marginTop: 20 }}>
<div className="tc-panel-head"><span>{real.length} facilities</span><span className="tc-panel-aside">{demo.length ? "demo shown last" : "no demo facility"}</span></div>
<div style={{ overflowX: "auto" }}>
<table style={{ borderCollapse: "collapse", width: "100%", fontSize: 13 }}>
<thead><tr>{th("Facility")}{th("State")}{th("Plan")}{th("Created")}{th("Users", true)}{th("Staff", true)}{th("Items", true)}{th("Issues", true)}{th("Requests", true)}{th("rev", true)}{th("Backup")}</tr></thead>
<tbody>
{[...real, ...demo].map((f) => (
<tr key={f.id}>
{td(<a href={`/ops/facilities/${f.id}`} style={{ fontWeight: 700 }}>{f.name}</a>)}
{td(<Pill state={f.state} />)}
{td(f.isDemo ? "—" : (
<span style={{ color: f.planView.readOnly ? "var(--color-accent-700)" : undefined, fontWeight: f.planView.readOnly ? 700 : undefined }}>
{f.planView.grandfathered ? `${f.planView.label} · grandfathered` : `${f.planView.label} · ${f.planView.state.replace("_", "-")}`}
</span>
))}
{td(f.isDemo ? "—" : fmt(f.createdAt))}
{td(n(f.users), true)}{td(n(f.staff), true)}{td(n(f.items), true)}{td(n(f.issues), true)}{td(n(f.requests), true)}{td(f.isDemo ? "—" : n(f.rev), true)}
{td(f.isDemo ? "—" : f.backupAgeDays === null ? <span style={{ color: "#8a6218", fontWeight: 700 }}>never</span> : f.backupAgeDays > 7 ? <span style={{ color: "#8a6218", fontWeight: 700 }}>{f.backupAgeDays}d</span> : `${f.backupAgeDays}d`)}
</tr>
))}
{rows.length === 0 && <tr>{td("No facilities yet.")}</tr>}
</tbody>
</table>
</div>
</div>
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { currentOperator } from "@/lib/ops/session";
import { Rail } from "./Rail";
/* Everything in this group needs a signed-in operator. The sign-in page sits outside the group
* so it is reachable without one. The proxy already turned away requests with no valid tc_ops
* cookie; this re-reads the row, so a deactivated operator or a changed password ends the
* session here even if the token still verifies. */
export const dynamic = "force-dynamic";
export default async function ConsoleLayout({ children }: { children: React.ReactNode }) {
const op = await currentOperator();
if (!op) redirect("/ops/login");
return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "stretch" }}>
<Rail name={op.name} email={op.email} role={op.role} viaSso={op.viaSso} totpEnabled={op.totpEnabled} />
<main style={{ flex: 1, minWidth: 0, padding: "0 32px 32px" }}>{children}</main>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { requireOperator } from "@/lib/ops/session";
import { overview } from "@/lib/ops/projections";
import { version } from "@/lib/ops/version";
/* Overview: is everything up, how many facilities, who needs attention. Counts and dates only —
* nothing on this screen names a person or reads a record. */
export const dynamic = "force-dynamic";
const tile: React.CSSProperties = { background: "var(--color-bg)", padding: 16 };
const figure: React.CSSProperties = { fontSize: 30, fontWeight: 800, lineHeight: 1, fontVariantNumeric: "tabular-nums" };
const label: React.CSSProperties = { fontSize: 11, fontWeight: 700, letterSpacing: "0.09em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 6 };
const note: React.CSSProperties = { fontSize: 12, color: "var(--color-neutral-700)", marginTop: 2 };
export default async function OpsOverview() {
await requireOperator();
const o = await overview();
const v = version();
const stale = !!(v.deployed && v.running && v.deployed.sha !== v.running);
const fmtAt = (d: Date) => d.toLocaleString("en-AU", { timeZone: "Australia/Brisbane", dateStyle: "medium", timeStyle: "short" });
const attention = [
...o.overdueBackups.map((f) => ({ tone: "warn", who: f.name, what: f.backupAgeDays === null ? "has never exported a backup" : `has not exported a backup in ${f.backupAgeDays} days`, meta: `${f.issues.toLocaleString()} issues on record`, href: `/ops/facilities/${f.id}` })),
...o.neverIssued.map((f) => ({ tone: "dormant", who: f.name, what: "has never issued", meta: `signed up ${f.createdAt.toLocaleDateString("en-AU", { day: "numeric", month: "short" })} · ${f.staff} staff · ${f.items} items`, href: `/ops/facilities/${f.id}` })),
...o.unconfiguredList.map((f) => ({ tone: "dormant", who: f.name, what: "has no staff groups named", meta: "everyone is on manager approval until a group goes on a route", href: `/ops/facilities/${f.id}` })),
];
const drift = o.migrations.applied !== o.migrations.inRepo;
return (
<>
<div className="page-head">
<div>
<div className="sec" style={{ border: 0, padding: 0 }}>Overview</div>
<h1 className="h1">Everything, at a glance</h1>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
{o.facilities} facilities · {o.live} live · migrations {o.migrations.applied}/{o.migrations.inRepo}{drift ? " — DRIFT" : ""}
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 2, background: "var(--color-divider)", border: "2px solid var(--color-text)", marginTop: 20 }}>
<div style={tile}><div style={figure}>{o.facilities}</div><div style={label}>Facilities</div><div style={note}>{o.demo} demo, excluded below</div></div>
<div style={tile}><div style={figure}>{o.live}</div><div style={label}>Live</div><div style={note}>have issued at least once</div></div>
<div style={tile}><div style={figure}>{o.dormant + o.unconfigured}</div><div style={label}>Not yet live</div><div style={note}>{o.unconfigured} never set up</div></div>
<div style={tile}><div style={figure}>{o.signupsThisMonth}</div><div style={label}>Signups this month</div><div style={note}>{o.reachedFirstIssue} reached first issue</div></div>
</div>
<div className="tc-panel" style={{ marginTop: 24 }}>
<div className="tc-panel-head"><span>Needs attention</span><span className="tc-panel-aside">{attention.length} {attention.length === 1 ? "item" : "items"}</span></div>
{attention.length === 0 ? (
<div className="tc-panel-body" style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Nothing. Every live facility has a recent backup and every signup has issued.</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{attention.map((a, i) => (
<li key={i} style={{ display: "flex", gap: 12, alignItems: "baseline", padding: "11px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<span style={{ width: 4, alignSelf: "stretch", flex: "none", background: a.tone === "warn" ? "#8a6218" : "var(--color-neutral-600)" }} />
<span style={{ flex: 1, minWidth: 0 }}><b>{a.who}</b> {a.what}<div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{a.meta}</div></span>
<a className="btn" href={a.href} style={{ padding: "3px 9px", fontSize: 12 }}>Open</a>
</li>
))}
</ul>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 24, marginTop: 24 }}>
<div className="tc-panel">
<div className="tc-panel-head"><span>Platform</span></div>
<dl className="tc-panel-body" style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px", fontSize: 13, margin: 0 }}>
<dt style={{ color: "var(--color-neutral-600)" }}>Migrations</dt><dd style={{ margin: 0, fontWeight: 600 }}>{o.migrations.applied} applied / {o.migrations.inRepo} in repo{drift ? " — drift" : ""}</dd>
<dt style={{ color: "var(--color-neutral-600)" }}>Enquiries</dt><dd style={{ margin: 0, fontWeight: 600 }}>{o.enquiriesUnhandled} unhandled</dd>
<dt style={{ color: "var(--color-neutral-600)" }}>Demo facility</dt><dd style={{ margin: 0, fontWeight: 600 }}>{o.demoRebuiltAgoMin === null ? "none" : `rebuilt ${o.demoRebuiltAgoMin} min ago`}</dd>
{/* Whole phrases (see the facility page): React puts comment nodes between adjacent expressions. */}
<dt style={{ color: "var(--color-neutral-600)" }}>Deployed</dt><dd style={{ margin: 0, fontWeight: 600 }}>{v.deployed ? `${v.deployed.sha} · ${fmtAt(v.deployed.at)}${v.deployed.from ? ` · was ${v.deployed.from}` : ""}` : "not recorded — the deploy script predates the release file"}</dd>
<dt style={{ color: "var(--color-neutral-600)" }}>Running</dt><dd style={{ margin: 0, fontWeight: 600, color: stale ? "var(--color-accent-700)" : undefined }}>{`${v.running || "release unknown"}${stale ? " — STALE: built but never restarted" : ""} · up ${v.uptimeMin} min · node ${v.node}`}</dd>
</dl>
</div>
</div>
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useEffect, useState } from "react";
/* An operator's own second factor.
*
* Three steps, the same as a coordinator's: set up (a secret and a QR, stored but not yet in
* force), enable (prove one code from it works — only then does the password stop being enough),
* and the recovery codes, shown exactly once. Turning it off or reissuing the codes asks for the
* password, because both are privileged.
*
* The QR arrives from the server as SVG generated by the product's own QR library from a secret
* that never has to reach client-side code; rendering it as markup is the same trust as rendering
* any other server response. */
export const dynamic = "force-dynamic";
type Status = { enabled: boolean; recoveryLeft: number; viaSso: boolean };
export default function OpsSecurity() {
const [st, setSt] = useState<Status | null>(null);
const [qr, setQr] = useState("");
const [secret, setSecret] = useState("");
const [code, setCode] = useState("");
const [pw, setPw] = useState("");
const [codes, setCodes] = useState<string[] | null>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
const r = await fetch("/api/ops/auth/totp").catch(() => null);
const j = await r?.json().catch(() => null);
if (j && typeof j.enabled === "boolean") setSt(j);
}
useEffect(() => { void load(); }, []);
async function act(action: string, extra: Record<string, string> = {}) {
setBusy(true); setErr("");
try {
const r = await fetch("/api/ops/auth/totp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, ...extra }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return null; }
return j;
} catch {
setErr("No connection — check the network and try again."); return null;
} finally {
setBusy(false);
}
}
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", margin: "14px 0 6px" };
const p: React.CSSProperties = { fontSize: 13.5, color: "var(--color-neutral-800)", lineHeight: 1.6, margin: "8px 0 0" };
return (
<div style={{ maxWidth: 560, margin: "48px auto", padding: "0 16px" }}>
<div className="sec">Operations console</div>
<h1 className="h1" style={{ marginTop: 12 }}>Your sign-in</h1>
<p style={p}><a href="/ops"> Back</a></p>
{!st && <p style={p}>Loading</p>}
{st && !st.enabled && !qr && (
<>
<p style={p}>The password door has no second factor yet. Until it does, a password alone opens this console and that password has been typed into more places than it should have been.</p>
<button className="btn btn-primary" disabled={busy} style={{ marginTop: 16 }} onClick={async () => { const j = await act("setup"); if (j) { setQr(j.qr); setSecret(j.secret); } }}>Set up an authenticator</button>
</>
)}
{qr && !codes && (
<>
<p style={p}>Scan this with your authenticator app, then enter the current code to switch it on.</p>
<div style={{ marginTop: 12, width: 220, border: "2px solid var(--color-text)", background: "#fff" }} dangerouslySetInnerHTML={{ __html: qr }} />
<p style={{ ...p, fontFamily: "ui-monospace, Menlo, monospace", fontSize: 12, wordBreak: "break-all" }}>{secret}</p>
<label style={label} htmlFor="ops-totp">Code from the app</label>
<input id="ops-totp" className="input" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => { setCode(e.target.value); setErr(""); }} placeholder="000000" style={{ width: "100%" }} />
<button className="btn btn-primary" disabled={busy || !code.trim()} style={{ marginTop: 16 }} onClick={async () => { const j = await act("enable", { code }); if (j) { setCodes(j.codes); setQr(""); await load(); } }}>Turn it on</button>
</>
)}
{codes && (
<>
<p style={p}><b>Recovery codes.</b> Each works once, for the day the phone is lost. They are shown now and never again write them down somewhere that is not this screen.</p>
<pre style={{ marginTop: 12, padding: 12, border: "2px solid var(--color-text)", background: "var(--color-surface)", fontSize: 13, lineHeight: 1.7 }}>{codes.join("\n")}</pre>
<button className="btn" style={{ marginTop: 12 }} onClick={() => { setCodes(null); setCode(""); }}>I have saved these</button>
</>
)}
{st && st.enabled && !codes && (
<>
<p style={p}>A second factor is on. {st.recoveryLeft} recovery {st.recoveryLeft === 1 ? "code" : "codes"} unused.{st.viaSso ? " This session came in by single sign-on." : ""}</p>
<label style={label} htmlFor="ops-pw">Password, to change either of these</label>
<input id="ops-pw" className="input" type="password" autoComplete="current-password" value={pw} onChange={(e) => { setPw(e.target.value); setErr(""); }} style={{ width: "100%" }} />
<div style={{ display: "flex", gap: 8, marginTop: 16, flexWrap: "wrap" }}>
<button className="btn" disabled={busy || !pw} onClick={async () => { const j = await act("regenerate", { password: pw }); if (j) { setCodes(j.codes); setPw(""); await load(); } }}>New recovery codes</button>
<button className="btn" disabled={busy || !pw} onClick={async () => { const j = await act("disable", { password: pw }); if (j) { setPw(""); await load(); } }}>Turn it off</button>
</div>
</>
)}
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 12 }}>{err}</div>}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { notFound } from "next/navigation";
/* The operations console lives under /ops and is served only on ops.threadcount.tech.
*
* proxy.ts already refuses /ops on every other hostname, but the proxy is not the only guard: a
* matcher change can silently remove its coverage, so the host is checked here again, from the
* request headers, before anything under this layout renders. Same comparison as the proxy —
* exact, lower-cased, port-stripped, never `includes`.
*
* Every page under here is dynamic. ISR is not keyed by host, so a cached ops page could be
* served on threadcount.tech; `force-dynamic` on the layout keeps the whole subtree out of the
* prerender cache. */
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: { default: "ThreadCount ops", template: "%s — ThreadCount ops" },
robots: { index: false, follow: false },
};
const OPS_HOST = "ops.threadcount.tech";
export default async function OpsLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const host = (h.get("x-forwarded-host") ?? h.get("host") ?? "").split(":")[0].toLowerCase();
if (host !== OPS_HOST) notFound();
return <div className="tc-ops" role="main">{children}</div>;
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
/* The break-glass door.
*
* Single sign-on through Cloudflare Access and Authentik is the front door (page.tsx hands off to
* it); this password form is the fire escape, for the day one of those is down. So it must not
* depend on either of them — which is why there is no Turnstile here. The sitekey is bound to the
* product's domain and compiled in at build time; on this hostname the widget would refuse and
* report a generic "security check failed", discovered during the exact incident in which this
* form is needed. The route behind it rate-limits failures under its own keys instead.
*
* A full navigation on success, not a router push: /ops is server-rendered behind the cookie
* that was just set, and a prefetched copy fetched before it existed would land a signed-in
* person on the sign-in screen. */
export default function LoginForm({ ssoFailed }: { ssoFailed: boolean }) {
const [email, setEmail] = useState("");
const [pw, setPw] = useState("");
const [code, setCode] = useState("");
const [needCode, setNeedCode] = useState(false);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
setBusy(true); setErr("");
try {
const r = await fetch("/api/ops/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password: pw, code: code || undefined }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
if (j.needCode) { setNeedCode(true); setErr(j.error || "Enter the code from your authenticator app."); return; }
setErr(j.error || "Email or password doesnt match."); return;
}
window.location.assign("/ops");
} catch {
setErr("No connection — check the network and try again.");
} finally {
setBusy(false);
}
}
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", margin: "14px 0 6px" };
return (
<div style={{ maxWidth: 400, margin: "64px auto", padding: "0 16px" }}>
<div className="sec">Operations console</div>
<h1 className="h1" style={{ marginTop: 12 }}>Sign in</h1>
{ssoFailed && (
<div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 12 }}>
Single sign-on could not complete. Sign in with your operator password below.
</div>
)}
<form onSubmit={submit}>
<label style={label} htmlFor="ops-email">Email</label>
<input id="ops-email" className="input" type="email" autoComplete="username" autoCapitalize="none" spellCheck={false} value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} style={{ width: "100%" }} />
<label style={label} htmlFor="ops-pw">Password</label>
<input id="ops-pw" className="input" type="password" autoComplete="current-password" value={pw} onChange={(e) => { setPw(e.target.value); setErr(""); }} style={{ width: "100%" }} />
{needCode && (
<>
<label style={label} htmlFor="ops-code">Authenticator code</label>
<input id="ops-code" className="input" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => { setCode(e.target.value); setErr(""); }} placeholder="000000" style={{ width: "100%" }} />
</>
)}
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 12 }}>{err}</div>}
<button className="btn btn-primary" type="submit" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{busy ? "Signing in…" : "Sign in"}</button>
</form>
<p style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 16, lineHeight: 1.6 }}>
This is the password door. Single sign-on is the usual way in; use this when it isnt available.
</p>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { accessSsoConfigured } from "@/lib/ops/cfAccess";
import { currentOperator } from "@/lib/ops/session";
import LoginForm from "./LoginForm";
/* The sign-in page decides between the front door and the fire escape.
*
* Behind Cloudflare Access every request carries a signed assertion. When SSO is configured and
* one is present, hand off to the SSO route, which verifies it and mints the session — unless we
* just came back from a failed attempt (?sso=failed), which would loop. The route falls back here
* on any problem, so the password form is always reachable. Without the two Access variables
* there is no SSO at all and this page is simply the form. */
export const dynamic = "force-dynamic";
export default async function OpsLogin({ searchParams }: { searchParams: Promise<{ sso?: string; next?: string }> }) {
if (await currentOperator()) redirect("/ops");
const sp = await searchParams;
const ssoFailed = sp.sso === "failed";
if (!ssoFailed && accessSsoConfigured()) {
const h = await headers();
if (h.get("cf-access-jwt-assertion")) redirect("/api/ops/auth/sso" + (sp.next ? `?next=${encodeURIComponent(sp.next)}` : ""));
}
return <LoginForm ssoFailed={ssoFailed} />;
}