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.
+
+
+
+
+
+
+ );
+}
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 (
+