forked from Goutam/lynkeduppro-crm
Merge pull request 'feat(auth): Google onboarding flow (option 3)' (#12) from feat/login-methods into goutamnextflow
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abe-kap/appshell-sdk": "^0.2.2",
|
"@abe-kap/appshell-sdk": "^0.2.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<main className="portal-main">
|
||||||
|
<span className="portal-grid" />
|
||||||
|
<div className="portal-split anim-in">
|
||||||
|
<PortalAside />
|
||||||
|
<section className="portal-panel">
|
||||||
|
<div style={{ width: "100%", maxWidth: 420 }}>
|
||||||
|
<PanelBrand />
|
||||||
|
<RegisterFlow mode="onboard" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<CookieBanner />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||||||
|
|
||||||
export function LoginFlow() {
|
export function LoginFlow() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login, loginWithOAuth, completeOAuthLogin, logout } = useAuth();
|
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
|
||||||
const { ready, sdk } = useAppShell();
|
const { ready, sdk } = useAppShell();
|
||||||
const [step, setStep] = useState<Step>("identify");
|
const [step, setStep] = useState<Step>("identify");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -78,27 +78,21 @@ export function LoginFlow() {
|
|||||||
completeOAuthLogin(code)
|
completeOAuthLogin(code)
|
||||||
.then(async (ace) => {
|
.then(async (ace) => {
|
||||||
if (!ace) { replace("identify"); return; }
|
if (!ace) { replace("identify"); return; }
|
||||||
// Google is sign-IN only: require an existing CRM profile. If the account
|
// First-time Google user (no CRM profile yet) → complete onboarding; an
|
||||||
// has none, sign back out and show an error rather than silently admitting it.
|
// existing profile → straight to the dashboard.
|
||||||
let registered = false;
|
let registered = false;
|
||||||
try {
|
try {
|
||||||
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
|
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
|
||||||
registered = !!st?.registered;
|
registered = !!st?.registered;
|
||||||
} catch { registered = false; }
|
} catch { registered = false; }
|
||||||
window.history.replaceState({}, "", "/portal/login");
|
window.history.replaceState({}, "", "/portal/login");
|
||||||
if (!registered) {
|
router.replace(registered ? "/dashboard" : "/portal/onboarding");
|
||||||
try { await logout(); } catch { /* ignore */ }
|
|
||||||
setFlash("No account is registered for this Google email. Please register first.");
|
|
||||||
replace("identify");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace("/dashboard");
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setFlash("Google sign-in didn't complete. Please try again.");
|
setFlash("Google sign-in didn't complete. Please try again.");
|
||||||
replace("identify");
|
replace("identify");
|
||||||
});
|
});
|
||||||
}, [ready, completeOAuthLogin, router, sdk, logout]);
|
}, [ready, completeOAuthLogin, router, sdk]);
|
||||||
|
|
||||||
/* ---- auth resolution ---- */
|
/* ---- auth resolution ---- */
|
||||||
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ type Addr = { line1?: string; line2?: string; city?: string; state?: string; pos
|
|||||||
const STEPS = ["Account", "Verify", "Address"];
|
const STEPS = ["Account", "Verify", "Address"];
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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 router = useRouter();
|
||||||
const { register } = useAuth();
|
const { register, setPassword, getUserEmail } = useAuth();
|
||||||
const { sdk } = useAppShell();
|
const { sdk } = useAppShell();
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [submitErr, setSubmitErr] = useState("");
|
const [submitErr, setSubmitErr] = useState("");
|
||||||
@@ -50,6 +54,15 @@ export function RegisterFlow() {
|
|||||||
const [emailVerified, setEmailVerified] = useState(false);
|
const [emailVerified, setEmailVerified] = useState(false);
|
||||||
const [phoneVerified, setPhoneVerified] = 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 isSelf = relationship === "Customer" || relationship === "Owner";
|
||||||
const isEmployee = relationship === "Employee";
|
const isEmployee = relationship === "Employee";
|
||||||
const country = countryCodes.find((c) => c.code === cc)!;
|
const country = countryCodes.find((c) => c.code === cc)!;
|
||||||
@@ -76,8 +89,11 @@ export function RegisterFlow() {
|
|||||||
|
|
||||||
if (isShellConfigured()) {
|
if (isShellConfigured()) {
|
||||||
setSubmitErr("");
|
setSubmitErr("");
|
||||||
// 1) Auth account — appshell → Supabase (SSO users already have a session).
|
// 1) Auth account. Onboarding users are already authenticated via Google — set
|
||||||
if (!sso) {
|
// 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); }
|
try { await register(finalEmail, pw); }
|
||||||
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
|
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,
|
consentTerms: termsOk, consentPrivacy: privacyOk,
|
||||||
});
|
});
|
||||||
} catch {
|
} 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");
|
router.push("/dashboard");
|
||||||
@@ -106,10 +123,11 @@ export function RegisterFlow() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card anim-fade-up">
|
<div className="card anim-fade-up">
|
||||||
<Stepper current={step} />
|
<Stepper current={step} labels={STEP_LABELS} />
|
||||||
|
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<StepAccount
|
<StepAccount
|
||||||
|
onboard={onboard}
|
||||||
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
||||||
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
||||||
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
||||||
@@ -123,7 +141,7 @@ export function RegisterFlow() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && !onboard && (
|
||||||
<StepVerify
|
<StepVerify
|
||||||
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
|
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
|
||||||
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
|
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
|
||||||
@@ -132,10 +150,10 @@ export function RegisterFlow() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{((onboard && step === 1) || (!onboard && step === 2)) && (
|
||||||
<>
|
<>
|
||||||
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
||||||
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
|
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(onboard ? 0 : 1)} onFinish={finish} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,9 +171,10 @@ function StepAccount(p: {
|
|||||||
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
|
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
|
||||||
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
|
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
|
||||||
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => 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 | "terms" | "privacy">(null);
|
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
|
||||||
|
|
||||||
if (phase === "sso") {
|
if (phase === "sso") {
|
||||||
@@ -190,7 +209,7 @@ function StepAccount(p: {
|
|||||||
{p.sso ? (
|
{p.sso ? (
|
||||||
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
|
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
|
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> {p.onboard ? "Signed in as" : "Creating account for"} {p.email}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -289,7 +308,7 @@ function StepAccount(p: {
|
|||||||
|
|
||||||
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
|
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
|
||||||
|
|
||||||
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account & Verify <Icon name="arrowR" size={16} /></button>
|
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button>
|
||||||
|
|
||||||
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
|
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
|
||||||
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
|
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
|
||||||
@@ -581,10 +600,10 @@ function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== stepper ====================== */
|
/* ====================== stepper ====================== */
|
||||||
function Stepper({ current }: { current: number }) {
|
function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="steps">
|
<div className="steps">
|
||||||
{STEPS.map((label, i) => {
|
{labels.map((label, i) => {
|
||||||
const done = i < current, on = i === current;
|
const done = i < current, on = i === current;
|
||||||
return (
|
return (
|
||||||
<div key={label} style={{ display: "contents" }}>
|
<div key={label} style={{ display: "contents" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user