Files
server-26/drb-frontend/app/onboarding/page.tsx
Logan CusanoandClaude Opus 5 be79499635
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
Give the nav's dead links somewhere to land
Three of the app's routes were referenced but never existed, so the redesign's
navigation pointed at 404s from several directions.

/dashboard was the post-login and fallback redirect target in nine places --
login, onboarding, middleware, the admin/nodes/systems/tokens/settings guards,
and the marketing header -- but app/dashboard/ was never created. Signing in
normally dropped the user on a 404. The real signed-in home is "/", which
app/page.tsx already renders as LiveView for an authed user with an org, and
which the nav labels "Live"; all nine now point there.

Nav also linked /watch and /network, neither of which existed. /watch is the
alerts screen under its redesign name, so it re-exports app/alerts/page.tsx
and /alerts stays reachable for old links. /network is new: the "my equipment"
hub the redesign moved /nodes, /systems and /tokens behind and then never
built, which had left /systems and /tokens with no entry point in the UI at
all. Its hooks all run before the admin/operator guard, per d041c86.

Separately, the admin page's guard read isAdmin without authLoading, so every
cold load of /admin -- typed URL, hard refresh, bookmark -- redirected away
while the Firebase claims were still resolving. Admin was only reachable by
clicking through from an already-mounted page. Now it waits, like every other
guarded route does.

And /incidents no longer lies about an empty list: a failed Firestore query
leaves `incidents` empty just as a quiet night does, and the page was printing
"No incidents recorded yet" over the top of a missing-composite-index error.
useIncidents already returned `error`; the page just ignored it. It now renders
an ErrorBanner instead, so the undeployed indexes in server-26#13 read as a
failure rather than as silence on the radio.

Closes server-26#30, server-26#31. server-26#13 stays open -- the rules and
indexes still have to be pushed to the live project by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:28:02 -04:00

88 lines
3.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { Button } from "@/components/ui/Button";
/**
* Shown to any signed-in user with no org_id claim — see ChromeSwitcher's
* no-claim guard (SAAS_PLAN.md B3). Two ways to land here:
* 1. Just created an account via /signup, org name not collected yet.
* 2. Signed in via Google on /login (which auto-creates a Firebase account
* on first use) and was never provisioned into anything.
* Either way, this is the one screen an unprovisioned account can reach,
* and completing it is what POST /auth/signup uses to grant org_id/org_role.
*/
export default function OnboardingPage() {
const { user, loading, orgId, refreshClaims } = useAuth();
const router = useRouter();
const [orgName, setOrgName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loading) return;
if (!user) {
router.replace("/login");
return;
}
if (orgId) {
router.replace("/");
}
}, [loading, user, orgId, router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!orgName.trim()) return;
setSubmitting(true);
setError(null);
try {
await c2api.signup(orgName.trim());
// Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard.
await refreshClaims();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
setSubmitting(false);
}
}
if (loading || !user || orgId) return null;
return (
<div className="max-w-sm mx-auto pt-16">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
<div>
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
<input
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
required
autoFocus
placeholder="e.g. Riverside County Scanner"
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>
{error && <p className="text-red-400 text-xs">{error}</p>}
<Button type="submit" disabled={submitting || !orgName.trim()} fullWidth>
{submitting ? "Setting up…" : "Continue"}
</Button>
</form>
</div>
</div>
);
}