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:
@@ -1,5 +1,10 @@
|
||||
import { Dashboard } from "@/components/dashboard/dashboard";
|
||||
import { AuthGate } from "@/components/dashboard/auth-gate";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <Dashboard />;
|
||||
return (
|
||||
<AuthGate>
|
||||
<Dashboard />
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { Providers } from "./providers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -31,7 +32,9 @@ export default function RootLayout({
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
// AppShell runs in the browser (boot, React context), so the provider is a
|
||||
// Client Component. When Supabase env is NOT configured (no BFF yet) we mount the
|
||||
// provider WITHOUT `auth` — `useAuth()` still works (returns nulls / status
|
||||
// "unauthenticated"), and the existing mock portal remains the fallback. Once the
|
||||
// env is set + the Shell BFF is deployed, real auth flows through appshell-sdk.
|
||||
|
||||
import { AppShellProvider } from "@abe-kap/appshell-sdk/react";
|
||||
import type { AppShellOptions } from "@abe-kap/appshell-sdk";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const bffBaseUrl = process.env.NEXT_PUBLIC_BFF_BASE_URL ?? "/shell";
|
||||
|
||||
const options: AppShellOptions = supabaseUrl
|
||||
? {
|
||||
auth: {
|
||||
supabaseUrl,
|
||||
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
|
||||
appId: "crm-web",
|
||||
},
|
||||
bffBaseUrl,
|
||||
}
|
||||
: { bffBaseUrl };
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return <AppShellProvider options={options}>{children}</AppShellProvider>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
|
||||
/**
|
||||
* Client gate for the dashboard. When the Shell is live, an unauthenticated
|
||||
* visitor is bounced to the portal; while the session is resolving we hold a
|
||||
* lightweight splash. When the Shell is NOT configured this is a pass-through,
|
||||
* so the existing mock demo (any login → /dashboard) keeps working unchanged.
|
||||
*/
|
||||
export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { status } = useAuth();
|
||||
const shell = isShellConfigured();
|
||||
|
||||
useEffect(() => {
|
||||
if (shell && status === "unauthenticated") router.replace("/portal/login");
|
||||
}, [shell, status, router]);
|
||||
|
||||
if (shell && status !== "authenticated") {
|
||||
return (
|
||||
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
|
||||
{status === "loading" ? "Loading your workspace…" : "Redirecting to sign in…"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -3,8 +3,18 @@
|
||||
import { Sun, Moon, ChevronDown } from "lucide-react";
|
||||
import { Icon } from "./ui";
|
||||
import { user } from "./account-data";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
|
||||
}
|
||||
|
||||
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
|
||||
// When signed in through the Shell, show the real identity from the App Context
|
||||
// Envelope; otherwise fall back to the static demo user.
|
||||
const { user: me } = useAuth();
|
||||
const name = me?.displayName || user.name;
|
||||
const initials = me ? initialsOf(me.displayName) : user.initials;
|
||||
return (
|
||||
<header className="dash-topbar">
|
||||
<div className="dash-title">
|
||||
@@ -21,9 +31,9 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
|
||||
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
||||
</button>
|
||||
<button className="top-user">
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span>
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
||||
<div style={{ textAlign: "left" }}>
|
||||
<div className="nm">{user.name}</div>
|
||||
<div className="nm">{name}</div>
|
||||
<div className="rl">{user.role}</div>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Integration helpers for appshell-sdk. NEXT_PUBLIC_* vars are inlined at build
|
||||
// time, so this reflects deploy-time configuration.
|
||||
|
||||
/**
|
||||
* True when the Shell (Supabase → BFF) is configured. When false, the app falls
|
||||
* back to the existing mock portal / static user so the demo keeps working before
|
||||
* the backend is deployed. Gate real-vs-mock auth on this.
|
||||
*/
|
||||
export function isShellConfigured(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
}
|
||||
Reference in New Issue
Block a user