From 948abf75fdc72ee2d9874c2bd048189bf2ad3db0 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Mon, 13 Jul 2026 03:35:55 +0530 Subject: [PATCH] feat(auth): first-time Google onboarding (option 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a Google sign-in with no CRM profile, route to /portal/onboarding — the registration flow in mode='onboard': email skipped (Google-verified, prefilled via getUserEmail), sets a password (updateUser) so email+password login also works, skips the OTP-verify step, and persists the profile via crm.account.register. Existing-profile users go straight to the dashboard. Bumps SDK ^0.2.3. --- package.json | 2 +- src/app/portal/onboarding/page.tsx | 40 ++++++++++++++++++++ src/components/portal/login-flow.tsx | 16 +++----- src/components/portal/register-flow.tsx | 49 +++++++++++++++++-------- 4 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 src/app/portal/onboarding/page.tsx diff --git a/package.json b/package.json index 4dd7f61..b8b553e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "@abe-kap/appshell-sdk": "^0.2.2", + "@abe-kap/appshell-sdk": "^0.2.3", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", diff --git a/src/app/portal/onboarding/page.tsx b/src/app/portal/onboarding/page.tsx new file mode 100644 index 0000000..1d7516e --- /dev/null +++ b/src/app/portal/onboarding/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react"; +import { PortalAside, PanelBrand } from "@/components/portal/parts"; +import { RegisterFlow } from "@/components/portal/register-flow"; +import { CookieBanner } from "@/components/portal/bits"; +import { isShellConfigured } from "@/lib/appshell"; + +/** + * Post-OAuth onboarding — a first-time Google user completes their CRM profile + * (everything except email, which Google already verified). Only reachable while + * authenticated; unauthenticated visitors are sent back to sign in. + */ +export default function OnboardingPage() { + const router = useRouter(); + const { status } = useAuth(); + const { ready } = useAppShell(); + + useEffect(() => { + if (isShellConfigured() && ready && status === "unauthenticated") router.replace("/portal/login"); + }, [ready, status, router]); + + return ( +
+ +
+ +
+
+ + +
+
+
+ +
+ ); +} diff --git a/src/components/portal/login-flow.tsx b/src/components/portal/login-flow.tsx index 600274d..2b986ab 100644 --- a/src/components/portal/login-flow.tsx +++ b/src/components/portal/login-flow.tsx @@ -24,7 +24,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function LoginFlow() { const router = useRouter(); - const { login, loginWithOAuth, completeOAuthLogin, logout } = useAuth(); + const { login, loginWithOAuth, completeOAuthLogin } = useAuth(); const { ready, sdk } = useAppShell(); const [step, setStep] = useState("identify"); const [email, setEmail] = useState(""); @@ -78,27 +78,21 @@ export function LoginFlow() { completeOAuthLogin(code) .then(async (ace) => { if (!ace) { replace("identify"); return; } - // Google is sign-IN only: require an existing CRM profile. If the account - // has none, sign back out and show an error rather than silently admitting it. + // First-time Google user (no CRM profile yet) → complete onboarding; an + // existing profile → straight to the dashboard. let registered = false; try { const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus"); registered = !!st?.registered; } catch { registered = false; } window.history.replaceState({}, "", "/portal/login"); - if (!registered) { - try { await logout(); } catch { /* ignore */ } - setFlash("No account is registered for this Google email. Please register first."); - replace("identify"); - return; - } - router.replace("/dashboard"); + router.replace(registered ? "/dashboard" : "/portal/onboarding"); }) .catch(() => { setFlash("Google sign-in didn't complete. Please try again."); replace("identify"); }); - }, [ready, completeOAuthLogin, router, sdk, logout]); + }, [ready, completeOAuthLogin, router, sdk]); /* ---- auth resolution ---- */ function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") { diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx index cdda12a..6ac33fd 100644 --- a/src/components/portal/register-flow.tsx +++ b/src/components/portal/register-flow.tsx @@ -19,9 +19,13 @@ type Addr = { line1?: string; line2?: string; city?: string; state?: string; pos const STEPS = ["Account", "Verify", "Address"]; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export function RegisterFlow() { +export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) { + // "onboard" = an already-authenticated OAuth (Google) user completing their CRM + // profile: email is skipped (Google-verified), the auth account already exists so + // we only SET a password + persist the profile, and the OTP-verify step is skipped. + const onboard = mode === "onboard"; const router = useRouter(); - const { register } = useAuth(); + const { register, setPassword, getUserEmail } = useAuth(); const { sdk } = useAppShell(); const [step, setStep] = useState(0); const [submitErr, setSubmitErr] = useState(""); @@ -50,6 +54,15 @@ export function RegisterFlow() { const [emailVerified, setEmailVerified] = useState(false); const [phoneVerified, setPhoneVerified] = useState(false); + // Onboarding: prefill the verified email from the Google session (the ACE omits it). + useEffect(() => { + if (!onboard) return; + getUserEmail().then((e) => { if (e) setEmail(e); }).catch(() => { /* ignore */ }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onboard]); + + const STEP_LABELS = onboard ? ["Profile", "Address"] : STEPS; + const isSelf = relationship === "Customer" || relationship === "Owner"; const isEmployee = relationship === "Employee"; const country = countryCodes.find((c) => c.code === cc)!; @@ -76,8 +89,11 @@ export function RegisterFlow() { if (isShellConfigured()) { setSubmitErr(""); - // 1) Auth account — appshell → Supabase (SSO users already have a session). - if (!sso) { + // 1) Auth account. Onboarding users are already authenticated via Google — set + // a password so email+password login works too. Everyone else registers now. + if (onboard) { + try { if (pw) await setPassword(pw); } catch { /* non-fatal — the profile still saves */ } + } else if (!sso) { try { await register(finalEmail, pw); } catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; } } @@ -98,7 +114,8 @@ export function RegisterFlow() { consentTerms: termsOk, consentPrivacy: privacyOk, }); } catch { - // Non-fatal: the auth account already exists; the domain profile can be filled in later. + if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; } + // register mode: non-fatal — the auth account exists; the profile can be filled in later. } } router.push("/dashboard"); @@ -106,10 +123,11 @@ export function RegisterFlow() { return (
- + {step === 0 && ( )} - {step === 1 && ( + {step === 1 && !onboard && ( )} - {step === 2 && ( + {((onboard && step === 1) || (!onboard && step === 2)) && ( <> {submitErr &&
{submitErr}
} - setStep(1)} onFinish={finish} /> + setStep(onboard ? 0 : 1)} onFinish={finish} /> )}
@@ -153,9 +171,10 @@ function StepAccount(p: { alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean; alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void; termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void; - valid: boolean; onContinue: () => void; toLogin: () => void; + valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; }) { - const [phase, setPhase] = useState<"sso" | "profile">("sso"); + // Onboarding (OAuth) starts on the profile step — email is already known + verified. + const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso"); const [modal, setModal] = useState(null); if (phase === "sso") { @@ -190,7 +209,7 @@ function StepAccount(p: { {p.sso ? (
Connected with {cap(p.sso.provider)} · {p.sso.email}
) : ( -
Creating account for {p.email}
+
{p.onboard ? "Signed in as" : "Creating account for"} {p.email}
)} @@ -289,7 +308,7 @@ function StepAccount(p: { {!p.valid &&

Fill all required fields and accept both documents to continue.

} - + {modal === "terms" && setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />} {modal === "privacy" && setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />} @@ -581,10 +600,10 @@ function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a: } /* ====================== stepper ====================== */ -function Stepper({ current }: { current: number }) { +function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) { return (
- {STEPS.map((label, i) => { + {labels.map((label, i) => { const done = i < current, on = i === current; return (