From 2a1d52b7af96d61fd36fa7d7a4359a0bf973e7a9 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Tue, 18 Aug 2026 20:34:04 -0400 Subject: [PATCH] Add a real signup path instead of the accidental one SAAS_PLAN.md 2.2: there was no /signup page. The only self-serve path was Google sign-in on /login, which auto-provisions a Firebase account with no role or org claim at all - previously that meant "viewer role, full read access" the moment the AuthProvider cookie logic (previous commit) let it through. That's closed now regardless; this commit is the other side of it - giving people an actual way in. app/signup/page.tsx: email/password (createUserWithEmailAndPassword) or Google, same visual language as /login. It only creates the Firebase account - org naming is deliberately not on this page, so every path that produces an account with no org (this one, and Google-via-/login) converges on the same next screen. app/onboarding/page.tsx: that screen. Shown to any signed-in user with no orgId (ChromeSwitcher's redirect, previous commit), collects an org name, calls the new c2api.signup() -> POST /auth/signup (routers/links.py, already shipped), then refreshClaims() to force-refetch the ID token so orgId picks up immediately and the same redirect effect sends them on to /dashboard - no manual reload needed. lib/c2api.ts also gained getOrg/updateOrg and the enrollment-token mint/list/revoke calls (routers/org.py, already shipped on the backend) and joinWaitlist (routers/waitlist.py) - none consumed yet, wired in ahead of the settings/legal commits that use them so this stays one add per concept rather than scattering client additions across later commits. /login gained a "Don't have an account? Sign up" link to /signup. This is signup plumbing, not marketing copy - pricing/plan copy (app/pricing, lib/billing.ts) is untouched in this pass, that's a separate, still-open decision (SAAS_PLAN.md section 6). Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy). Co-Authored-By: Claude Opus 5 --- drb-frontend/app/login/page.tsx | 5 ++ drb-frontend/app/onboarding/page.tsx | 87 ++++++++++++++++++ drb-frontend/app/signup/page.tsx | 128 +++++++++++++++++++++++++++ drb-frontend/lib/c2api.ts | 31 +++++++ 4 files changed, 251 insertions(+) create mode 100644 drb-frontend/app/onboarding/page.tsx create mode 100644 drb-frontend/app/signup/page.tsx diff --git a/drb-frontend/app/login/page.tsx b/drb-frontend/app/login/page.tsx index e2e6262..f63eb32 100644 --- a/drb-frontend/app/login/page.tsx +++ b/drb-frontend/app/login/page.tsx @@ -105,6 +105,11 @@ export default function LoginPage() { Continue with Google + +

+ Don't have an account?{" "} + Sign up +

); diff --git a/drb-frontend/app/onboarding/page.tsx b/drb-frontend/app/onboarding/page.tsx new file mode 100644 index 0000000..45a3945 --- /dev/null +++ b/drb-frontend/app/onboarding/page.tsx @@ -0,0 +1,87 @@ +"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(null); + + useEffect(() => { + if (loading) return; + if (!user) { + router.replace("/login"); + return; + } + if (orgId) { + router.replace("/dashboard"); + } + }, [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 ( +
+
+
+

Set up your organization

+

+ One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later. +

+
+ +
+
+ + 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" + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/drb-frontend/app/signup/page.tsx b/drb-frontend/app/signup/page.tsx new file mode 100644 index 0000000..c46c49a --- /dev/null +++ b/drb-frontend/app/signup/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { createUserWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; +import { auth } from "@/lib/firebase"; +import { useRouter } from "next/navigation"; + +/** + * Self-serve account creation (SAAS_PLAN.md B4). Only creates the Firebase + * user — org naming happens on the next screen, /onboarding, which is also + * where every other no-org-yet path (Google sign-in via /login, etc.) ends + * up. Keeping that step in one shared place means there's exactly one route + * that calls POST /auth/signup. + */ +export default function SignupPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + try { + await createUserWithEmailAndPassword(auth, email, password); + router.push("/onboarding"); + } catch (err: unknown) { + const code = (err as { code?: string })?.code; + if (code === "auth/email-already-in-use") { + setError("An account with this email already exists. Try signing in instead."); + } else if (code === "auth/weak-password") { + setError("Password is too weak — use at least 6 characters."); + } else { + setError("Could not create your account. Check your details and try again."); + } + } finally { + setLoading(false); + } + } + + async function handleGoogle() { + setLoading(true); + setError(null); + try { + await signInWithPopup(auth, new GoogleAuthProvider()); + router.push("/onboarding"); + } catch { + setError("Google sign-up failed. Try again."); + } finally { + setLoading(false); + } + } + + return ( +
+ + D + DRB + +
+

Create your account

+ +
+
+ + 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" + /> +
+
+ + setPassword(e.target.value)} + required + minLength={6} + autoComplete="new-password" + 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" + /> +
+ {error &&

{error}

} + +
+ +
+
+ or +
+
+ + + +

+ Already have an account?{" "} + Sign in +

+
+
+ ); +} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 240ea20..fe76407 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -222,4 +222,35 @@ export const c2api = { // Session recording — called on each explicit sign-in recordSession: () => request<{ ok: boolean }>("/auth/session", { method: "POST" }), + + // Org provisioning (SAAS_PLAN.md B4) — called once from /onboarding right + // after a Firebase account exists but before it has an org_id claim. + signup: (orgName: string) => + request<{ org_id: string; org_name: string; already_provisioned: boolean }>("/auth/signup", { + method: "POST", + body: JSON.stringify({ org_name: orgName }), + }), + + // Organization profile + getOrg: () => + request<{ org_id: string; name: string; created_at: string }>("/org"), + updateOrg: (name: string) => + request<{ ok: boolean; name: string }>("/org", { method: "PATCH", body: JSON.stringify({ name }) }), + + // Per-org enrollment tokens (SAAS_PLAN.md B2b) + listEnrollmentTokens: () => + request<{ token_id: string; label: string; created_at: string; revoked: boolean; uses: number }[]>( + "/org/enrollment-tokens" + ), + mintEnrollmentToken: (label: string) => + request<{ token_id: string; token: string; label: string }>("/org/enrollment-tokens", { + method: "POST", + body: JSON.stringify({ label }), + }), + revokeEnrollmentToken: (tokenId: string) => + request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }), + + // Public waitlist — no auth, see routers/waitlist.py + joinWaitlist: (body: { email: string; org_name?: string; note?: string }) => + request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }), };