feat(appshell): integrate @abe-kap/appshell-sdk (strangler, mock fallback)
Wire the AppShell SDK for auth/identity across the CRM, gated behind isShellConfigured() so the existing mock portal + static demo user stay the fallback until the Shell BFF is configured. - providers.tsx: app-wide <AppShellProvider> (auth passed only when Supabase env set) - next.config.ts: transpilePackages + /shell -> BFF rewrite (keeps /api/geo intact) - login: password -> login(), social -> loginWithOAuth(), OAuth return -> completeOAuthLogin() - register: email/password sign-ups call register() on finish - dashboard: AuthGate bounces unauthenticated visitors; topbar shows ACE identity - docs/APPSHELL_INTEGRATION.md, .env.local.example, .npmrc for GitHub Packages
This commit is contained in:
@@ -8,7 +8,12 @@ import {
|
||||
RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial,
|
||||
PasswordStrength,
|
||||
} from "./bits";
|
||||
import { lookupAccount, type Account } from "./data";
|
||||
import { lookupAccount, maskEmail, type Account } from "./data";
|
||||
import { useAuth } 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<string, string> = { google: "google", microsoft: "azure", apple: "apple" };
|
||||
|
||||
type Step =
|
||||
| "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password"
|
||||
@@ -19,6 +24,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function LoginFlow() {
|
||||
const router = useRouter();
|
||||
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
|
||||
const [step, setStep] = useState<Step>("identify");
|
||||
const [email, setEmail] = useState("");
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
@@ -47,6 +53,17 @@ export function LoginFlow() {
|
||||
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;
|
||||
setStep("connecting");
|
||||
completeOAuthLogin(code)
|
||||
.then((ace) => { if (ace) router.replace("/dashboard"); else replace("identify"); })
|
||||
.catch(() => { setFlash("Sign-in with that provider didn't complete. Try again."); replace("identify"); });
|
||||
}, [completeOAuthLogin, router]);
|
||||
|
||||
/* ---- auth resolution ---- */
|
||||
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
||||
if (factor === "password") { setFlash(""); push("otp"); return; }
|
||||
@@ -56,6 +73,14 @@ export function LoginFlow() {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -65,6 +90,19 @@ export function LoginFlow() {
|
||||
|
||||
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: "",
|
||||
});
|
||||
replace("password");
|
||||
return;
|
||||
}
|
||||
push("connecting");
|
||||
setProvider("");
|
||||
setTimeout(() => {
|
||||
@@ -124,7 +162,7 @@ export function LoginFlow() {
|
||||
)}
|
||||
|
||||
{step === "password" && account && (
|
||||
<Password account={account} 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={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
|
||||
)}
|
||||
|
||||
{step === "otp" && account && (
|
||||
@@ -274,16 +312,27 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
|
||||
);
|
||||
}
|
||||
|
||||
function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
|
||||
account: Account; flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void;
|
||||
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("");
|
||||
function submit(e: React.FormEvent) {
|
||||
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; }
|
||||
if (!pw) return;
|
||||
onOk();
|
||||
}
|
||||
return (
|
||||
@@ -304,13 +353,13 @@ function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ marginTop: 14 }}>Sign in <Icon name="arrowR" size={16} /></button>
|
||||
<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 using email OTP</button>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,17 @@ import {
|
||||
countryCodes, relationshipOptions, addressCountries,
|
||||
TERMS, PRIVACY, passwordStrength, type AddrCountry,
|
||||
} from "./data";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
|
||||
const STEPS = ["Account", "Verify", "Address"];
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function RegisterFlow() {
|
||||
const router = useRouter();
|
||||
const { register } = useAuth();
|
||||
const [step, setStep] = useState(0);
|
||||
const [submitErr, setSubmitErr] = useState("");
|
||||
|
||||
// shared form state
|
||||
const [sso, setSso] = useState<{ provider: string; email: string } | null>(null);
|
||||
@@ -51,15 +55,23 @@ export function RegisterFlow() {
|
||||
|
||||
const step2Valid = emailVerified && phoneVerified;
|
||||
|
||||
function finish() {
|
||||
async function finish() {
|
||||
const finalEmail = sso?.email || email;
|
||||
const profile = {
|
||||
name: `${first} ${last}`.trim(),
|
||||
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
|
||||
email: sso?.email || email,
|
||||
email: finalEmail,
|
||||
isAllottee: isSelf,
|
||||
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
|
||||
};
|
||||
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
|
||||
// With the Shell live, create the real account through appshell → BFF. SSO users
|
||||
// already have a session from OAuth, so only email/password sign-ups register here.
|
||||
if (isShellConfigured() && !sso) {
|
||||
setSubmitErr("");
|
||||
try { await register(finalEmail, pw); }
|
||||
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
|
||||
}
|
||||
router.push("/dashboard");
|
||||
}
|
||||
|
||||
@@ -92,7 +104,10 @@ export function RegisterFlow() {
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<StepAddress onBack={() => setStep(1)} onFinish={finish} />
|
||||
<>
|
||||
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
||||
<StepAddress onBack={() => setStep(1)} onFinish={finish} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user