30 Commits

Author SHA1 Message Date
tanweer919 da7f7a7891 feat(register/onboard): real phone OTP verification via Twilio (SDK 0.2.6)
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.
2026-07-13 04:29:02 +05:30
tanweer919 3c6190d16a Merge pull request 'fix(onboarding): reliably prefill verified email (SDK 0.2.5)' (#15) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:36:29 +00:00
tanweer919 6ba00bfbf9 fix(onboarding): reliably prefill the verified email (SDK 0.2.5)
getUserEmail() relied on getSession(), which returns null here because the
Supabase session is intentionally never persisted to storage — so the
onboarding email field stayed empty and Continue was blocked. SDK 0.2.5
captures the email in memory from the token-exchange response instead.
Bump @abe-kap/appshell-sdk to ^0.2.5.
2026-07-13 04:06:13 +05:30
tanweer919 5e2fa574bd Merge pull request 'fix(onboarding): gate direct access behind OAuth handoff' (#14) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:32:35 +00:00
tanweer919 d79da8cd6a fix(onboarding): gate direct access behind the OAuth handoff
/portal/onboarding is only a step in the Google sign-in flow. Redirect to
login unless the login page's onboard_email handoff hint is present (and the
session is authenticated), instead of showing an empty form on direct/typed
URL access. Hold rendering until the check passes to avoid a form flash, and
clear the hint once onboarding completes so it can't be reused.
2026-07-13 04:01:01 +05:30
tanweer919 10141806dc Merge pull request 'fix(onboarding): prefill verified email; SDK 0.2.4' (#13) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:26:05 +00:00
tanweer919 08ef85869f fix(onboarding): prefill verified email in a disabled field; bump SDK 0.2.4
- Onboarding reads the OAuth email (session-storage hint + live Supabase
  session via getUserEmail) so Continue is no longer disabled.
- Replace the empty 'Signed in as' banner with a disabled, prefilled
  email field (email is fixed — Google-verified).
- Stash the verified email at OAuth callback time for onboarding prefill.
- Bump @abe-kap/appshell-sdk to ^0.2.4 (getUserEmail via getSession).
2026-07-13 03:55:05 +05:30
tanweer919 619ec4c9b1 Merge pull request 'feat(auth): Google onboarding flow (option 3)' (#12) from feat/login-methods into goutamnextflow 2026-07-12 22:06:03 +00:00
tanweer919 948abf75fd feat(auth): first-time Google onboarding (option 3)
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.
2026-07-13 03:35:55 +05:30
tanweer919 66cd8953ba Merge pull request 'feat(auth): gate Google sign-in on CRM profile' (#11) from feat/login-methods into goutamnextflow 2026-07-12 21:52:11 +00:00
tanweer919 34cb44cfa8 feat(auth): gate Google sign-in on an existing CRM profile
Google is sign-IN only: after OAuth, check crm.account.registrationStatus; if the
account has no CRM profile, sign back out and show an error on the login screen
instead of admitting a profile-less user. (Onboarding to collect the profile comes later.)
2026-07-13 03:22:04 +05:30
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 f38fa80bde polish(dashboard): show real ACE role in user chip instead of static 'Property Owner' 2026-07-13 03:03:17 +05:30
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 86af77c92b feat(dashboard): real user in sidebar + sign-out; strip demo/mock hints
- Sidebar footer now shows the ACE identity (was static 'James Carter') with a
  Sign-out menu; topbar user gets a Sign-out dropdown too. Both call useAuth().logout.
- Remove all visible 'Demo:' hints from login + register and the 'Demo mode' subtitle.
2026-07-13 02:57:51 +05:30
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 27e7bdf8f5 fix(auth): bump appshell-sdk 0.2.2 (ready-after-restore) — session persists on reload 2026-07-13 02:41:39 +05:30
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 64be4b9b3f fix(auth): don't redirect from dashboard until SDK boot completes
AuthGate redirected to /portal/login whenever status !== authenticated, which
during boot (before restore() resolves) dropped a valid session on reload. Gate
the redirect on useAppShell().ready so we only bounce once auth has resolved.
2026-07-13 02:35:13 +05:30
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 5186497c50 chore(auth): remove debug logging from OAuth + registration error paths 2026-07-13 02:17:22 +05:30
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 0e8a207640 fix(auth): bump appshell-sdk 0.2.1 (PKCE verifier fix); tidy OAuth error handling 2026-07-13 02:02:24 +05:30
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 7570fe7be3 fix(auth): surface the real OAuth completion error (console + flash) 2026-07-13 01:45:10 +05:30
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 6e1a895e7e fix(auth): complete Google OAuth after SDK boot (was stuck on ?code=…#identify)
The OAuth-return effect ran on mount before AppShell finished booting, so
completeOAuthLogin no-oped (sdk.auth undefined → null) and fell back to identify,
never retrying. Gate it on useAppShell().ready so it runs once auth is up, and
strip ?code= from the URL on success.
2026-07-13 01:31:37 +05:30
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 74caae0710 feat(auth): Google-only social; sign-up email/password only; phone login entry
- SocialButtons: only Google (Microsoft/Apple hidden).
- Sign-up: remove all social (email/password only).
- Login: keep Google + email/password (single step) + email/SMS OTP; add a direct
  'Sign in with a phone number' entry (SMS OTP), and rename the OTP link to
  'Sign in with a one-time code'. All Supabase-supported; no mandatory 2FA.
2026-07-11 20:48:47 +05:30
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
11 changed files with 1128 additions and 180 deletions
+1
View File
@@ -41,3 +41,4 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
.vercel .vercel
.env*.local
+780 -20
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@abe-kap/appshell-sdk": "^0.2.0", "@abe-kap/appshell-sdk": "^0.2.6",
"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",
+5
View File
@@ -106,6 +106,11 @@
.sb-user .av { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; flex: 0 0 30px; } .sb-user .av { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; flex: 0 0 30px; }
.sb-user .nm { font-size: 12.5px; font-weight: 600; } .sb-user .nm { font-size: 12.5px; font-weight: 600; }
.sb-user .rl { font-size: 11px; color: var(--faint); } .sb-user .rl { font-size: 11px; color: var(--faint); }
.sb-user .nm, .sb-user .rl { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-user-menu { position: absolute; bottom: 56px; left: 12px; right: 12px; z-index: 30; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2); background: var(--panel); box-shadow: var(--shadow), 0 8px 30px -12px rgba(0,0,0,0.55); animation: ds-rise 0.14s ease; }
.sb-user-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
.sb-user-menu button:hover { background: var(--panel-2); }
.sb-user-menu button.danger { color: var(--red, #ef4444); }
/* ---- main ---- */ /* ---- main ---- */
.dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } .dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } 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).
*
* This screen is ONLY a step in the OAuth onboarding handoff: the login page
* stashes the verified email in `sessionStorage` right before routing here. Any
* other way in a typed URL, a fresh tab, a lost session has no handoff hint
* (and possibly no session), so we bounce back to sign in rather than showing an
* empty form.
*/
export default function OnboardingPage() {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
if (!isShellConfigured()) { setAllowed(true); return; } // local dev without the Shell
if (!ready) return; // wait for session restore
let hasHandoff = false;
try { hasHandoff = !!sessionStorage.getItem("onboard_email"); } catch { /* ignore */ }
if (status === "unauthenticated" || !hasHandoff) {
router.replace("/portal/login");
return;
}
setAllowed(true);
}, [ready, status, router]);
if (!allowed) return <main className="portal-main"><span className="portal-grid" /></main>;
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 -5
View File
@@ -2,7 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@abe-kap/appshell-sdk/react"; import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell"; import { isShellConfigured } from "@/lib/appshell";
/** /**
@@ -14,16 +14,21 @@ import { isShellConfigured } from "@/lib/appshell";
export function AuthGate({ children }: { children: React.ReactNode }) { export function AuthGate({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const { status } = useAuth(); const { status } = useAuth();
const { ready } = useAppShell();
const shell = isShellConfigured(); const shell = isShellConfigured();
useEffect(() => { useEffect(() => {
if (shell && status === "unauthenticated") router.replace("/portal/login"); // Only bounce to login once the SDK has finished booting AND restore() has
}, [shell, status, router]); // resolved to unauthenticated. Redirecting while boot is still in flight would
// drop a perfectly valid session on reload (the status is transiently not-yet
// "authenticated" during boot).
if (shell && ready && status === "unauthenticated") router.replace("/portal/login");
}, [shell, ready, status, router]);
if (shell && status !== "authenticated") { if (shell && (!ready || status !== "authenticated")) {
return ( return (
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}> <div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
{status === "loading" ? "Loading your workspace…" : "Redirecting to sign in…"} {ready && status === "unauthenticated" ? "Redirecting to sign in…" : "Loading your workspace…"}
</div> </div>
); );
} }
+38 -7
View File
@@ -1,9 +1,16 @@
"use client"; "use client";
import { ChevronsUpDown } from "lucide-react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui"; import { Icon } from "./ui";
import { user } from "./account-data"; import { user } from "./account-data";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
export type NavItem = { key: string; label: string; icon: string; subtitle?: string }; export type NavItem = { key: string; label: string; icon: string; subtitle?: string };
export type NavGroup = { title: string; items: NavItem[] }; export type NavGroup = { title: string; items: NavItem[] };
@@ -61,6 +68,22 @@ export const NAV_GROUPS: NavGroup[] = [
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items); export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) { export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
const router = useRouter();
const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
// Real signed-in identity from the App Context Envelope; fall back to the static
// demo user only when the Shell isn't wired.
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore — proceed to portal either way */ }
router.replace("/portal/login");
}
return ( return (
<aside className="dash-sidebar"> <aside className="dash-sidebar">
<div className="dash-brand"> <div className="dash-brand">
@@ -82,12 +105,20 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
))} ))}
</nav> </nav>
<div className="sb-foot"> <div className="sb-foot" style={{ position: "relative" }}>
<button className="sb-user"> {menuOpen && (
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span> <>
<div style={{ flex: 1, textAlign: "left" }}> <div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="nm">{user.name}</div> <div className="sb-user-menu" role="menu">
<div className="rl">{user.role}</div> <button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
<button className="sb-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div>
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{secondary}</div>
</div> </div>
<ChevronsUpDown size={15} /> <ChevronsUpDown size={15} />
</button> </button>
+35 -12
View File
@@ -1,20 +1,33 @@
"use client"; "use client";
import { Sun, Moon, ChevronDown } from "lucide-react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui"; import { Icon } from "./ui";
import { user } from "./account-data"; import { user } from "./account-data";
import { useAuth } from "@abe-kap/appshell-sdk/react";
function initialsOf(name: string): string { function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase(); return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
} }
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) { export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
// When signed in through the Shell, show the real identity from the App Context // When signed in through the Shell, show the real identity from the App Context
// Envelope; otherwise fall back to the static demo user. // Envelope; otherwise fall back to the static demo user.
const { user: me } = useAuth(); const router = useRouter();
const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name; const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials; const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore */ }
router.replace("/portal/login");
}
return ( return (
<header className="dash-topbar"> <header className="dash-topbar">
<div className="dash-title"> <div className="dash-title">
@@ -30,14 +43,24 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
<Icon name="bell" size={18} /> <Icon name="bell" size={18} />
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} /> <span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
</button> </button>
<button className="top-user"> <div className="top-user-wrap" style={{ position: "relative" }}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span> <button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<div style={{ textAlign: "left" }}> <span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div className="nm">{name}</div> <div style={{ textAlign: "left", minWidth: 0 }}>
<div className="rl">{user.role}</div> <div className="nm">{name}</div>
</div> <div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 150 }}>{secondary}</div>
<ChevronDown size={16} /> </div>
</button> <ChevronDown size={16} />
</button>
{menuOpen && (
<>
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="tm-menu-pop" role="menu">
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
</div>
</div> </div>
</header> </header>
); );
+2 -7
View File
@@ -145,18 +145,13 @@ export function startSocial(provider: "google" | "microsoft" | "apple"): boolean
if (url) { window.location.href = url; return true; } if (url) { window.location.href = url; return true; }
return false; return false;
} }
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) { export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google") => void; verb?: string }) {
// Only Google is offered (Microsoft/Apple intentionally hidden).
return ( return (
<div className="col gap-3"> <div className="col gap-3">
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}> <button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
<GoogleMark /> {verb} with Google <GoogleMark /> {verb} with Google
</button> </button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("microsoft")}>
<MicrosoftMark /> {verb} with Microsoft / Outlook
</button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("apple")}>
<AppleMark /> {verb} with Apple
</button>
</div> </div>
); );
} }
+56 -22
View File
@@ -9,7 +9,7 @@ import {
PasswordStrength, PasswordStrength,
} from "./bits"; } from "./bits";
import { lookupAccount, maskEmail, type Account } from "./data"; import { lookupAccount, maskEmail, type Account } from "./data";
import { useAuth } from "@abe-kap/appshell-sdk/react"; import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell"; import { isShellConfigured } from "@/lib/appshell";
// Map the portal's social button ids to Supabase OAuth provider ids. // Map the portal's social button ids to Supabase OAuth provider ids.
@@ -24,13 +24,26 @@ 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, getUserEmail } = useAuth();
const { ready, sdk } = 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);
const [provider, setProvider] = useState<string>(""); const [provider, setProvider] = useState<string>("");
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const [flash, setFlash] = useState<string>(""); const [flash, setFlash] = useState<string>("");
const [otpChannel, setOtpChannel] = useState<"email" | "sms">("email");
// Minimal account stand-in for passwordless entry points (Shell mode).
function blankAccount(): Account {
return { firstName: "", name: "", initials: "", hasPasskey: false, hasTotp: false, hasPush: false, maskedEmail: maskEmail(email || ""), maskedPhone: "", maskedWa: "" };
}
// Direct "sign in with phone" → the one-time-code screen, SMS preselected.
function startPhoneLogin() {
setAccount(blankAccount());
setOtpChannel("sms");
replace("otp");
}
/* ---- history hash sync ---- */ /* ---- history hash sync ---- */
function push(s: Step) { function push(s: Step) {
@@ -58,11 +71,31 @@ export function LoginFlow() {
if (!isShellConfigured()) return; if (!isShellConfigured()) return;
const code = new URLSearchParams(window.location.search).get("code"); const code = new URLSearchParams(window.location.search).get("code");
if (!code) return; if (!code) return;
// Wait until the SDK has booted — completeOAuthLogin no-ops (returns null) if
// sdk.auth isn't ready yet, and this effect only re-runs when `ready` flips.
if (!ready) { setStep("connecting"); return; }
setStep("connecting"); setStep("connecting");
completeOAuthLogin(code) completeOAuthLogin(code)
.then((ace) => { if (ace) router.replace("/dashboard"); else replace("identify"); }) .then(async (ace) => {
.catch(() => { setFlash("Sign-in with that provider didn't complete. Try again."); replace("identify"); }); if (!ace) { replace("identify"); return; }
}, [completeOAuthLogin, router]); // 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) { router.replace("/dashboard"); return; }
// Capture the verified email now (session is fresh) so onboarding prefills it.
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding");
})
.catch(() => {
setFlash("Google sign-in didn't complete. Please try again.");
replace("identify");
});
}, [ready, completeOAuthLogin, router, sdk, getUserEmail]);
/* ---- auth resolution ---- */ /* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") { function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
@@ -100,6 +133,7 @@ export function LoginFlow() {
hasPasskey: false, hasTotp: false, hasPush: false, hasPasskey: false, hasTotp: false, hasPush: false,
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "", maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
}); });
setOtpChannel("email");
replace("password"); replace("password");
return; return;
} }
@@ -116,14 +150,18 @@ export function LoginFlow() {
/* =================================================================== */ /* =================================================================== */
return ( return (
<div className="card anim-fade-up" key={step}> <div className="card anim-fade-up" key={step}>
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} toRegister={() => router.push("/portal/register")} />} {step === "identify" && (
<>
{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">
<Spinner lg /> <Spinner lg />
<div> <div>
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1> <h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1>
{provider && <p className="sub" style={{ marginTop: 6 }}>Demo mode no provider keys configured.</p>}
</div> </div>
</div> </div>
)} )}
@@ -162,11 +200,11 @@ export function LoginFlow() {
)} )}
{step === "password" && account && ( {step === "password" && account && (
<Password account={account} email={email} login={login} onAuthenticated={() => router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} /> <Password account={account} email={email} login={login} onAuthenticated={() => router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
)} )}
{step === "otp" && account && ( {step === "otp" && account && (
<OtpVerify account={account} email={email} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} /> <OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
)} )}
{step === "another" && account && ( {step === "another" && account && (
@@ -230,9 +268,9 @@ export function LoginFlow() {
/* ============================ screens ============================ */ /* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: { function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
email: string; setEmail: (v: string) => void; email: string; setEmail: (v: string) => void;
onSocial: (p: "google" | "microsoft" | "apple") => void; onEmail: () => void; toRegister: () => void; onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void;
}) { }) {
const [showEmail, setShowEmail] = useState(false); const [showEmail, setShowEmail] = useState(false);
const valid = EMAIL_RE.test(email); const valid = EMAIL_RE.test(email);
@@ -263,6 +301,9 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
</button> </button>
</form> </form>
)} )}
{isShellConfigured() && (
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
)}
</div> </div>
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p> <p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
@@ -271,7 +312,6 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
<span><Icon name="shield" size={13} /> Licensed &amp; Insured</span> <span><Icon name="shield" size={13} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={13} /> Drone-Powered</span> <span><Icon name="check" size={13} /> Drone-Powered</span>
</div> </div>
<p className="demo-hint">Demo: any email signs in; an email starting with new shows not found.</p>
</div> </div>
); );
} }
@@ -306,7 +346,6 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
<button className="link" onClick={onPassword}>Use your password instead</button> <button className="link" onClick={onPassword}>Use your password instead</button>
<button className="link" onClick={onAnother}>Try another way</button> <button className="link" onClick={onAnother}>Try another way</button>
</div> </div>
<p className="demo-hint">Demo: passkey always succeeds here.</p>
<button hidden onClick={onFail} /> <button hidden onClick={onFail} />
</div> </div>
); );
@@ -357,20 +396,19 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor
</form> </form>
<div className="row between" style={{ marginTop: 16 }}> <div className="row between" style={{ marginTop: 16 }}>
<button className="link" onClick={onForgot}>Forgot password?</button> <button className="link" onClick={onForgot}>Forgot password?</button>
<button className="link" onClick={onOtp}>Sign in using email OTP</button> <button className="link" onClick={onOtp}>Sign in with a one-time code</button>
</div> </div>
{!isShellConfigured() && <p className="demo-hint">Demo: any password works; type wrong to see the lockout. Password always needs 2-step next.</p>}
</div> </div>
); );
} }
function OtpVerify({ account, email, remember, setRemember, onBack, onVerified, onAuthenticated }: { function OtpVerify({ account, email, initialChannel = "email", remember, setRemember, onBack, onVerified, onAuthenticated }: {
account: Account; email: string; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void; account: Account; email: string; initialChannel?: "email" | "sms"; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
}) { }) {
const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth(); const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
const shell = isShellConfigured(); const shell = isShellConfigured();
// In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp. // In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email"); const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel);
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -440,7 +478,6 @@ function OtpVerify({ account, email, remember, setRemember, onBack, onVerified,
<span /> <span />
</div> </div>
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." /> <RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
{!shell && <p className="demo-hint">Demo: any 6 digits verify; 000000 shows an error.</p>}
</div> </div>
); );
} }
@@ -495,7 +532,6 @@ function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"} {setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
</button> </button>
<RememberDevice checked={remember} onChange={setRemember} /> <RememberDevice checked={remember} onChange={setRemember} />
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails.</p>
</div> </div>
); );
} }
@@ -514,7 +550,6 @@ function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onA
<p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p> <p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p>
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div> <div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>} {!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
</div> </div>
); );
} }
@@ -598,7 +633,6 @@ function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void })
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div> <div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>} {error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div> <div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
<p className="demo-hint">Demo: any 6 digits work; 000000 fails.</p>
</div> </div>
); );
} }
+144 -106
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { Icon, Avatar } from "./icons"; import { Icon, Avatar } from "./icons";
import { import {
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice, StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
SocialButtons, startSocial, PasswordStrength, LegalModal, PasswordStrength, LegalModal,
} from "./bits"; } from "./bits";
import { import {
countryCodes, relationshipOptions, addressCountries, countryCodes, relationshipOptions, addressCountries,
@@ -19,12 +19,20 @@ 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, addPhone, verifyPhone } = 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("");
// 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) // address (lifted from StepAddress/AddressBlock so finish() can persist it)
const [regAddr, setRegAddr] = useState<Addr>({}); const [regAddr, setRegAddr] = useState<Addr>({});
@@ -46,10 +54,20 @@ export function RegisterFlow() {
const [termsOk, setTermsOk] = useState(false); const [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = useState(false); const [privacyOk, setPrivacyOk] = useState(false);
// verify step // verify step (email is trusted without an OTP — see verifyValid below)
const [emailVerified, setEmailVerified] = useState(false);
const [phoneVerified, setPhoneVerified] = useState(false); const [phoneVerified, setPhoneVerified] = useState(false);
// Onboarding: prefill the verified email — from the session-storage hint the login
// page stashed at OAuth time, then confirmed via the live Supabase session.
useEffect(() => {
if (!onboard) return;
try { const cached = sessionStorage.getItem("onboard_email"); if (cached) setEmail(cached); } catch { /* ignore */ }
getUserEmail().then((e) => { if (e) setEmail(e); }).catch(() => { /* ignore */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboard]);
const STEP_LABELS = onboard ? ["Profile", "Verify", "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)!;
@@ -61,7 +79,26 @@ export function RegisterFlow() {
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk && first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim())))); 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() { async function finish() {
const finalEmail = sso?.email || email; const finalEmail = sso?.email || email;
@@ -76,10 +113,12 @@ export function RegisterFlow() {
if (isShellConfigured()) { if (isShellConfigured()) {
setSubmitErr(""); setSubmitErr("");
// 1) Auth account — appshell → Supabase (SSO users already have a session). // 1) Auth account already exists at this point — registration created it when
if (!sso) { // leaving the Account step; onboarding users signed in via Google. Onboarding
try { await register(finalEmail, pw); } // additionally sets a password so email+password login works too (the phone was
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; } // 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, // 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. // allottee, both addresses, consent). Non-fatal: the auth account exists either way.
@@ -97,38 +136,44 @@ export function RegisterFlow() {
mailingSameAsRegistered: mailingSame, mailingSameAsRegistered: mailingSame,
consentTerms: termsOk, consentPrivacy: privacyOk, consentTerms: termsOk, consentPrivacy: privacyOk,
}); });
} catch (e) { } catch {
console.warn("crm.account.register failed (continuing):", e); 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.
} }
} }
// Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ }
router.push("/dashboard"); router.push("/dashboard");
} }
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 <>
sso={sso} setSso={setSso} email={email} setEmail={setEmail} {submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
first={first} setFirst={setFirst} last={last} setLast={setLast} <StepAccount
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country} onboard={onboard} creating={creating}
pw={pw} setPw={setPw} sso={sso} setSso={setSso} email={email} setEmail={setEmail}
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf} first={first} setFirst={setFirst} last={last} setLast={setLast}
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk} cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast} pw={pw} setPw={setPw}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk} relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
valid={!!step0Valid} alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
onContinue={() => setStep(1)} toLogin={() => router.push("/portal/login")} alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
/> termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid}
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
/>
</>
)} )}
{step === 1 && ( {step === 1 && (
<StepVerify <StepVerify
emailValue={sso?.email || email} cc={cc} phone={phone} country={country} cc={cc} phone={phone} country={country}
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified} phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={step2Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)} valid={verifyValid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/> />
)} )}
@@ -153,28 +198,19 @@ 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; creating?: 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);
function pickSso(provider: "google" | "microsoft" | "apple") {
if (startSocial(provider)) return;
const mockEmail = `you@${provider === "microsoft" ? "outlook.com" : provider + ".com"}`;
p.setSso({ provider, email: mockEmail });
p.setEmail(mockEmail);
setPhase("profile");
}
if (phase === "sso") { if (phase === "sso") {
const valid = EMAIL_RE.test(p.email); const valid = EMAIL_RE.test(p.email);
return ( return (
<div> <div>
<h1>Create your account</h1> <h1>Create your account</h1>
<p className="sub">Sign up with a provider or your email to get started.</p> <p className="sub">Enter your email to get started.</p>
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<SocialButtons onPick={pickSso} verb="Sign up" />
<div className="divider">or continue with email</div>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}> <form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<div className="field"> <div className="field">
<label className="label">Email address</label> <label className="label">Email address</label>
@@ -196,12 +232,13 @@ function StepAccount(p: {
<h1>Complete your profile</h1> <h1>Complete your profile</h1>
<p className="sub">Tell us a bit about you to set up your account.</p> <p className="sub">Tell us a bit about you to set up your account.</p>
<div style={{ marginTop: 16 }}> <div className="field" style={{ marginTop: 16 }}>
{p.sso ? ( <label className="label">Email address</label>
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div> <div className="input-wrap">
) : ( <span className="input-ico"><Icon name="mail" size={17} /></span>
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div> <input className="input" type="email" value={p.email} disabled readOnly aria-label="Email address" />
)} </div>
<span className="faint" style={{ fontSize: 12 }}>{p.onboard ? "Verified with Google — this can't be changed." : "The email you're registering with."}</span>
</div> </div>
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}> <div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
@@ -299,7 +336,9 @@ 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 &amp; Verify <Icon name="arrowR" size={16} /></button> <button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid || p.creating} onClick={p.onContinue}>
{p.creating ? "Creating account…" : p.onboard ? "Continue" : "Create Account & Verify"} {!p.creating && <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); }} />}
@@ -309,10 +348,13 @@ function StepAccount(p: {
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read // module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
const reviewed = { terms: false, privacy: false }; 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: { function StepVerify(p: {
emailValue: string; cc: string; phone: string; country: typeof countryCodes[number]; cc: string; phone: string; country: typeof countryCodes[number];
emailVerified: boolean; setEmailVerified: (v: boolean) => void;
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void; phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void; valid: boolean; onBack: () => void; onContinue: () => void;
}) { }) {
@@ -320,12 +362,11 @@ function StepVerify(p: {
return ( return (
<div> <div>
<StepBack onClick={p.onBack} /> <StepBack onClick={p.onBack} />
<h1>Verify email &amp; phone</h1> <h1>Verify your phone</h1>
<p className="sub">Confirm both so we can secure your account.</p> <p className="sub">We&apos;ll text a one-time code to confirm your number. Your email is already verified.</p>
<div className="col gap-4" style={{ marginTop: 16 }}> <div className="col gap-4" style={{ marginTop: 16 }}>
<VerifyChannel kind="email" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.emailVerified} onVerified={() => p.setEmailVerified(true)} /> <PhoneVerify cc={p.cc} country={p.country} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
<VerifyChannel kind="phone" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
</div> </div>
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div> <div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
@@ -334,40 +375,47 @@ function StepVerify(p: {
); );
} }
type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile"; type SendState = "idle" | "sending" | "sent" | "invalid_mobile" | "send_error";
function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: { function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: {
kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string; cc: string; country: typeof countryCodes[number]; initialPhone: string;
verified: boolean; onVerified: () => void; verified: boolean; onVerified: () => void;
}) { }) {
const [value, setValue] = useState(kind === "email" ? initial : initialPhone); const { addPhone, verifyPhone } = useAuth();
const [via, setVia] = useState<"primary" | "wa">("primary"); const [value, setValue] = useState(initialPhone);
const [state, setState] = useState<SendState>("idle"); const [state, setState] = useState<SendState>("idle");
const [otpError, setOtpError] = useState(false); const [otpError, setOtpError] = useState("");
const primaryLabel = kind === "email" ? "Email" : "SMS"; 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"); setState("sending");
setTimeout(() => { setOtpError("");
if (kind === "email") { try {
if (!EMAIL_RE.test(value)) return setState("invalid_email"); await addPhone(e164); // Supabase → Twilio sends the SMS OTP
if (value.includes("full")) return setState("mailbox_full"); setState("sent");
if (value.includes("bo")) return setState("bounce_risk"); } catch {
return setState("ok"); setState("send_error");
} else { }
if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile"); }
return setState("ok");
} async function submit(code: string) {
}, 700); 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) { if (verified) {
return ( return (
<div className="vchannel verified"> <div className="vchannel verified">
<div className="row between"> <div className="row between">
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email" : "Mobile"}</span> <span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile</span>
<Badge tone="green"><Icon name="check" size={12} /> Verified</Badge> <Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
</div> </div>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{kind === "email" ? value : `${cc} ${value}`}</p> <p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{cc} {value}</p>
</div> </div>
); );
} }
@@ -375,46 +423,36 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
return ( return (
<div className="vchannel"> <div className="vchannel">
<div className="row between" style={{ marginBottom: 12 }}> <div className="row between" style={{ marginBottom: 12 }}>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email address" : "Mobile number"}</span> <span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile number</span>
</div> </div>
{kind === "email" ? ( <div className="phone-row">
<input className="input" value={value} onChange={(e) => { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" /> <span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
) : ( {/* eslint-disable-next-line @next/next/no-img-element */}
<div className="phone-row"> <img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}> {cc}
{/* eslint-disable-next-line @next/next/no-img-element */} </span>
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} /> <input className="input grow" inputMode="numeric" value={value} disabled={state === "sent"} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
{cc} </div>
</span>
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
</div>
)}
<div className="row between" style={{ marginTop: 12 }}> <div className="row between" style={{ marginTop: 12 }}>
<div className="seg"> <span className="faint" style={{ fontSize: 12 }}>Standard SMS rates may apply.</span>
<button className={via === "primary" ? "on" : ""} onClick={() => setVia("primary")}>{primaryLabel}</button> <button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "sent"} onClick={send}>
<button className={via === "wa" ? "on" : ""} onClick={() => setVia("wa")}><Icon name="whatsapp" size={14} /> WhatsApp</button> {state === "sending" ? "Sending…" : state === "sent" ? "Sent" : "Send code"}
</div>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "ok"} onClick={send}>
{state === "sending" ? "Sending…" : "Send code"}
</button> </button>
</div> </div>
{state === "ok" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.</p>} {state === "sent" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>Code sent to {cc} {value} by SMS.</p>}
{state === "invalid_email" && <div style={{ marginTop: 10 }}><FlashNote tone="error">That email address looks invalid.</FlashNote></div>}
{state === "mailbox_full" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">This mailbox appears full try another email.</FlashNote></div>}
{state === "bounce_risk" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">High bounce risk for this address.</FlashNote></div>}
{state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>} {state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>}
{state === "send_error" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Couldn&apos;t send the code. Check the number and try again.</FlashNote></div>}
{state === "ok" && ( {state === "sent" && (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<OtpBoxes onComplete={(c) => { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} /> <OtpBoxes onComplete={submit} error={!!otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>} {otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">{otpError}</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div> <div style={{ marginTop: 12 }}><ResendLink seconds={60} onResend={send} /></div>
</div> </div>
)} )}
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails. Email with full/bo shows send errors.</p>
</div> </div>
); );
} }
@@ -592,10 +630,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" }}>