10 Commits

Author SHA1 Message Date
tanweer919 11931cbf6f Merge pull request 'polish: real role in user chip' (#10) from feat/login-methods into goutamnextflow 2026-07-12 21:33:24 +00:00
tanweer919 43f9a3eb83 Merge pull request 'feat(dashboard): real user + sign-out; remove demo text' (#9) from feat/login-methods into goutamnextflow 2026-07-12 21:27:58 +00:00
tanweer919 f5ff7bf6ea Merge pull request 'fix(auth): session persists on reload (SDK 0.2.2)' (#8) from feat/login-methods into goutamnextflow 2026-07-12 21:11:46 +00:00
tanweer919 3e79b3bf31 Merge pull request 'fix(auth): session persists on reload (gate redirect on boot-ready)' (#7) from feat/login-methods into goutamnextflow 2026-07-12 21:05:20 +00:00
tanweer919 e08fa357f7 Merge pull request 'chore: remove debug logging' (#6) from feat/login-methods into goutamnextflow 2026-07-12 20:47:30 +00:00
tanweer919 e37ef375eb Merge pull request 'fix(auth): PKCE verifier (SDK 0.2.1) — Google sign-in' (#5) from feat/login-methods into goutamnextflow 2026-07-12 20:32:32 +00:00
tanweer919 66118ff63f Merge pull request 'fix(auth): surface OAuth error' (#4) from feat/login-methods into goutamnextflow 2026-07-12 20:15:17 +00:00
tanweer919 6d0c8890d3 Merge pull request 'fix(auth): complete Google OAuth after SDK boot' (#3) from feat/login-methods into goutamnextflow 2026-07-12 20:01:53 +00:00
tanweer919 e9ec33d412 Merge pull request 'feat(auth): Google-only social; email/password sign-up; phone login' (#2) from feat/login-methods into goutamnextflow 2026-07-11 15:19:06 +00:00
tanweer919 2a653b807b Merge pull request 'feat: AppShell integration — auth, email/SMS OTP login, live multi-role team management' (#1) from tanweer919/lynkeduppro-crm:feat/appshell-integration into goutamnextflow 2026-07-11 14:24:59 +00:00
4 changed files with 27 additions and 94 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@abe-kap/appshell-sdk": "^0.2.3", "@abe-kap/appshell-sdk": "^0.2.2",
"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",
-40
View File
@@ -1,40 +0,0 @@
"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>
);
}
+10 -18
View File
@@ -25,7 +25,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginFlow() { export function LoginFlow() {
const router = useRouter(); const router = useRouter();
const { login, loginWithOAuth, completeOAuthLogin } = useAuth(); const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
const { ready, sdk } = useAppShell(); const { ready } = useAppShell();
const [step, setStep] = useState<Step>("identify"); const [step, setStep] = useState<Step>("identify");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [account, setAccount] = useState<Account | null>(null); const [account, setAccount] = useState<Account | null>(null);
@@ -76,23 +76,20 @@ export function LoginFlow() {
if (!ready) { setStep("connecting"); return; } if (!ready) { setStep("connecting"); return; }
setStep("connecting"); setStep("connecting");
completeOAuthLogin(code) completeOAuthLogin(code)
.then(async (ace) => { .then((ace) => {
if (!ace) { replace("identify"); return; } if (ace) {
// First-time Google user (no CRM profile yet) → complete onboarding; an // Clear the ?code=… from the URL, then enter the app.
// 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"); window.history.replaceState({}, "", "/portal/login");
router.replace(registered ? "/dashboard" : "/portal/onboarding"); router.replace("/dashboard");
} else {
replace("identify");
}
}) })
.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]); }, [ready, completeOAuthLogin, router]);
/* ---- auth resolution ---- */ /* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") { function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
@@ -147,12 +144,7 @@ export function LoginFlow() {
/* =================================================================== */ /* =================================================================== */
return ( return (
<div className="card anim-fade-up" key={step}> <div className="card anim-fade-up" key={step}>
{step === "identify" && ( {step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />}
<>
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />
</>
)}
{step === "connecting" && ( {step === "connecting" && (
<div className="interstitial"> <div className="interstitial">
+15 -34
View File
@@ -19,13 +19,9 @@ 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({ mode = "register" }: { mode?: "register" | "onboard" }) { export function RegisterFlow() {
// "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, setPassword, getUserEmail } = useAuth(); const { register } = 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("");
@@ -54,15 +50,6 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
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)!;
@@ -89,11 +76,8 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
if (isShellConfigured()) { if (isShellConfigured()) {
setSubmitErr(""); setSubmitErr("");
// 1) Auth account. Onboarding users are already authenticated via Google — set // 1) Auth account — appshell → Supabase (SSO users already have a session).
// a password so email+password login works too. Everyone else registers now. if (!sso) {
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; }
} }
@@ -114,8 +98,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
consentTerms: termsOk, consentPrivacy: privacyOk, consentTerms: termsOk, consentPrivacy: privacyOk,
}); });
} catch { } catch {
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; } // Non-fatal: the auth account already exists; the domain profile can be filled in later.
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
} }
} }
router.push("/dashboard"); router.push("/dashboard");
@@ -123,11 +106,10 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
return ( return (
<div className="card anim-fade-up"> <div className="card anim-fade-up">
<Stepper current={step} labels={STEP_LABELS} /> <Stepper current={step} />
{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}
@@ -141,7 +123,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
/> />
)} )}
{step === 1 && !onboard && ( {step === 1 && (
<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}
@@ -150,10 +132,10 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
/> />
)} )}
{((onboard && step === 1) || (!onboard && step === 2)) && ( {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(onboard ? 0 : 1)} onFinish={finish} /> <StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
</> </>
)} )}
</div> </div>
@@ -171,10 +153,9 @@ 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; onboard?: boolean; valid: boolean; onContinue: () => void; toLogin: () => void;
}) { }) {
// Onboarding (OAuth) starts on the profile step — email is already known + verified. const [phase, setPhase] = useState<"sso" | "profile">("sso");
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") {
@@ -209,7 +190,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} /> {p.onboard ? "Signed in as" : "Creating account for"} {p.email}</div> <div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
)} )}
</div> </div>
@@ -308,7 +289,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}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button> <button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account &amp; 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); }} />}
@@ -600,10 +581,10 @@ function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a:
} }
/* ====================== stepper ====================== */ /* ====================== stepper ====================== */
function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) { function Stepper({ current }: { current: number }) {
return ( return (
<div className="steps"> <div className="steps">
{labels.map((label, i) => { {STEPS.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" }}>