127e0f8912
- Login: identify → password/otp now push() history (was replace()), so Back returns to the email screen instead of leaving to /register. - Login: in Shell mode the OTP screen no longer shows an email/SMS toggle — the channel is fixed by how you signed in, so email sign-in never shows a stray SMS option. - Login: hide phone (SMS) sign-in while OTP is mocked — a faked login code can't mint a session (AuthGate would bounce), so it can't work without SMS. Email OTP + password + Google stay real and working. - Register: add Back buttons (email screen → sign in; profile screen → email). - Share MOCK_OTP/DEMO_OTP via @/lib/otp.
688 lines
34 KiB
TypeScript
688 lines
34 KiB
TypeScript
"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";
|
|
import { MOCK_OTP } from "@/lib/otp";
|
|
|
|
// Map the portal's social button ids to Supabase OAuth provider ids.
|
|
const OAUTH_PROVIDER: Record<string, string> = { 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, getUserEmail } = useAuth();
|
|
const { ready, sdk } = useAppShell();
|
|
const [step, setStep] = useState<Step>("identify");
|
|
const [email, setEmail] = useState("");
|
|
const [account, setAccount] = useState<Account | null>(null);
|
|
const [provider, setProvider] = useState<string>("");
|
|
const [remember, setRemember] = useState(false);
|
|
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");
|
|
push("otp"); // push (not replace) so Back returns to the identify screen
|
|
}
|
|
|
|
/* ---- 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(async (ace) => {
|
|
if (!ace) { replace("identify"); return; }
|
|
// 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 ---- */
|
|
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");
|
|
push("password"); // push (not replace) so Back returns to the email screen, not off-page
|
|
return;
|
|
}
|
|
push("connecting");
|
|
setProvider("");
|
|
setTimeout(() => {
|
|
const acc = lookupAccount(email);
|
|
if (!acc) { replace("notfound"); return; }
|
|
setAccount(acc);
|
|
replace("primary");
|
|
}, 650);
|
|
}
|
|
|
|
/* =================================================================== */
|
|
return (
|
|
<div className="card anim-fade-up" key={step}>
|
|
{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" && (
|
|
<div className="interstitial">
|
|
<Spinner lg />
|
|
<div>
|
|
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}…` : "Looking up your account…"}</h1>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === "notfound" && (
|
|
<div>
|
|
<div className="conn-banner conn-error-like" style={{ background: "rgba(239,68,68,.1)", border: "1px solid rgba(239,68,68,.35)", color: "#fca5a5", marginBottom: 18 }}>
|
|
<Icon name="alert" size={18} /> No account found for <b> {email}</b>
|
|
</div>
|
|
<h1>We couldn't find that account</h1>
|
|
<p className="sub">No account is on file for this email. Try another email or register a new account.</p>
|
|
<div className="col gap-3" style={{ marginTop: 22 }}>
|
|
<button className="btn" onClick={() => replace("identify")}>Try a different email</button>
|
|
<button className="btn btn-primary" onClick={() => router.push("/portal/register")}>Register a new account <Icon name="arrowR" size={16} /></button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === "primary" && account && (
|
|
<div>
|
|
<h1>Hi {account.firstName}</h1>
|
|
<p className="sub">Choose how you want to sign in.</p>
|
|
<div style={{ margin: "18px 0" }}>
|
|
<AccountPill initials={account.initials} email={email} onChange={() => replace("identify")} />
|
|
</div>
|
|
<div className="col gap-3">
|
|
{account.hasPasskey && <MethodRow icon="key" title="Use your passkey" sub="Fingerprint, face or screen lock" badge="Fastest" onClick={() => push("passkey")} />}
|
|
<MethodRow icon="lock" title="Enter your password" sub="Sign in with your password" onClick={() => push("password")} />
|
|
<MethodRow icon="sparkle" title="Try another way" sub="Authenticator, push, OTP…" onClick={() => push("another")} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === "passkey" && account && (
|
|
<Passkey account={account} onBack={back} onPassword={() => replace("password")} onAnother={() => replace("another")} onSuccess={() => afterAuth("passkey")} onFail={() => { setFlash("Passkey was cancelled or timed out."); replace("error"); }} />
|
|
)}
|
|
|
|
{step === "password" && account && (
|
|
<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 && (
|
|
<OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
|
|
)}
|
|
|
|
{step === "another" && account && (
|
|
<AnotherWay account={account} onBack={back} go={(s) => push(s)} />
|
|
)}
|
|
|
|
{step === "totp" && account && (
|
|
<Totp remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("totp")} />
|
|
)}
|
|
|
|
{step === "push" && (
|
|
<PushApprove onBack={back} onApproved={() => afterAuth("push")} onCancel={() => { setFlash("Sign-in request cancelled."); replace("another"); }} />
|
|
)}
|
|
|
|
{step === "error" && (
|
|
<div>
|
|
<CenterIcon name="shield" tone="amber" />
|
|
<h1 style={{ textAlign: "center" }}>Couldn't sign you in</h1>
|
|
<p className="sub" style={{ textAlign: "center" }}>{flash || "Something interrupted the sign-in."}</p>
|
|
<ul className="tips">
|
|
<li>Make sure you're using the correct device or account.</li>
|
|
<li>Check your internet connection and try again.</li>
|
|
<li>You can switch to another verification method.</li>
|
|
</ul>
|
|
<div className="col gap-3" style={{ marginTop: 18 }}>
|
|
<button className="btn btn-primary" onClick={() => (account ? replace("primary") : replace("identify"))}>Try again</button>
|
|
<button className="btn" onClick={switchAccount}>Use a different account</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === "terms" && <TermsGate onAccept={() => { try { localStorage.setItem("lup_terms", "1"); } catch { /* ignore */ } replace("success"); }} />}
|
|
|
|
{step === "success" && (
|
|
<Success account={account} email={email} remember={remember} onContinue={() => router.push("/dashboard")} />
|
|
)}
|
|
|
|
{step === "recovery" && (
|
|
<div>
|
|
<StepBack onClick={back} />
|
|
<CenterIcon name="info" tone="blue" />
|
|
<h1 style={{ textAlign: "center" }}>Account recovery</h1>
|
|
<p className="sub" style={{ textAlign: "center" }}>None of the available methods worked. Our team can help verify your identity and restore access.</p>
|
|
<FlashNote tone="info">Contact support at <b>help@lynkeduppro.com</b> with your property or account details.</FlashNote>
|
|
<button className="btn" style={{ marginTop: 16 }} onClick={() => replace("identify")}>Back to sign in</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === "fp_confirm" && account && (
|
|
<ForgotConfirm masked={account.maskedEmail} onBack={back} onSend={() => push("fp_code")} />
|
|
)}
|
|
{step === "fp_code" && (
|
|
<ForgotCode onBack={back} onOk={() => push("fp_reset")} />
|
|
)}
|
|
{step === "fp_reset" && (
|
|
<ForgotReset onDone={() => { setFlash("Password updated. Please sign in."); replace("password"); }} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================ 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 (
|
|
<div>
|
|
<div className="kicker">Welcome back</div>
|
|
<h1>Sign in to LynkedUp</h1>
|
|
<p className="sub">Drone inspections, AI estimates and insurance-ready reports — all in one place.</p>
|
|
|
|
<div style={{ marginTop: 22 }}>
|
|
<SocialButtons onPick={onSocial} />
|
|
<div className="divider">or continue with email</div>
|
|
|
|
{!showEmail ? (
|
|
<button className="btn" onClick={() => setShowEmail(true)}><Icon name="mail" size={18} /> Continue with email</button>
|
|
) : (
|
|
<form onSubmit={(e) => { e.preventDefault(); onEmail(); }}>
|
|
<div className="field">
|
|
<label className="label" htmlFor="lemail">Email address</label>
|
|
<div className="input-wrap">
|
|
<span className="input-ico"><Icon name="mail" size={17} /></span>
|
|
<input id="lemail" className="input" type="email" autoFocus value={email}
|
|
onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
|
|
</div>
|
|
</div>
|
|
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>
|
|
Continue <Icon name="arrowR" size={17} />
|
|
</button>
|
|
</form>
|
|
)}
|
|
{/* Phone (SMS) sign-in needs a deliverable one-time code — hidden while SMS is
|
|
mocked, since a faked code can't mint a real session. Email + password + Google
|
|
all work without SMS. */}
|
|
{isShellConfigured() && !MOCK_OTP && (
|
|
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
|
|
)}
|
|
</div>
|
|
|
|
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
|
|
<div className="secure-footer">
|
|
<span><Icon name="lock" size={13} /> 256-bit SSL</span>
|
|
<span><Icon name="shield" size={13} /> Licensed & Insured</span>
|
|
<span><Icon name="check" size={13} /> Drone-Powered</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<CenterIcon name="key" tone="amber" />
|
|
<h1 style={{ textAlign: "center" }}>Use your passkey</h1>
|
|
<p className="sub" style={{ textAlign: "center" }}>Verify it's you with your device's fingerprint, face or screen lock.</p>
|
|
{waiting ? (
|
|
<div className="interstitial"><Spinner lg /><p className="muted">Waiting for your device…</p></div>
|
|
) : (
|
|
<button className="btn btn-primary" style={{ marginTop: 20 }} onClick={attempt}>Verify with passkey</button>
|
|
)}
|
|
<div className="col gap-2" style={{ marginTop: 16, alignItems: "center" }}>
|
|
<button className="link" onClick={onPassword}>Use your password instead</button>
|
|
<button className="link" onClick={onAnother}>Try another way</button>
|
|
</div>
|
|
<button hidden onClick={onFail} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Password({ account, email, login, onAuthenticated, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
|
|
account: Account; email: string; login: ReturnType<typeof useAuth>["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 (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<h1>Enter your password</h1>
|
|
<p className="sub">Signing in as {account.maskedEmail}</p>
|
|
{flash && <div style={{ marginTop: 14 }}><FlashNote tone="info">{flash}</FlashNote></div>}
|
|
{err === "locked" && <div style={{ marginTop: 14 }}><FlashNote tone="error">Account temporarily locked after too many attempts. <button className="link" onClick={onOtp}>Get a one-time code instead</button>.</FlashNote></div>}
|
|
<form onSubmit={submit} style={{ marginTop: 16 }}>
|
|
<div className="field">
|
|
<label className="label" htmlFor="lpw">Password</label>
|
|
<div className="input-wrap">
|
|
<span className="input-ico"><Icon name="lock" size={17} /></span>
|
|
<input id="lpw" className="input" type={show ? "text" : "password"} autoFocus value={pw} onChange={(e) => setPw(e.target.value)} placeholder="••••••••" />
|
|
<button type="button" className="input-eye" onClick={() => setShow((s) => !s)} aria-label={show ? "Hide" : "Show"}>
|
|
<Icon name={show ? "eyeOff" : "eye"} size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={busy}>{busy ? "Signing in…" : <>Sign in <Icon name="arrowR" size={16} /></>}</button>
|
|
</form>
|
|
<div className="row between" style={{ marginTop: 16 }}>
|
|
<button className="link" onClick={onForgot}>Forgot password?</button>
|
|
<button className="link" onClick={onOtp}>Sign in with a one-time code</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string>("");
|
|
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 (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
|
|
<p className="sub">
|
|
{!shell
|
|
? "Enter the 6-digit code we sent you to finish signing in."
|
|
: channel === "sms"
|
|
? "We'll text a 6-digit code to sign in."
|
|
: "We'll email you a 6-digit code to sign in."}
|
|
</p>
|
|
{/* In Shell mode the channel is fixed by how you signed in (email vs phone) —
|
|
no toggle, so an email sign-in never shows a stray SMS option. */}
|
|
{!shell && (
|
|
<div className="seg" style={{ margin: "16px 0 14px" }}>
|
|
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
|
|
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
|
|
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
|
|
</div>
|
|
)}
|
|
|
|
{shell && channel === "sms" && !sent ? (
|
|
<form onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
|
<div className="field">
|
|
<label className="label" htmlFor="otpphone">Phone number</label>
|
|
<input id="otpphone" className="input" type="tel" autoFocus placeholder="+1 415 555 0100" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
|
</div>
|
|
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={busy}>Send code <Icon name="arrowR" size={16} /></button>
|
|
</form>
|
|
) : (
|
|
<>
|
|
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
|
|
{showBoxes && <OtpBoxes onComplete={(c) => void complete(c)} error={!!error} />}
|
|
</>
|
|
)}
|
|
|
|
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">{error === "bad" ? "Incorrect code. Please try again." : error}</FlashNote></div>}
|
|
<div className="row between" style={{ margin: "14px 0" }}>
|
|
{shell ? <button className="link" onClick={() => void send()} disabled={busy}>Resend code</button> : <ResendLink seconds={30} />}
|
|
<span />
|
|
</div>
|
|
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AnotherWay({ account, onBack, go }: { account: Account; onBack: () => void; go: (s: Step) => void }) {
|
|
const [more, setMore] = useState(false);
|
|
return (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<h1>Try another way</h1>
|
|
<p className="sub">Choose a different way to verify it's you.</p>
|
|
<div className="col gap-3" style={{ marginTop: 18 }}>
|
|
{account.hasPasskey && <MethodRow icon="key" title="Use your passkey" sub="Fingerprint, face or screen lock" onClick={() => go("passkey")} />}
|
|
{account.hasPush && <MethodRow icon="smartphone" title="Approve sign-in with a mobile app" sub="Send a push to your phone" onClick={() => go("push")} />}
|
|
<MethodRow icon="lock" title="Enter your password" onClick={() => go("password")} />
|
|
{account.hasTotp && <MethodRow icon="totp" title="Get a code from your authenticator app" onClick={() => go("totp")} />}
|
|
<MethodRow icon="mail" title="Email a one-time code" sub={account.maskedEmail} onClick={() => go("otp")} />
|
|
{more && <>
|
|
<MethodRow icon="sms" title="Text a one-time code" sub={account.maskedPhone} onClick={() => go("otp")} />
|
|
<MethodRow icon="whatsapp" title="WhatsApp a one-time code" sub={account.maskedWa} onClick={() => go("otp")} />
|
|
</>}
|
|
</div>
|
|
{!more
|
|
? <button className="link" style={{ marginTop: 16 }} onClick={() => setMore(true)}>Show more options</button>
|
|
: <button className="link" style={{ marginTop: 16 }} onClick={() => go("recovery")}>None of these work →</button>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<h1>Authenticator code</h1>
|
|
<p className="sub">Open your authenticator app and enter the 6-digit code.</p>
|
|
<div style={{ marginTop: 18 }}>
|
|
{!setupKey ? (
|
|
<OtpBoxes onComplete={(c) => { if (c === "000000") setError(true); else onVerified(); }} error={error} />
|
|
) : (
|
|
<div className="field">
|
|
<label className="label">Setup key</label>
|
|
<input className="input" value={key} onChange={(e) => setKey(e.target.value)} placeholder="xxxx-xxxx-xxxx" />
|
|
<button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => onVerified()}>Verify</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
|
|
<button className="link" style={{ margin: "14px 0" }} onClick={() => setSetupKey((s) => !s)}>
|
|
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
|
|
</button>
|
|
<RememberDevice checked={remember} onChange={setRemember} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<CenterIcon name="smartphone" tone={approved ? "green" : "amber"} />
|
|
<h1 style={{ textAlign: "center" }}>{approved ? "Approved — signing you in…" : "Check your phone"}</h1>
|
|
<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>
|
|
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TermsGate({ onAccept }: { onAccept: () => void }) {
|
|
const [reviewed, setReviewed] = useState(false);
|
|
const [checked, setChecked] = useState(false);
|
|
const scrollRef = useRef<HTMLDivElement>(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<HTMLDivElement>) {
|
|
const el = e.currentTarget;
|
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 12) setReviewed(true);
|
|
}
|
|
return (
|
|
<div>
|
|
<h1>Before you continue</h1>
|
|
<p className="sub">Please review and accept the portal terms to finish signing in.</p>
|
|
<div className="terms-scroll" ref={scrollRef} onScroll={onScroll}>
|
|
{["Authorised use", "Accuracy of records", "Data & privacy", "Security", "Acceptable conduct"].map((h, i) => (
|
|
<div key={h} style={{ marginBottom: 14 }}>
|
|
<h4 style={{ fontSize: 14, fontWeight: 700, marginBottom: 5 }}>{i + 1}. {h}</h4>
|
|
<p style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.6 }}>
|
|
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.
|
|
</p>
|
|
</div>
|
|
))}
|
|
<p className="faint" style={{ fontSize: 12 }}>— End of terms —</p>
|
|
</div>
|
|
<label className={`remember-card ${!reviewed ? "is-disabled" : ""}`} style={{ marginTop: 14 }}>
|
|
<input type="checkbox" disabled={!reviewed} checked={checked} onChange={(e) => setChecked(e.target.checked)} />
|
|
<span style={{ fontWeight: 600, fontSize: 13.5 }}>I accept the Terms of Use and Privacy Policy{!reviewed && <span className="faint" style={{ fontWeight: 500 }}> — scroll to the end to enable</span>}</span>
|
|
</label>
|
|
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!checked} onClick={onAccept}>I accept</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ textAlign: "center" }}>
|
|
<CenterIcon name="check" tone="green" />
|
|
<h1>You're signed in</h1>
|
|
<p className="sub">Welcome back to the Lynkedup Pro portal.</p>
|
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, margin: "18px 0", padding: "8px 14px", border: "1px solid var(--line-2)", borderRadius: 999 }}>
|
|
<Avatar initials={account?.initials ?? "LP"} size={28} />
|
|
<span style={{ fontSize: 13.5, fontWeight: 600 }}>{email || account?.name}</span>
|
|
</div>
|
|
{remember && <p className="faint" style={{ fontSize: 12.5 }}>We'll remember this device for 90 days.</p>}
|
|
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={onContinue}>Go to dashboard now <Icon name="arrowR" size={16} /></button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- forgot password mini machine ---- */
|
|
function ForgotConfirm({ masked, onBack, onSend }: { masked: string; onBack: () => void; onSend: () => void }) {
|
|
return (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<h1>Reset your password</h1>
|
|
<p className="sub">We'll send a verification code to {masked}.</p>
|
|
<button className="btn btn-primary" style={{ marginTop: 18 }} onClick={onSend}>Send code <Icon name="arrowR" size={16} /></button>
|
|
</div>
|
|
);
|
|
}
|
|
function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void }) {
|
|
const [error, setError] = useState(false);
|
|
return (
|
|
<div>
|
|
<StepBack onClick={onBack} />
|
|
<h1>Enter the code</h1>
|
|
<p className="sub">Enter the 6-digit code we just sent.</p>
|
|
<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>}
|
|
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
|
|
</div>
|
|
);
|
|
}
|
|
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 (
|
|
<div>
|
|
<h1>Create a new password</h1>
|
|
<p className="sub">Choose a strong password you haven't used before.</p>
|
|
<div className="field" style={{ marginTop: 16 }}>
|
|
<label className="label">New password</label>
|
|
<input className="input" type="password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="New password" />
|
|
</div>
|
|
<PasswordStrength pw={pw} />
|
|
<div className="field" style={{ marginTop: 14 }}>
|
|
<label className="label">Confirm password</label>
|
|
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="Re-enter password" />
|
|
</div>
|
|
{confirm && !okMatch && <p className="faint" style={{ fontSize: 12, color: "#fca5a5", marginTop: 6 }}>Passwords do not match.</p>}
|
|
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!(okMatch && strongEnough)} onClick={onDone}>Update password</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---- 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 (
|
|
<span style={{ width: 64, height: 64, margin: "4px auto 16px", display: "grid", placeItems: "center", borderRadius: 18, background: bg, color: fg }}>
|
|
<Icon name={name} size={28} />
|
|
</span>
|
|
);
|
|
}
|
|
function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); }
|