From da7f7a7891a9008413eefbc7676bfad5b46ae098 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Mon, 13 Jul 2026 04:29:02 +0530 Subject: [PATCH] feat(register/onboard): real phone OTP verification via Twilio (SDK 0.2.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the mock verify step (which accepted any 6 digits) with a real SMS OTP: addPhone() → Supabase/Twilio texts a code → verifyPhone() confirms it. - Registration creates the auth account when leaving the Account step, so the phone can be attached + verified against a live Supabase session; finish() no longer double-registers. - Onboarding gains a real phone-verify step (Profile → Verify → Address). - Email is trusted without an OTP in both flows (Supabase auto-confirms on signup; Google verifies for onboarding), matching project config. - Bump @abe-kap/appshell-sdk to ^0.2.6. --- package-lock.json | 8 +- package.json | 2 +- src/components/portal/register-flow.tsx | 215 +++++++++++++----------- 3 files changed, 125 insertions(+), 100 deletions(-) diff --git a/package-lock.json b/package-lock.json index da13be9..9ea699a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "lynkeduppro-crm", "version": "0.1.0", "dependencies": { - "@abe-kap/appshell-sdk": "^0.2.5", + "@abe-kap/appshell-sdk": "^0.2.6", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", @@ -28,9 +28,9 @@ } }, "node_modules/@abe-kap/appshell-sdk": { - "version": "0.2.5", - "resolved": "https://npm.pkg.github.com/download/@abe-kap/appshell-sdk/0.2.5/76bddffe21164d833c4a63adbb6409930dbc1356", - "integrity": "sha512-iHkdtkUs+sgiEY+2xbQDlm9WOtEbrbB0Z/YoiBZrXM64PT4+gcgFf3RPpKNSqqkVT6LzoL2mZjulFpSMCLw+fA==", + "version": "0.2.6", + "resolved": "https://npm.pkg.github.com/download/@abe-kap/appshell-sdk/0.2.6/d47293556f910a31ed189bbabab4c81022549744", + "integrity": "sha512-OAMY3Lhahdl8lSvsNT5uBZFZVeV2Nr0E/QDIqWErZj8Q4jbysHLNnF0/ra/yrmNfVSZg3wPtV5OADVG67r6jqA==", "license": "MIT", "dependencies": { "@supabase/supabase-js": "^2.108.2", diff --git a/package.json b/package.json index 4d2181b..e695282 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "@abe-kap/appshell-sdk": "^0.2.5", + "@abe-kap/appshell-sdk": "^0.2.6", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx index c498991..cd15d9c 100644 --- a/src/components/portal/register-flow.tsx +++ b/src/components/portal/register-flow.tsx @@ -25,10 +25,14 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa // we only SET a password + persist the profile, and the OTP-verify step is skipped. const onboard = mode === "onboard"; const router = useRouter(); - const { register, setPassword, getUserEmail } = useAuth(); + const { register, setPassword, getUserEmail, addPhone, verifyPhone } = useAuth(); const { sdk } = useAppShell(); const [step, setStep] = useState(0); const [submitErr, setSubmitErr] = useState(""); + // Verifying a phone via Supabase needs a live session. Onboarding already has one + // (Google OAuth); registration creates the account when leaving the Account step, + // then attaches + verifies the phone on it. `creating` guards the Account button. + const [creating, setCreating] = useState(false); // address (lifted from StepAddress/AddressBlock so finish() can persist it) const [regAddr, setRegAddr] = useState({}); @@ -50,8 +54,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa const [termsOk, setTermsOk] = useState(false); const [privacyOk, setPrivacyOk] = useState(false); - // verify step - const [emailVerified, setEmailVerified] = useState(false); + // verify step (email is trusted without an OTP — see verifyValid below) const [phoneVerified, setPhoneVerified] = useState(false); // Onboarding: prefill the verified email — from the session-storage hint the login @@ -63,7 +66,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa // eslint-disable-next-line react-hooks/exhaustive-deps }, [onboard]); - const STEP_LABELS = onboard ? ["Profile", "Address"] : STEPS; + const STEP_LABELS = onboard ? ["Profile", "Verify", "Address"] : STEPS; const isSelf = relationship === "Customer" || relationship === "Owner"; const isEmployee = relationship === "Employee"; @@ -76,7 +79,26 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk && termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim())))); - const step2Valid = emailVerified && phoneVerified; + // Email is already trusted in both flows (registration: Supabase auto-confirms on + // signup; onboarding: Google-verified), so the Verify step only gates on the phone. + const verifyValid = phoneVerified; + + // Registration: create the auth account when leaving the Account step, so the phone + // can be attached + verified against a live session at the Verify step. Onboarding + // is already authenticated, so it just advances. + async function leaveAccountStep() { + if (onboard || !isShellConfigured() || sso) { setStep(1); return; } + setSubmitErr(""); + setCreating(true); + try { + await register(email, pw); + setStep(1); + } catch { + setSubmitErr("We couldn't create that account. The email may already be registered."); + } finally { + setCreating(false); + } + } async function finish() { const finalEmail = sso?.email || email; @@ -91,13 +113,12 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa if (isShellConfigured()) { setSubmitErr(""); - // 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; } + // 1) Auth account already exists at this point — registration created it when + // leaving the Account step; onboarding users signed in via Google. Onboarding + // additionally sets a password so email+password login works too (the phone was + // just verified against the same live session). + if (onboard && pw) { + try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ } } // 2) Persist the full CRM registration payload to be-crm (name, phone, persona, // allottee, both addresses, consent). Non-fatal: the auth account exists either way. @@ -130,34 +151,36 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa {step === 0 && ( - setStep(1)} toLogin={() => router.push("/portal/login")} - /> - )} - - {step === 1 && !onboard && ( - setStep(0)} onContinue={() => setStep(2)} - /> - )} - - {((onboard && step === 1) || (!onboard && step === 2)) && ( <> {submitErr &&
{submitErr}
} - setStep(onboard ? 0 : 1)} onFinish={finish} /> + router.push("/portal/login")} + /> + + )} + + {step === 1 && ( + setStep(0)} onContinue={() => setStep(2)} + /> + )} + + {step === 2 && ( + <> + {submitErr &&
{submitErr}
} + setStep(1)} onFinish={finish} /> )} @@ -175,7 +198,7 @@ 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; onboard?: boolean; + valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; }) { // Onboarding (OAuth) starts on the profile step — email is already known + verified. const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso"); @@ -313,7 +336,9 @@ 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); }} />} @@ -323,10 +348,13 @@ function StepAccount(p: { // module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read const reviewed = { terms: false, privacy: false }; -/* ====================== STEP 1 — VERIFY ====================== */ +/* ====================== STEP 1 — VERIFY PHONE ====================== + Email is already trusted (registration: Supabase auto-confirms on signup; + onboarding: Google-verified), so this step only verifies the phone via a real + SMS OTP: addPhone() → Twilio texts a code → verifyPhone() confirms it. Both + run against the live Supabase session established in the previous step. */ function StepVerify(p: { - emailValue: string; cc: string; phone: string; country: typeof countryCodes[number]; - emailVerified: boolean; setEmailVerified: (v: boolean) => void; + cc: string; phone: string; country: typeof countryCodes[number]; phoneVerified: boolean; setPhoneVerified: (v: boolean) => void; valid: boolean; onBack: () => void; onContinue: () => void; }) { @@ -334,12 +362,11 @@ function StepVerify(p: { return (
-

Verify email & phone

-

Confirm both so we can secure your account.

+

Verify your phone

+

We'll text a one-time code to confirm your number. Your email is already verified.

- p.setEmailVerified(true)} /> - p.setPhoneVerified(true)} /> + p.setPhoneVerified(true)} />
@@ -348,40 +375,47 @@ function StepVerify(p: { ); } -type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile"; -function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: { - kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string; +type SendState = "idle" | "sending" | "sent" | "invalid_mobile" | "send_error"; +function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: { + cc: string; country: typeof countryCodes[number]; initialPhone: string; verified: boolean; onVerified: () => void; }) { - const [value, setValue] = useState(kind === "email" ? initial : initialPhone); - const [via, setVia] = useState<"primary" | "wa">("primary"); + const { addPhone, verifyPhone } = useAuth(); + const [value, setValue] = useState(initialPhone); const [state, setState] = useState("idle"); - const [otpError, setOtpError] = useState(false); - const primaryLabel = kind === "email" ? "Email" : "SMS"; + const [otpError, setOtpError] = useState(""); + const e164 = `${cc}${value.replace(/\D/g, "")}`; - function send() { + async function send() { + if (value.replace(/\D/g, "").length !== country.digits) { setState("invalid_mobile"); return; } setState("sending"); - setTimeout(() => { - if (kind === "email") { - if (!EMAIL_RE.test(value)) return setState("invalid_email"); - if (value.includes("full")) return setState("mailbox_full"); - if (value.includes("bo")) return setState("bounce_risk"); - return setState("ok"); - } else { - if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile"); - return setState("ok"); - } - }, 700); + setOtpError(""); + try { + await addPhone(e164); // Supabase → Twilio sends the SMS OTP + setState("sent"); + } catch { + setState("send_error"); + } + } + + async function submit(code: string) { + setOtpError(""); + try { + await verifyPhone(e164, code); // confirm the OTP (phone_change) + onVerified(); + } catch { + setOtpError("That code didn't match. Check the SMS and try again."); + } } if (verified) { return (
- {kind === "email" ? "Email" : "Mobile"} + Mobile Verified
-

{kind === "email" ? value : `${cc} ${value}`}

+

{cc} {value}

); } @@ -389,43 +423,34 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV return (
- {kind === "email" ? "Email address" : "Mobile number"} + Mobile number
- {kind === "email" ? ( - { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" /> - ) : ( -
- - {/* eslint-disable-next-line @next/next/no-img-element */} - {country.name} - {cc} - - { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} /> -
- )} +
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + {country.name} + {cc} + + { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} /> +
-
- - -
-
- {state === "ok" &&

OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.

} - {state === "invalid_email" &&
That email address looks invalid.
} - {state === "mailbox_full" &&
This mailbox appears full — try another email.
} - {state === "bounce_risk" &&
High bounce risk for this address.
} + {state === "sent" &&

Code sent to {cc} {value} by SMS.

} {state === "invalid_mobile" &&
Enter a valid {country.digits}-digit mobile number.
} + {state === "send_error" &&
Couldn't send the code. Check the number and try again.
} - {state === "ok" && ( + {state === "sent" && (
- { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} /> - {otpError &&
Incorrect code.
} -
+ + {otpError &&
{otpError}
} +
)}