Rebuild the frontend as a product rather than an internal tool
The UI worked but read as an operator console: no public face, no way to describe or sell the thing, and no account surface beyond the node list. This adds the missing halves and reorganises what was already there around the incident, which is the unit of value the rest of the pipeline is built to produce. A shared design system replaces per-page styling: components/ui (Button, Card, Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the Tailwind config, and light-mode tokens in globals.css. The existing html:not(.dark) remap mechanism is extended rather than replaced -- a parallel theming system would have been two sources of truth for the same colours. Public marketing pages (/, /features, /pricing, /faq) load without a session. middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX redirect and is still NOT an authorisation boundary, which the comment there says explicitly. Real enforcement is unchanged and still lives server-side in c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher instead of by route group, because a route group would have collided on / and forced most of app/ to move for no behavioural gain. Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and lib/apiKeys.ts define the data model and the screens consume it, but every mutating call throws with a message naming the backend route that has to exist first, and the sample data is labelled as sample. Nothing here can charge anyone or mint a real credential -- picking a payment processor and holding its keys is a decision for a human, and a half-wired checkout is worse than an obviously absent one. The severity work from the c2-core change lands here too. severity is now a filter and sort dimension on the incident list rather than decoration, since a busy dispatch channel is only readable if you can collapse it to moderate and above. routine gets a muted treatment because it is the majority of traffic, legacy "unknown" still renders nothing, and TypeBadge handles the new "other" incident type. Severity rendering moved into lib/severity.tsx so the incident list, incident detail and call rows cannot drift apart. Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and admin. They already share the palette and stay coherent, and rewriting them would have buried the parts that actually needed to change. No colour tokens were renamed, so nothing regressed there. Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification was possible and none was done. No new environment variables.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Organization API keys — STUB MODULE, no backend endpoint exists yet.
|
||||
*
|
||||
* This is a distinct concept from the two API-key-shaped things that already
|
||||
* exist server-side:
|
||||
* - `node_keys` (Firestore collection) — per-node upload credentials, issued
|
||||
* via /nodes/{id}/reissue-key. Not this.
|
||||
* - The Discord bot token pool (app/tokens) — Discord bot tokens, not this.
|
||||
*
|
||||
* This module models organization-level API keys for third-party
|
||||
* integrations (a standard SaaS feature) that DRB does not yet expose.
|
||||
* Everything below is in-memory demo state so the settings UI has something
|
||||
* real to render; nothing here is persisted or capable of authenticating
|
||||
* against the real API.
|
||||
*
|
||||
* TODO(api-keys): to make this real, add to drb-c2-core:
|
||||
* - `org_api_keys` Firestore collection: {key_id, org_id, name, key_hash,
|
||||
* key_prefix, created_at, last_used_at, created_by_uid, revoked}
|
||||
* - POST /org/api-keys → generate, return the raw key ONCE
|
||||
* - GET /org/api-keys → list (prefix + metadata only, never the raw key)
|
||||
* - DELETE /org/api-keys/{id} → revoke
|
||||
* - A new auth path in internal/auth.py that checks `Authorization: Bearer drb_live_…`
|
||||
* against `key_hash` (constant-time compare), scoped like a viewer/operator token.
|
||||
* Then replace the functions below with c2api calls hitting those routes.
|
||||
*/
|
||||
|
||||
export interface ApiKeyRecord {
|
||||
key_id: string;
|
||||
name: string;
|
||||
/** Only the prefix is ever shown after creation — mirrors how real key systems (Stripe, GitHub) do it. */
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
// Sample/demo fixture — obviously not real keys, never sent anywhere.
|
||||
let DEMO_KEYS: ApiKeyRecord[] = [
|
||||
{
|
||||
key_id: "demo_key_1",
|
||||
name: "Ops dashboard integration",
|
||||
key_prefix: "drb_live_sample_4f2a",
|
||||
created_at: "2026-07-02T14:00:00.000Z",
|
||||
last_used_at: "2026-08-15T09:12:00.000Z",
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
|
||||
export async function listApiKeys(): Promise<ApiKeyRecord[]> {
|
||||
return DEMO_KEYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full (fake) key exactly once, same UX contract a real key
|
||||
* issuance flow would have — the raw secret is shown once and never again.
|
||||
*/
|
||||
export async function createApiKey(name: string): Promise<{ record: ApiKeyRecord; rawKey: string }> {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const record: ApiKeyRecord = {
|
||||
key_id: `demo_key_${DEMO_KEYS.length + 1}`,
|
||||
name,
|
||||
key_prefix: `drb_live_sample_${suffix.slice(0, 4)}`,
|
||||
created_at: new Date().toISOString(),
|
||||
last_used_at: null,
|
||||
revoked: false,
|
||||
};
|
||||
DEMO_KEYS = [...DEMO_KEYS, record];
|
||||
return { record, rawKey: `drb_live_sample_${suffix}_DEMO_NOT_A_REAL_KEY` };
|
||||
}
|
||||
|
||||
export async function revokeApiKey(keyId: string): Promise<void> {
|
||||
DEMO_KEYS = DEMO_KEYS.map((k) => (k.key_id === keyId ? { ...k, revoked: true } : k));
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Billing/licensing boundary — STUB MODULE.
|
||||
*
|
||||
* This file defines the typed shape the settings UI (app/settings/billing) talks to.
|
||||
* Nothing here calls a real payment processor. Every exported function returns
|
||||
* hardcoded sample data or throws, and is marked with a TODO describing exactly
|
||||
* what a real integration would do.
|
||||
*
|
||||
* Do NOT wire a real Stripe (or other) publishable/secret key into this file.
|
||||
* When a processor is chosen:
|
||||
* 1. Add a c2-core router (e.g. `routers/billing.py`) that owns all server-side
|
||||
* calls to the processor's API using a secret key from server env — never
|
||||
* exposed to the frontend.
|
||||
* 2. Add a Stripe (or similar) webhook endpoint on c2-core that keeps an
|
||||
* `organizations/{orgId}` Firestore doc in sync with subscription state
|
||||
* (plan, status, current_period_end, seats, node_limit).
|
||||
* 3. Replace the bodies below with `c2api`-style `fetch` calls into that router.
|
||||
* Checkout/portal functions should return a redirect URL from a real
|
||||
* Checkout/Billing Portal session — the frontend's only job is
|
||||
* `window.location.href = url`, it should never touch card data directly.
|
||||
*/
|
||||
|
||||
export type PlanId = "free" | "pro" | "enterprise";
|
||||
export type SubscriptionStatus = "trialing" | "active" | "past_due" | "canceled" | "none";
|
||||
export type BillingInterval = "monthly" | "annual";
|
||||
|
||||
export interface PlanLimits {
|
||||
seats: number | "unlimited";
|
||||
nodes: number | "unlimited";
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export interface PlanDefinition {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
tagline: string;
|
||||
priceMonthlyUsd: number | null; // null = "contact us"
|
||||
priceAnnualUsd: number | null;
|
||||
limits: PlanLimits;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
planId: PlanId;
|
||||
status: SubscriptionStatus;
|
||||
interval: BillingInterval;
|
||||
currentPeriodEnd: string | null; // ISO date
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trialEndsAt: string | null; // ISO date
|
||||
seatsUsed: number;
|
||||
nodesUsed: number;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
date: string; // ISO date
|
||||
amountUsd: number;
|
||||
status: "paid" | "open" | "void" | "uncollectible";
|
||||
description: string;
|
||||
/** In a real integration, a short-lived link to the processor-hosted PDF/receipt. */
|
||||
hostedUrl: string | null;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
seatsUsed: number;
|
||||
seatsLimit: number | "unlimited";
|
||||
nodesUsed: number;
|
||||
nodesLimit: number | "unlimited";
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan catalog — this is real UI copy (safe to ship), just not wired to a
|
||||
// live pricing table. In a real integration this would likely be fetched
|
||||
// from the processor (Stripe Prices API) instead of hardcoded here so price
|
||||
// changes don't require a frontend deploy.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PLANS: PlanDefinition[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Community",
|
||||
tagline: "For a single node and a small crew keeping an eye on local traffic.",
|
||||
priceMonthlyUsd: 0,
|
||||
priceAnnualUsd: 0,
|
||||
limits: { seats: 3, nodes: 1, retentionDays: 7 },
|
||||
features: [
|
||||
"1 field node",
|
||||
"3 team seats",
|
||||
"Live incident map",
|
||||
"7-day call & incident history",
|
||||
"Discord voice relay",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
tagline: "For agencies and serious hobbyist networks running multiple nodes.",
|
||||
priceMonthlyUsd: 79,
|
||||
priceAnnualUsd: 790,
|
||||
limits: { seats: 15, nodes: 10, retentionDays: 90 },
|
||||
features: [
|
||||
"Up to 10 field nodes",
|
||||
"15 team seats",
|
||||
"AI incident correlation & summaries",
|
||||
"90-day call & incident history",
|
||||
"Alert rules with Discord webhooks",
|
||||
"API key access",
|
||||
],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
tagline: "For regional networks with custom retention, SSO, and support needs.",
|
||||
priceMonthlyUsd: null,
|
||||
priceAnnualUsd: null,
|
||||
limits: { seats: "unlimited", nodes: "unlimited", retentionDays: 365 },
|
||||
features: [
|
||||
"Unlimited field nodes",
|
||||
"Unlimited team seats",
|
||||
"1-year+ retention (custom)",
|
||||
"SSO / SAML",
|
||||
"Dedicated support & uptime SLA",
|
||||
"Custom data residency",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getPlan(id: PlanId): PlanDefinition {
|
||||
return PLANS.find((p) => p.id === id) ?? PLANS[0];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample account state — clearly a demo fixture, not a real customer record.
|
||||
// TODO(billing): replace with `c2api.getSubscription()` once c2-core exposes
|
||||
// GET /org/subscription backed by the processor + Firestore org doc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_SUBSCRIPTION: Subscription = {
|
||||
planId: "pro",
|
||||
status: "trialing",
|
||||
interval: "monthly",
|
||||
currentPeriodEnd: new Date(Date.now() + 1000 * 60 * 60 * 24 * 21).toISOString(),
|
||||
cancelAtPeriodEnd: false,
|
||||
trialEndsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
|
||||
seatsUsed: 4,
|
||||
nodesUsed: 2,
|
||||
};
|
||||
|
||||
const SAMPLE_INVOICES: Invoice[] = [
|
||||
{ id: "sample_inv_1003", date: "2026-07-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||
{ id: "sample_inv_1002", date: "2026-06-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||
{ id: "sample_inv_1001", date: "2026-05-16", amountUsd: 0, status: "paid", description: "Community plan", hostedUrl: null },
|
||||
];
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getSubscription()` → GET /org/subscription.
|
||||
* Returns sample data so the settings UI has something real to render today.
|
||||
*/
|
||||
export async function getCurrentSubscription(): Promise<Subscription> {
|
||||
return SAMPLE_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getUsageSummary()` → GET /org/usage,
|
||||
* computed server-side from `nodes` count + org member count.
|
||||
*/
|
||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||
const sub = await getCurrentSubscription();
|
||||
const plan = getPlan(sub.planId);
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
return {
|
||||
seatsUsed: sub.seatsUsed,
|
||||
seatsLimit: plan.limits.seats,
|
||||
nodesUsed: sub.nodesUsed,
|
||||
nodesLimit: plan.limits.nodes,
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getInvoices()` → GET /org/invoices,
|
||||
* which on the backend would list Stripe Invoices for the org's customer id
|
||||
* and map them to this shape (hostedUrl = Stripe's `hosted_invoice_url`).
|
||||
*/
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return SAMPLE_INVOICES;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.createCheckoutSession(planId, interval)`
|
||||
* → POST /org/billing/checkout-session, which creates a Stripe Checkout
|
||||
* Session server-side (secret key never leaves the server) and returns
|
||||
* `{ url }`. Frontend then does `window.location.href = url`.
|
||||
*
|
||||
* Throws here — there is no live checkout to redirect to.
|
||||
*/
|
||||
export async function createCheckoutSession(_planId: PlanId, _interval: BillingInterval): Promise<{ url: string }> {
|
||||
throw new Error(
|
||||
"Checkout is not wired to a payment processor yet. This is a demo build — " +
|
||||
"no card will be charged. See lib/billing.ts for the integration TODO."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.createBillingPortalSession()` →
|
||||
* POST /org/billing/portal-session, which creates a Stripe Billing Portal
|
||||
* session server-side and returns `{ url }` for redirect. The portal is
|
||||
* where a real integration would let customers update payment methods,
|
||||
* cancel, or download invoices — avoids building that UI ourselves.
|
||||
*/
|
||||
export async function createBillingPortalSession(): Promise<{ url: string }> {
|
||||
throw new Error(
|
||||
"Billing portal is not wired to a payment processor yet. See lib/billing.ts for the integration TODO."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.previewPlanChange(planId, interval)` →
|
||||
* GET /org/billing/preview?plan=…, which on the backend would call the
|
||||
* processor's upcoming-invoice/proration preview endpoint.
|
||||
* Returns a rough client-side estimate so the upgrade/downgrade UI has
|
||||
* something to show; not a real proration calculation.
|
||||
*/
|
||||
export async function previewPlanChange(planId: PlanId, interval: BillingInterval): Promise<{ dueTodayUsd: number; nextAmountUsd: number }> {
|
||||
const plan = getPlan(planId);
|
||||
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
|
||||
return { dueTodayUsd: price ?? 0, nextAmountUsd: price ?? 0 };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared severity ladder for calls and incidents: routine < minor < moderate < major.
|
||||
* Every call/incident gets one of these four. `"unknown"` (and any other
|
||||
* unrecognized value) is a legacy value still present on historical docs —
|
||||
* treat it as "no severity", not as a fifth level.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export type Severity = "routine" | "minor" | "moderate" | "major";
|
||||
|
||||
export const SEVERITY_ORDER: Record<Severity, number> = { routine: 0, minor: 1, moderate: 2, major: 3 };
|
||||
export const SEVERITY_LABEL: Record<Severity, string> = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" };
|
||||
export const SEVERITY_COLORS: Record<Severity, string> = {
|
||||
routine: "bg-gray-800/40 text-gray-600",
|
||||
minor: "bg-gray-800 text-gray-400",
|
||||
moderate: "bg-orange-950 text-orange-400",
|
||||
major: "bg-red-950 text-red-400",
|
||||
};
|
||||
|
||||
export function isKnownSeverity(s: string | null | undefined): s is Severity {
|
||||
return s === "routine" || s === "minor" || s === "moderate" || s === "major";
|
||||
}
|
||||
|
||||
/** Legacy/unset severities rank below `routine` so a recency-sorted list never confuses them with a real (low) severity. */
|
||||
export function severityRank(s: string | null | undefined): number {
|
||||
return isKnownSeverity(s) ? SEVERITY_ORDER[s] : -1;
|
||||
}
|
||||
|
||||
export function severityBadge(severity: string | null | undefined): ReactElement | null {
|
||||
if (!isKnownSeverity(severity)) return null;
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${SEVERITY_COLORS[severity]}`}>
|
||||
{SEVERITY_LABEL[severity]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,8 @@ export interface CallRecord {
|
||||
location: string | null;
|
||||
tags: string[];
|
||||
status: "active" | "ended";
|
||||
/** Four-level ladder: routine | minor | moderate | major. Legacy docs may still carry "unknown". */
|
||||
severity?: string | null;
|
||||
// Correlation debug — written by the correlator, present after a call is linked
|
||||
corr_path?: string | null;
|
||||
corr_score?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user