feat(team+auth): live multi-role team management + email/SMS OTP login
- team-management: new useTeamData() data layer serves live be-crm data door (crm.team.member.search/role.list/invitation.list + set-roles/perm/invite commands) or the mock fallback, gated on isShellConfigured(). A member can hold MANY roles: role chips + multi-select picker in Edit/Invite modals. - login OTP: OtpVerify wired to real Supabase email + SMS OTP (passwordless) via appshell sendEmailOtp/verifyEmailOtp + sendPhoneOtp/verifyPhoneOtp. - bump @abe-kap/appshell-sdk ^0.2.0 (phone OTP).
This commit is contained in:
@@ -166,7 +166,7 @@ export function LoginFlow() {
|
||||
)}
|
||||
|
||||
{step === "otp" && account && (
|
||||
<OtpVerify account={account} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} />
|
||||
<OtpVerify account={account} email={email} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
|
||||
)}
|
||||
|
||||
{step === "another" && account && (
|
||||
@@ -364,35 +364,83 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor
|
||||
);
|
||||
}
|
||||
|
||||
function OtpVerify({ account, remember, setRemember, onBack, onVerified }: {
|
||||
account: Account; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void;
|
||||
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;
|
||||
}) {
|
||||
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 [error, setError] = useState(false);
|
||||
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? account.maskedPhone : account.maskedWa;
|
||||
function complete(code: string) {
|
||||
if (code === "000000") { setError(true); return; }
|
||||
setError(false); onVerified();
|
||||
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>2-step verification</h1>
|
||||
<p className="sub">Enter the 6-digit code we sent you to finish signing in.</p>
|
||||
<h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
|
||||
<p className="sub">{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."}</p>
|
||||
<div className="seg" style={{ margin: "16px 0 14px" }}>
|
||||
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(false); }}><Icon name="mail" size={15} /> Email</button>
|
||||
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(false); }}><Icon name="sms" size={15} /> SMS</button>
|
||||
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(false); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
|
||||
<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>
|
||||
{!shell && <button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>}
|
||||
</div>
|
||||
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
|
||||
<OtpBoxes onComplete={complete} error={error} />
|
||||
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code. Please try again.</FlashNote></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" }}>
|
||||
<ResendLink seconds={30} />
|
||||
{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." />
|
||||
<p className="demo-hint">Demo: any 6 digits verify; “000000” shows an error.</p>
|
||||
{!shell && <p className="demo-hint">Demo: any 6 digits verify; “000000” shows an error.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user