"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Icon, Avatar } from "./icons"; import { Spinner, StepBack, AccountPill, FlashNote, MethodRow, RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial, PasswordStrength, } from "./bits"; import { lookupAccount, maskEmail, type Account } from "./data"; import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react"; import { isShellConfigured } from "@/lib/appshell"; // Map the portal's social button ids to Supabase OAuth provider ids. const OAUTH_PROVIDER: Record = { google: "google", microsoft: "azure", apple: "apple" }; type Step = | "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password" | "otp" | "another" | "totp" | "push" | "error" | "terms" | "success" | "recovery" | "fp_confirm" | "fp_code" | "fp_reset"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function LoginFlow() { const router = useRouter(); const { login, loginWithOAuth, completeOAuthLogin } = useAuth(); const { ready } = useAppShell(); const [step, setStep] = useState("identify"); const [email, setEmail] = useState(""); const [account, setAccount] = useState(null); const [provider, setProvider] = useState(""); const [remember, setRemember] = useState(false); const [flash, setFlash] = useState(""); 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 ---- */ function push(s: Step) { setStep(s); try { window.history.pushState({ s }, "", `#${s}`); } catch { /* ignore */ } } function replace(s: Step) { setStep(s); try { window.history.replaceState({ s }, "", `#${s}`); } catch { /* ignore */ } } const back = () => window.history.back(); function switchAccount() { setAccount(null); setEmail(""); setFlash(""); replace("identify"); } useEffect(() => { const onPop = () => { const h = window.location.hash.replace("#", "") as Step; setStep(h || "identify"); }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); /* ---- OAuth return: finish the redirect started by loginWithOAuth ---- */ useEffect(() => { if (!isShellConfigured()) return; const code = new URLSearchParams(window.location.search).get("code"); 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"); completeOAuthLogin(code) .then((ace) => { if (ace) { // Clear the ?code=… from the URL, then enter the app. window.history.replaceState({}, "", "/portal/login"); router.replace("/dashboard"); } else { replace("identify"); } }) .catch((e) => { console.error("[oauth] Google sign-in failed:", e); setFlash(`Google sign-in didn't complete: ${(e as Error)?.message ?? "please try again"}`); replace("identify"); }); }, [ready, completeOAuthLogin, router]); /* ---- auth resolution ---- */ function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") { if (factor === "password") { setFlash(""); push("otp"); return; } let accepted = false; try { accepted = !!localStorage.getItem("lup_terms"); } catch { /* ignore */ } push(accepted ? "success" : "terms"); } function onSocial(p: "google" | "microsoft" | "apple") { if (isShellConfigured()) { // Real OAuth: appshell redirects to the provider; we finish on return (effect above). setProvider(p); push("connecting"); loginWithOAuth(OAUTH_PROVIDER[p] ?? p, `${window.location.origin}/portal/login`) .catch(() => { setFlash("Couldn't start sign-in with that provider."); replace("identify"); }); return; } const real = startSocial(p); if (real) return; // redirected away setProvider(p); push("connecting"); setTimeout(() => afterAuth("social"), 1300); } function identifyEmail() { if (!EMAIL_RE.test(email)) return; if (isShellConfigured()) { // No mock account directory when the Shell is live: any valid email goes to the // password screen; the BFF/Supabase decides if the account exists. const name = email.split("@")[0]; setAccount({ firstName: name.charAt(0).toUpperCase() + name.slice(1), name, initials: name.slice(0, 2).toUpperCase(), hasPasskey: false, hasTotp: false, hasPush: false, maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "", }); setOtpChannel("email"); replace("password"); return; } push("connecting"); setProvider(""); setTimeout(() => { const acc = lookupAccount(email); if (!acc) { replace("notfound"); return; } setAccount(acc); replace("primary"); }, 650); } /* =================================================================== */ return (
{step === "identify" && router.push("/portal/register")} />} {step === "connecting" && (

{provider ? `Connecting to ${cap(provider)}…` : "Looking up your account…"}

{provider &&

Demo mode — no provider keys configured.

}
)} {step === "notfound" && (
No account found for  {email}

We couldn't find that account

No account is on file for this email. Try another email or register a new account.

)} {step === "primary" && account && (

Hi {account.firstName}

Choose how you want to sign in.

replace("identify")} />
{account.hasPasskey && push("passkey")} />} push("password")} /> push("another")} />
)} {step === "passkey" && account && ( replace("password")} onAnother={() => replace("another")} onSuccess={() => afterAuth("passkey")} onFail={() => { setFlash("Passkey was cancelled or timed out."); replace("error"); }} /> )} {step === "password" && account && ( 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 && ( afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} /> )} {step === "another" && account && ( push(s)} /> )} {step === "totp" && account && ( afterAuth("totp")} /> )} {step === "push" && ( afterAuth("push")} onCancel={() => { setFlash("Sign-in request cancelled."); replace("another"); }} /> )} {step === "error" && (

Couldn't sign you in

{flash || "Something interrupted the sign-in."}

  • Make sure you're using the correct device or account.
  • Check your internet connection and try again.
  • You can switch to another verification method.
)} {step === "terms" && { try { localStorage.setItem("lup_terms", "1"); } catch { /* ignore */ } replace("success"); }} />} {step === "success" && ( router.push("/dashboard")} /> )} {step === "recovery" && (

Account recovery

None of the available methods worked. Our team can help verify your identity and restore access.

Contact support at help@lynkeduppro.com with your property or account details.
)} {step === "fp_confirm" && account && ( push("fp_code")} /> )} {step === "fp_code" && ( push("fp_reset")} /> )} {step === "fp_reset" && ( { setFlash("Password updated. Please sign in."); replace("password"); }} /> )}
); } /* ============================ screens ============================ */ function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: { email: string; setEmail: (v: string) => void; onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void; }) { const [showEmail, setShowEmail] = useState(false); const valid = EMAIL_RE.test(email); return (
Welcome back

Sign in to LynkedUp

Drone inspections, AI estimates and insurance-ready reports — all in one place.

or continue with email
{!showEmail ? ( ) : (
{ e.preventDefault(); onEmail(); }}>
setEmail(e.target.value)} placeholder="you@example.com" />
)} {isShellConfigured() && ( )}

New homeowner or contractor?

256-bit SSL Licensed & Insured Drone-Powered

Demo: any email signs in; an email starting with “new” shows “not found”.

); } function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }: { account: Account; onBack: () => void; onPassword: () => void; onAnother: () => void; onSuccess: () => void; onFail: () => void; }) { const [waiting, setWaiting] = useState(false); async function attempt() { setWaiting(true); try { if (navigator.credentials && "get" in navigator.credentials) { await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(32), rpId: window.location.hostname, userVerification: "preferred", timeout: 8000 }, } as CredentialRequestOptions).catch(() => null); } } catch { /* ignore */ } setTimeout(onSuccess, 900); // demo: succeed } return (

Use your passkey

Verify it's you with your device's fingerprint, face or screen lock.

{waiting ? (

Waiting for your device…

) : ( )}

Demo: passkey always succeeds here.

); } function Password({ account, email, login, onAuthenticated, flash, onBack, onForgot, onOtp, onLocked, onOk }: { account: Account; email: string; login: ReturnType["login"]; onAuthenticated: () => void; flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void; }) { const [pw, setPw] = useState(""); const [show, setShow] = useState(false); const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); async function submit(e: React.FormEvent) { e.preventDefault(); if (!pw || busy) return; if (isShellConfigured()) { // Real sign-in through appshell → BFF. A resolved ACE means fully authenticated // (Supabase handles any MFA), so go straight to the dashboard. setErr(""); setBusy(true); try { await login({ email, password: pw }); onAuthenticated(); } catch { setErr("locked"); onLocked(); } finally { setBusy(false); } return; } if (pw.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; } onOk(); } return (

Enter your password

Signing in as {account.maskedEmail}

{flash &&
{flash}
} {err === "locked" &&
Account temporarily locked after too many attempts. .
}
setPw(e.target.value)} placeholder="••••••••" />
{!isShellConfigured() &&

Demo: any password works; type “wrong” to see the lockout. Password always needs 2-step next.

}
); } function OtpVerify({ account, email, initialChannel = "email", remember, setRemember, onBack, onVerified, onAuthenticated }: { 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 shell = isShellConfigured(); // In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp. const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel); const [phone, setPhone] = useState(""); const [sent, setSent] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const target = channel === "email" ? account.maskedEmail : channel === "sms" ? (phone || account.maskedPhone) : account.maskedWa; const PHONE_RE = /^\+[1-9]\d{6,14}$/; // Real Supabase OTP: auto-send the email code when this screen opens. useEffect(() => { if (shell && channel === "email" && !sent) void send(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [shell, channel]); async function send() { setError(""); try { if (channel === "email") { await sendEmailOtp(email); setSent(true); } else if (channel === "sms") { if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; } await sendPhoneOtp(phone); setSent(true); } } catch (e) { setError((e as Error).message); } } async function complete(code: string) { if (!shell) { if (code === "000000") { setError("bad"); return; } setError(""); onVerified(); return; } setBusy(true); setError(""); try { if (channel === "email") await verifyEmailOtp(email, code); else await verifyPhoneOtp(phone, code); onAuthenticated(); } catch { setError("Incorrect or expired code. Please try again."); } finally { setBusy(false); } } const showBoxes = !shell || sent; return (

{shell ? "One-time passcode" : "2-step verification"}

{shell ? "We'll text or email you a 6-digit code to sign in." : "Enter the 6-digit code we sent you to finish signing in."}

{!shell && }
{shell && channel === "sms" && !sent ? (
{ e.preventDefault(); void send(); }}>
setPhone(e.target.value)} />
) : ( <>

Code sent to {target}

{showBoxes && void complete(c)} error={!!error} />} )} {error &&
{error === "bad" ? "Incorrect code. Please try again." : error}
}
{shell ? : }
{!shell &&

Demo: any 6 digits verify; “000000” shows an error.

}
); } function AnotherWay({ account, onBack, go }: { account: Account; onBack: () => void; go: (s: Step) => void }) { const [more, setMore] = useState(false); return (

Try another way

Choose a different way to verify it's you.

{account.hasPasskey && go("passkey")} />} {account.hasPush && go("push")} />} go("password")} /> {account.hasTotp && go("totp")} />} go("otp")} /> {more && <> go("otp")} /> go("otp")} /> }
{!more ? : }
); } function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void }) { const [setupKey, setSetupKey] = useState(false); const [error, setError] = useState(false); const [key, setKey] = useState(""); return (

Authenticator code

Open your authenticator app and enter the 6-digit code.

{!setupKey ? ( { if (c === "000000") setError(true); else onVerified(); }} error={error} /> ) : (
setKey(e.target.value)} placeholder="xxxx-xxxx-xxxx" />
)}
{error &&
Incorrect code.
}

Demo: any 6 digits verify; “000000” fails.

); } function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onApproved: () => void; onCancel: () => void }) { const [approved, setApproved] = useState(false); useEffect(() => { const t = setTimeout(() => { setApproved(true); setTimeout(onApproved, 900); }, 3200); return () => clearTimeout(t); }, [onApproved]); return (

{approved ? "Approved — signing you in…" : "Check your phone"}

{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}

{approved ? : }
{!approved && }

Demo: auto-approves after ~3 seconds.

); } function TermsGate({ onAccept }: { onAccept: () => void }) { const [reviewed, setReviewed] = useState(false); const [checked, setChecked] = useState(false); const scrollRef = useRef(null); // If the terms fit without scrolling, enable the checkbox right away. useEffect(() => { const el = scrollRef.current; if (el && el.scrollHeight <= el.clientHeight + 12) setReviewed(true); }, []); function onScroll(e: React.UIEvent) { const el = e.currentTarget; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 12) setReviewed(true); } return (

Before you continue

Please review and accept the portal terms to finish signing in.

{["Authorised use", "Accuracy of records", "Data & privacy", "Security", "Acceptable conduct"].map((h, i) => (

{i + 1}. {h}

By using the Lynkedup Pro portal you agree to access only records you are entitled to, keep your information accurate, protect your credentials, and use the service lawfully. Verification is required to safeguard your account.

))}

— End of terms —

); } function Success({ account, email, remember, onContinue }: { account: Account | null; email: string; remember: boolean; onContinue: () => void }) { useEffect(() => { if (remember) { try { localStorage.setItem("lup_trusted", String(Date.now() + 90 * 864e5)); } catch { /* ignore */ } } const t = setTimeout(onContinue, 2600); return () => clearTimeout(t); }, [remember, onContinue]); return (

You're signed in

Welcome back to the Lynkedup Pro portal.

{email || account?.name}
{remember &&

We'll remember this device for 90 days.

}
); } /* ---- forgot password mini machine ---- */ function ForgotConfirm({ masked, onBack, onSend }: { masked: string; onBack: () => void; onSend: () => void }) { return (

Reset your password

We'll send a verification code to {masked}.

); } function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void }) { const [error, setError] = useState(false); return (

Enter the code

Enter the 6-digit code we just sent.

(c === "000000" ? setError(true) : onOk())} error={error} />
{error &&
Incorrect code.
}

Demo: any 6 digits work; “000000” fails.

); } function ForgotReset({ onDone }: { onDone: () => void }) { const [pw, setPw] = useState(""); const [confirm, setConfirm] = useState(""); const okMatch = pw.length > 0 && pw === confirm; const strongEnough = pw.length >= 8; return (

Create a new password

Choose a strong password you haven't used before.

setPw(e.target.value)} placeholder="New password" />
setConfirm(e.target.value)} placeholder="Re-enter password" />
{confirm && !okMatch &&

Passwords do not match.

}
); } /* ---- helpers ---- */ function CenterIcon({ name, tone }: { name: string; tone: "amber" | "green" | "blue" }) { const bg = tone === "green" ? "rgba(52,211,153,.14)" : tone === "blue" ? "rgba(56,189,248,.14)" : "var(--primary-ghost)"; const fg = tone === "green" ? "#6ee7b7" : tone === "blue" ? "#7dd3fc" : "var(--primary-2)"; return ( ); } function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); }