|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
|
|
PasswordStrength,
|
|
|
|
|
} from "./bits";
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
// Map the portal's social button ids to Supabase OAuth provider ids.
|
|
|
|
@@ -25,12 +25,25 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
export function LoginFlow() {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
|
|
|
|
|
const { ready } = 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");
|
|
|
|
|
replace("otp");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---- history hash sync ---- */
|
|
|
|
|
function push(s: Step) {
|
|
|
|
@@ -58,11 +71,22 @@ export function LoginFlow() {
|
|
|
|
|
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) router.replace("/dashboard"); else replace("identify"); })
|
|
|
|
|
.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(() => { setFlash("Sign-in with that provider didn't complete. Try again."); replace("identify"); });
|
|
|
|
|
}, [completeOAuthLogin, router]);
|
|
|
|
|
}, [ready, completeOAuthLogin, router]);
|
|
|
|
|
|
|
|
|
|
/* ---- auth resolution ---- */
|
|
|
|
|
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
|
|
|
@@ -100,6 +124,7 @@ export function LoginFlow() {
|
|
|
|
|
hasPasskey: false, hasTotp: false, hasPush: false,
|
|
|
|
|
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
|
|
|
|
|
});
|
|
|
|
|
setOtpChannel("email");
|
|
|
|
|
replace("password");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
@@ -116,7 +141,7 @@ export function LoginFlow() {
|
|
|
|
|
/* =================================================================== */
|
|
|
|
|
return (
|
|
|
|
|
<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" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />}
|
|
|
|
|
|
|
|
|
|
{step === "connecting" && (
|
|
|
|
|
<div className="interstitial">
|
|
|
|
@@ -162,11 +187,11 @@ export function LoginFlow() {
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{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 && (
|
|
|
|
|
<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 && (
|
|
|
|
@@ -230,9 +255,9 @@ export function LoginFlow() {
|
|
|
|
|
|
|
|
|
|
/* ============================ screens ============================ */
|
|
|
|
|
|
|
|
|
|
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
|
|
|
|
|
function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
|
|
|
|
|
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 valid = EMAIL_RE.test(email);
|
|
|
|
@@ -263,6 +288,9 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
|
|
|
|
|
</button>
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
|
|
|
|
@@ -357,20 +385,20 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor
|
|
|
|
|
</form>
|
|
|
|
|
<div className="row between" style={{ marginTop: 16 }}>
|
|
|
|
|
<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>
|
|
|
|
|
{!isShellConfigured() && <p className="demo-hint">Demo: any password works; type “wrong” to see the lockout. Password always needs 2-step next.</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function OtpVerify({ account, email, remember, setRemember, onBack, onVerified, onAuthenticated }: {
|
|
|
|
|
account: Account; email: string; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
|
|
|
|
|
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">("email");
|
|
|
|
|
const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel);
|
|
|
|
|
const [phone, setPhone] = useState("");
|
|
|
|
|
const [sent, setSent] = useState(false);
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|