Compare commits

..
3 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5 427d2a9f37 Say so loudly when the OpenAI account can no longer be billed
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Failing after 2m56s
Transcription is the top of the pipeline and it fails soft: any exception logs
a WARNING, returns None, and upload.py carries on. That is the right behaviour
for a network blip and exactly the wrong behaviour for an unpayable account,
because with no transcript there is no extraction, no correlation and no
incident -- the system keeps accepting calls and quietly stores empty ones,
which looks like quiet radio traffic rather than an outage.

This is the third instance of the same failure mode today. The Gemini
correlator was down first on a retired model ID and then on a depleted
balance, and in both cases the only signal was a per-call WARNING that read as
noise. The OpenAI balance is low enough that this one is a matter of when.

Billing-shaped errors (insufficient_quota, billing, credit, quota exceeded)
now log once at ERROR, name what is dead downstream, and link the top-up page.
Everything else keeps the existing per-call WARNING.

No new environment variables, so CI deploys this without an ansible run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:41:45 -04:00
Logan CusanoandClaude Opus 5 83416fe169 Split platform-admin from org-owner, hide Trips from non-founding orgs
SAAS_PLAN.md B7. "admin" meant two different things before this: platform
operator (SAAS_PLAN.md's own framing) and, by accident of how
app/settings/layout.tsx was gated, the only role that could ever reach an
org's own billing/members/node-ownership settings. A paying customer who is
their own org's owner couldn't reach their own Settings page - the gate
checked isAdmin, which only platform admins ever have.

settings/layout.tsx now admits org_role === "owner" as well as platform
admins (isAdmin stays valid too, for support access to any org's
settings). Nav.tsx shows the Settings link on the same condition, and moves
Admin (the platform-operator screens: feature flags, users, audit,
correlation debug) out of the customer-facing link group entirely - it was
already gated server-side, this is just the nav no longer implying it's
part of the product.

Trips - an internal utility feature riding along on this stack, not a
tenant-scoped product surface (see [[trips-feature-intentional]]) - drops
out of the customer-facing viewer link group and only shows for the
founding org (new lib/tenancy.ts mirrors app/internal/tenancy.py's
FOUNDING_ORG_ID) or a platform admin, matching the mutation-route gating
routers/trips.py already got in the backend tenancy commit. Reads stay
open to any signed-in user, same as before - trips' own visibility model
(public/private per trip) predates and is unrelated to org tenancy, and
restricting it further wasn't asked for.

Also closes two DEFERRED.md items now that they have somewhere to write to:
app/settings/organization's "Save changes" button now actually calls
c2api.getOrg()/updateOrg() (routers/org.py, shipped in the backend tenancy
commit) instead of being permanently disabled. app/settings/nodes gained an
EnrollmentTokensPanel (mint/list/revoke against the same commit's
/org/enrollment-tokens routes) - without this, B2b's whole point (a
customer enrolls their own node with their own token instead of an
admin-issued key) had no way to actually be used outside a raw API call.

Left alone, and written up as new DEFERRED.md entries instead of guessed
at: node/system *write* routes (approve, create, delete) stay
platform-admin-only rather than being loosened to org owner/operator - a
real gap per SAAS_PLAN.md 2.4, but a separate authorization design that the
plan's 12-item build order doesn't enumerate. And settings/members +
settings/nodes' ownership table both still call GET /admin/users
(platform-admin-only) - a pure org owner who reaches the page via this
commit's gate will get 403s from it. Today's only real user is also a
platform admin, so this is invisible until a second, non-admin org owner
exists.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:39:16 -04:00
Logan CusanoandClaude Opus 5 1b4ed0d09c Ship /terms, /privacy, and /waitlist as structure, not finished pages
SAAS_PLAN.md B5/B6, narrowed: no Stripe/pricing/tier work of any kind this
pass (a mid-build correction from the business side landed while this was
in progress - the commercial model, SAAS_PLAN.md section 6.1, is still
undecided), so app/pricing and lib/billing.ts's PLANS are untouched here.
What's left of B5/B6 without that - real legal pages and a working
waitlist - still ships.

app/terms/page.tsx and app/privacy/page.tsx are section scaffolding, not
legal text. Every section is a TODO(legal) note describing what that
section needs to cover, and the page leads with a "Draft - not yet in
force" banner. This isn't caution for its own sake: DRB records, stores,
and transcribes public-safety radio traffic, and recording/rebroadcast
legality varies by state (SAAS_PLAN.md section 6.3) - an agent-generated
draft here would be actively wrong to publish, not just unpolished. Both
were pre-added to middleware.ts's PUBLIC_PATHS and ChromeSwitcher's
MARKETING_PATHS two commits ago; MarketingFooter now links both.

app/waitlist/page.tsx is a real, working form against the already-shipped
POST /waitlist - email + optional org name/note, no plan or price
mentioned anywhere on it, matching the backend route's own scope (rate
limited by source IP, not coupled to any tier). Linked from
MarketingFooter as "Request access," not from the pricing page - pricing
CTAs stay exactly as they were.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:38:49 -04:00
11 changed files with 550 additions and 23 deletions
+37 -1
View File
@@ -93,6 +93,42 @@ def _is_degenerate(text: str, segments: list[dict]) -> bool:
return False
_billing_reported = False
def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
"""
Log a transcription failure, escalating an unpayable account to ERROR once.
Transcription failing returns None and the pipeline carries on by design, so
a per-call WARNING is invisible: no transcript means no extraction, which
means no incident, and the only symptom is calls quietly arriving empty. A
network blip is genuinely a warning. An exhausted balance is not -- it will
not fix itself and it takes the whole pipeline down with it, so it says so
once, loudly, and names the fix.
The same failure mode already bit the Gemini correlator twice (a retired
model ID, then a depleted balance), which is why this is worth the code.
"""
global _billing_reported
text = str(exc)
low = text.lower()
if ("insufficient_quota" in low or "billing" in low
or "credit" in low or "exceeded your current quota" in low):
if not _billing_reported:
_billing_reported = True
logger.error(
"Transcription: the OpenAI account cannot be billed -- EVERY call is "
"now stored with no transcript, so extraction, correlation and "
"incidents are all dead downstream. Top up at "
f"https://platform.openai.com/settings/organization/billing. API said: {text}"
)
return
logger.warning(f"Transcription failed for call {call_id}: {text}")
async def transcribe_call(
call_id: str,
gcs_uri: str,
@@ -114,7 +150,7 @@ async def transcribe_call(
_sync_transcribe, gcs_uri, talkgroup_name
)
except Exception as e:
logger.warning(f"Transcription failed for call {call_id}: {e}")
_log_transcribe_failure(call_id, e)
return None, []
if transcript:
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
/**
* SAAS_PLAN.md B5: page structure only — see app/terms/page.tsx for why the
* agent building this did not write real legal text. Privacy Policy needs
* the same jurisdiction-aware legal review as Terms, plus specifics this
* agent cannot respond for on the owner's behalf: what a real DPA/CCPA/GDPR
* posture looks like, and what third-party processors (OpenAI, Gemini,
* Google Maps, Firebase/GCP, Stripe once chosen) actually receive and why.
*/
const SECTIONS: { heading: string; note: string }[] = [
{
heading: "1. What data this collects",
note: "TODO(legal): account data (email, org membership), field node telemetry (location, status), radio call audio and AI-generated transcripts/entities/incident data, and usage/session logs (drb-c2-core's audit_log and user_sessions collections already exist and hold some of this today).",
},
{
heading: "2. Third parties this data is sent to, and why",
note: "TODO(legal): OpenAI (Whisper transcription), Google Gemini (incident extraction/summarization/embeddings), Google Maps (geocoding location strings extracted from transcripts), Google Cloud (Firestore + GCS storage, Firebase Auth), and — once a payment processor is chosen (SAAS_PLAN.md section 6.5, not yet decided) — that processor. Each of these is a real, already-integrated dependency, not a hypothetical one; this section needs to name them accurately, not generically.",
},
{
heading: "3. Recorded radio traffic specifically",
note: "TODO(legal): this product's core function is recording, transcribing, and storing monitored radio audio — including public-safety traffic that may name individuals, locations, and in-progress incidents. This needs explicit treatment distinct from generic 'we collect usage data' privacy boilerplate, and needs to be read alongside the same legal review flagged in Terms section 3.",
},
{
heading: "4. How long data is kept",
note: "TODO(legal): no retention enforcement exists in the product yet (no TTL, no sweep, no deletion job — see DEFERRED.md) — this section cannot promise a retention/deletion window the system doesn't actually implement.",
},
{
heading: "5. Customer and end-user rights",
note: "TODO(legal): access/export/deletion requests, and who they're directed to — org owner vs. platform operator.",
},
{
heading: "6. Cookies and session data",
note: "TODO(legal): drb_session is a client-set, non-httpOnly cookie used only for UI redirect logic (not an auth boundary — see CLAUDE.md); Firebase Auth sets its own session storage. No analytics/tracking cookies are set today.",
},
{
heading: "7. Security practices",
note: "TODO(legal): at a level appropriate for public disclosure — Firestore security rules, per-node credentials, encrypted transport. Should be reviewed against SAAS_PLAN.md's actual findings before publishing any specific claim.",
},
{
heading: "8. Changes to this policy",
note: "TODO(legal): how customers are notified.",
},
{
heading: "9. Contact",
note: "TODO(legal): real company legal identity and contact address — not yet decided (SAAS_PLAN.md section 6.6).",
},
];
export default function PrivacyPage() {
return (
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
<p className="text-yellow-200 text-sm font-mono font-semibold">
Draft — not yet in force
</p>
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
This page is a structural placeholder, not a real Privacy Policy. Every section below is a{" "}
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page
describes a binding commitment about how data is handled.
</p>
</div>
<h1 className="text-display-sm text-white">Privacy Policy</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8">
{SECTIONS.map((s) => (
<section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section>
))}
</div>
<p className="text-gray-600 text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p>
</div>
);
}
+11 -4
View File
@@ -15,15 +15,22 @@ const TABS = [
];
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { isAdmin, loading } = useAuth();
// SAAS_PLAN.md B7: this used to gate on isAdmin (platform admin) alone,
// which meant a paying customer who is their own org's owner couldn't
// reach their own billing/members/node-ownership settings — "admin" here
// conflated "platform operator" with "org owner". isAdmin still passes
// (support/debugging access to any org's settings), but org_role ===
// "owner" is now sufficient on its own.
const { isAdmin, isOrgOwner, loading } = useAuth();
const canAccess = isAdmin || isOrgOwner;
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (!loading && !isAdmin) router.replace("/dashboard");
}, [loading, isAdmin, router]);
if (!loading && !canAccess) router.replace("/dashboard");
}, [loading, canAccess, router]);
if (loading || !isAdmin) return null;
if (loading || !canAccess) return null;
return (
<div className="space-y-6">
+163 -6
View File
@@ -3,15 +3,168 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useNodes } from "@/lib/useNodes";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import type { UserRecord } from "@/lib/types";
import { StatusBadge } from "@/components/StatusBadge";
import { Card } from "@/components/ui/Card";
import { Card, CardHeader } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonRow } from "@/components/ui/Skeleton";
const UNASSIGNED = "__unassigned__";
interface EnrollmentToken {
token_id: string;
label: string;
created_at: string;
revoked: boolean;
uses: number;
}
/**
* SAAS_PLAN.md B2b — per-org enrollment tokens (routers/org.py). This is the
* credential a customer's field node presents to POST /nodes/enroll
* (X-Enrollment-Token) so it lands in THIS org instead of the legacy
* fleet-wide pool. Minting/revoking is owner-only server-side; any org
* member can list (metadata only, the raw token is shown exactly once at
* mint time and never again).
*/
function EnrollmentTokensPanel() {
const { isOrgOwner, isAdmin } = useAuth();
const canManage = isOrgOwner || isAdmin;
const [tokens, setTokens] = useState<EnrollmentToken[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [label, setLabel] = useState("");
const [minting, setMinting] = useState(false);
const [justMinted, setJustMinted] = useState<string | null>(null);
const load = useCallback(() => {
c2api.listEnrollmentTokens()
.then(setTokens)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
async function handleMint(e: React.FormEvent) {
e.preventDefault();
if (!label.trim()) return;
setMinting(true);
setError(null);
try {
const result = await c2api.mintEnrollmentToken(label.trim());
setJustMinted(result.token);
setLabel("");
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setMinting(false);
}
}
async function handleRevoke(tokenId: string) {
try {
await c2api.revokeEnrollmentToken(tokenId);
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
return (
<Card>
<CardHeader
title="Enrollment tokens"
subtitle="Give a new field node one of these instead of an admin-issued key — it enrolls straight into this org."
/>
{justMinted && (
<div className="bg-indigo-900/30 border border-indigo-700/50 rounded-lg p-3 mb-4">
<p className="text-xs text-indigo-200 font-mono mb-1">
New token — copy it now, it won&apos;t be shown again:
</p>
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<button
type="button"
onClick={() => setJustMinted(null)}
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors"
>
Dismiss
</button>
</div>
)}
{error && <ErrorBanner message={error} />}
{canManage && (
<form onSubmit={handleMint} className="flex flex-wrap gap-2 mb-4">
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Label, e.g. 'node-003 field kit'"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
{minting ? "Minting…" : "New token"}
</Button>
</form>
)}
{loading ? (
<div className="space-y-2">
<SkeletonRow cols={1} />
</div>
) : tokens.length === 0 ? (
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="py-2 text-left">Label</th>
<th className="py-2 text-left hidden sm:table-cell">Created</th>
<th className="py-2 text-left">Status</th>
{canManage && <th className="py-2 text-right">Actions</th>}
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.token_id} className="border-b border-gray-800 last:border-0">
<td className="py-2 text-white">{t.label}</td>
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell">
{new Date(t.created_at).toLocaleDateString()}
</td>
<td className="py-2 text-xs">
{t.revoked ? (
<span className="text-gray-600">Revoked</span>
) : (
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
)}
</td>
{canManage && (
<td className="py-2 text-right">
{!t.revoked && (
<button
onClick={() => handleRevoke(t.token_id)}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Revoke
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</Card>
);
}
export default function NodeOwnershipSettingsPage() {
const { nodes, loading: nodesLoading } = useNodes();
const [users, setUsers] = useState<UserRecord[]>([]);
@@ -69,12 +222,15 @@ export default function NodeOwnershipSettingsPage() {
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>
<div className="space-y-6">
<EnrollmentTokensPanel />
{error && <ErrorBanner message={error} />}
<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">
@@ -122,6 +278,7 @@ export default function NodeOwnershipSettingsPage() {
</tbody>
</table>
</Card>
</div>
</div>
);
}
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import { useNodes } from "@/lib/useNodes";
import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing";
import { Card, CardHeader } from "@/components/ui/Card";
@@ -21,15 +22,40 @@ function StatTile({ label, value }: { label: string; value: string | number }) {
export default function OrganizationSettingsPage() {
const { nodes } = useNodes();
const { isOrgOwner, isAdmin } = useAuth();
const [memberCount, setMemberCount] = useState<number | null>(null);
const [sub, setSub] = useState<Subscription | null>(null);
const [orgName, setOrgName] = useState("My Organization");
const [orgName, setOrgName] = useState("");
const [savedName, setSavedName] = useState("");
const [orgLoading, setOrgLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const canEdit = isOrgOwner || isAdmin;
useEffect(() => {
c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
getCurrentSubscription().then(setSub);
c2api.getOrg()
.then((org) => { setOrgName(org.name); setSavedName(org.name); })
.catch(() => {})
.finally(() => setOrgLoading(false));
}, []);
async function handleSave() {
setSaving(true);
setSaveError(null);
try {
const res = await c2api.updateOrg(orgName.trim());
setSavedName(res.name);
setOrgName(res.name);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Could not save.");
} finally {
setSaving(false);
}
}
const plan = sub ? getPlan(sub.planId) : null;
return (
@@ -44,17 +70,21 @@ export default function OrganizationSettingsPage() {
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
<input
value={orgName}
disabled={orgLoading || !canEdit}
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"
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 disabled:opacity-50"
/>
</div>
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
<div className="flex items-center gap-3">
<Button size="sm" disabled title="Organization profile isn't persisted server-side yet">
Save changes
<Button
size="sm"
disabled={orgLoading || !canEdit || saving || !orgName.trim() || orgName.trim() === savedName}
onClick={handleSave}
title={!canEdit ? "Only the organization owner can change this" : undefined}
>
{saving ? "Saving…" : "Save changes"}
</Button>
<span className="text-xs text-gray-600 font-mono">
Preview only — no backend endpoint stores this yet.
</span>
</div>
</div>
</Card>
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
/**
* SAAS_PLAN.md B5: page structure only. The agent building this is
* explicitly instructed not to invent legal text — DRB records, stores, and
* transcribes public-safety radio traffic, and recording/rebroadcast
* legality varies by state (see SAAS_PLAN.md section 6.3), so this needs a
* human, and probably a lawyer, not a generated draft. Every section below
* is a placeholder marking what a real Terms of Service needs to cover, not
* actual terms — see the banner and every TODO(legal) marker.
*/
const SECTIONS: { heading: string; note: string }[] = [
{
heading: "1. Acceptance of terms",
note: "TODO(legal): standard acceptance clause — using the service means agreeing to these terms.",
},
{
heading: "2. What the service does and does not do",
note: "TODO(legal): describe the product (SDR ingestion, transcription, AI correlation, Discord relay) and, importantly, disclaim accuracy — AI-generated transcripts and incident summaries are not guaranteed accurate and must not be relied on as the sole source for dispatch or safety decisions.",
},
{
heading: "3. Radio recording and rebroadcast — the part that needs a lawyer",
note: "TODO(legal): this is the section that actually matters. Recording, storing, and rebroadcasting monitored radio traffic (including public-safety frequencies) has different legal treatment by state and by traffic type (encrypted vs. clear, dispatch vs. tactical). Needs jurisdiction-aware legal review before this product can be sold across state lines — do not ship this page live without it.",
},
{
heading: "4. Customer responsibilities and acceptable use",
note: "TODO(legal): who owns the hardware, who's responsible for lawful operation of the field node, prohibited uses.",
},
{
heading: "5. Data ownership and retention",
note: "TODO(legal): who owns the recorded audio/transcripts/incidents, how long they're kept, what happens on cancellation or account deletion. Note: no retention enforcement exists in the product yet either (see DEFERRED.md) — this section can't promise a retention window the system doesn't yet enforce.",
},
{
heading: "6. Payment, billing, and cancellation",
note: "TODO(legal): once a billing model and pricing exist (SAAS_PLAN.md section 6, still undecided) — refunds, proration, what happens to data on non-payment.",
},
{
heading: "7. Service availability and support",
note: "TODO(legal): whether any uptime/SLA commitment is made (today: none).",
},
{
heading: "8. Limitation of liability",
note: "TODO(legal): standard limitation-of-liability language, reviewed against the fact that this product touches public-safety-adjacent data.",
},
{
heading: "9. Changes to these terms",
note: "TODO(legal): how customers are notified of material changes.",
},
{
heading: "10. Governing law and contact",
note: "TODO(legal): governing jurisdiction, and a real company legal identity and contact address (SAAS_PLAN.md section 6.6 — not yet decided).",
},
];
export default function TermsPage() {
return (
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
<p className="text-yellow-200 text-sm font-mono font-semibold">
Draft — not yet in force
</p>
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
This page is a structural placeholder, not a real Terms of Service. Every section below is a{" "}
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page is
binding, and no self-serve signup should be considered subject to it until an actual attorney-reviewed
version replaces this content.
</p>
</div>
<h1 className="text-display-sm text-white">Terms of Service</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8">
{SECTIONS.map((s) => (
<section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section>
))}
</div>
<p className="text-gray-600 text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { c2api } from "@/lib/c2api";
/**
* SAAS_PLAN.md B6's waitlist form — POST /waitlist (routers/waitlist.py) is
* public, source-IP rate-limited, and deliberately not coupled to any plan
* or tier: the commercial model (who runs the node, what a customer
* actually buys) is still an open decision (SAAS_PLAN.md section 6.1), so
* this collects only {email, org_name, note} and makes no promise about
* price or plan.
*/
export default function WaitlistPage() {
const [email, setEmail] = useState("");
const [orgName, setOrgName] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await c2api.joinWaitlist({ email, org_name: orgName || undefined, note: note || undefined });
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not submit — try again in a moment.");
} finally {
setSubmitting(false);
}
}
return (
<div className="max-w-sm mx-auto pt-16">
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB
</Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
{done ? (
<>
<h1 className="text-white text-lg font-bold">You&apos;re on the list</h1>
<p className="text-gray-400 text-sm leading-relaxed">
Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
</p>
</>
) : (
<>
<div>
<h1 className="text-white text-lg font-bold">Request access</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
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>
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label>
<input
type="text"
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>
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={2000}
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 resize-none"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={submitting}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{submitting ? "Submitting…" : "Request access"}
</button>
</form>
</>
)}
</div>
</div>
);
}
+11 -4
View File
@@ -7,6 +7,7 @@ import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider";
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
// Links visible to all authenticated roles (viewer+)
const viewerLinks = [
@@ -15,9 +16,13 @@ const viewerLinks = [
{ href: "/incidents", label: "Incidents" },
{ href: "/map", label: "Map" },
{ href: "/alerts", label: "Alerts" },
{ href: "/trips", label: "Trips" },
];
// Trips is an internal utility feature, not a tenant-scoped product surface
// (see [[trips-feature-intentional]] and SAAS_PLAN.md B7) — shown only to
// the founding org, matching routers/trips.py's own gating.
const tripsLink = { href: "/trips", label: "Trips" };
// Additional links for operators and admins
const operatorLinks = [
{ href: "/nodes", label: "Nodes" },
@@ -25,10 +30,10 @@ const operatorLinks = [
{ href: "/tokens", label: "Tokens" },
];
// Admin-only links
// Platform-admin-only link. Settings is handled separately below — it's
// customer-facing for org owners too, not admin-only (SAAS_PLAN.md B7).
const adminLinks = [
{ href: "/admin", label: "Admin" },
{ href: "/settings", label: "Settings" },
];
function SunIcon() {
@@ -56,7 +61,7 @@ function MoonIcon() {
}
export function Nav() {
const { user, isAdmin, isOperator } = useAuth();
const { user, isAdmin, isOperator, isOrgOwner, orgId } = useAuth();
const pathname = usePathname();
const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes();
@@ -68,8 +73,10 @@ export function Nav() {
const allLinks = [
...viewerLinks,
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []),
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
];
function navLinkClass(href: string) {
@@ -13,6 +13,9 @@ export function MarketingFooter() {
<Link href="/features" className="hover:text-gray-300 transition-colors">Features</Link>
<Link href="/pricing" className="hover:text-gray-300 transition-colors">Pricing</Link>
<Link href="/faq" className="hover:text-gray-300 transition-colors">FAQ</Link>
<Link href="/waitlist" className="hover:text-gray-300 transition-colors">Request access</Link>
<Link href="/terms" className="hover:text-gray-300 transition-colors">Terms</Link>
<Link href="/privacy" className="hover:text-gray-300 transition-colors">Privacy</Link>
<Link href="/login" className="hover:text-gray-300 transition-colors">Sign in</Link>
</nav>
+10
View File
@@ -0,0 +1,10 @@
/**
* Mirrors drb-c2-core/app/internal/tenancy.py's FOUNDING_ORG_ID — the org
* every pre-tenancy document and every legacy enrollment path resolves
* into. Frontend-side, it's used only to gate the /trips feature (an
* internal utility riding along on this stack, not a tenant-scoped product
* surface — see [[trips-feature-intentional]] and SAAS_PLAN.md B7) to the
* founding org, matching the same restriction the backend already enforces
* in routers/trips.py.
*/
export const FOUNDING_ORG_ID = "founding";
+1 -1
View File
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
// Public marketing pages — no session required. Keep this in sync with
// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
// chrome; this one decides whether to redirect at all).
const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]);
const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy", "/waitlist"]);
// /signup and /onboarding are deliberately NOT gated by the drb_session
// cookie here, even though they aren't "public" in the sense of not needing