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 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-08-18 20:34:04 -04:00
co-authored by Claude Opus 5
parent c7f985df42
commit 2a1d52b7af
4 changed files with 251 additions and 0 deletions
+5
View File
@@ -105,6 +105,11 @@ export default function LoginPage() {
</svg>
Continue with Google
</button>
<p className="text-center text-xs text-gray-500">
Don&apos;t have an account?{" "}
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
</p>
</div>
</div>
);
+87
View File
@@ -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<string | null>(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 (
<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>
);
}
+128
View File
@@ -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<string | null>(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 (
<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">
<h1 className="text-white text-lg font-bold">Create your account</h1>
<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">Password</label>
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading}
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"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span>
<div className="flex-1 h-px bg-gray-700" />
</div>
<button
type="button"
onClick={handleGoogle}
disabled={loading}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-100 disabled:opacity-50 text-gray-900 rounded-lg py-2 text-sm font-semibold transition-colors"
>
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.859-3.048.859-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
<path d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.962L3.964 6.294C4.672 4.169 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continue with Google
</button>
<p className="text-center text-xs text-gray-500">
Already have an account?{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
</p>
</div>
</div>
);
}
+31
View File
@@ -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) }),
};