forked from Goutam/lynkeduppro-crm
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.
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ export function LoginFlow() {
|
|||||||
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) {
|
||||||
@@ -100,6 +112,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,7 +129,7 @@ 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" && <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">
|
||||||
@@ -162,11 +175,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 +243,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 +276,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>
|
||||||
@@ -357,20 +373,20 @@ 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>}
|
{!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);
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -158,23 +158,13 @@ function StepAccount(p: {
|
|||||||
const [phase, setPhase] = useState<"sso" | "profile">("sso");
|
const [phase, setPhase] = useState<"sso" | "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>
|
||||||
|
|||||||
Reference in New Issue
Block a user