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:
tanweer919
2026-07-09 20:51:17 +05:30
parent 9675bf014d
commit 1d37593102
13 changed files with 253 additions and 16 deletions
+57 -8
View File
@@ -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>
);
}