feat: AppShell integration (auth, OTP, live multi-role team) #1

Merged
tanweer919 merged 2 commits from feat/appshell-integration into goutamnextflow 2026-07-10 23:16:45 +00:00
13 changed files with 253 additions and 16 deletions
Showing only changes of commit 1d37593102 - Show all commits
+16
View File
@@ -0,0 +1,16 @@
# appshell-sdk / Shell BFF config. Leave unset to keep the existing mock portal
# (the app still runs). Set these once the Shell BFF is deployed for real auth.
# Browser-safe Supabase config (the anon key is PUBLISHABLE — never service_role):
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<publishable-anon-key>
# Where the browser reaches the Shell BFF (same-origin path; rewritten to BFF_ORIGIN).
NEXT_PUBLIC_BFF_BASE_URL=/shell
# The deployed Shell BFF origin — the /shell/* rewrite proxies here (see next.config.ts).
BFF_ORIGIN=http://localhost:4000
# Installing @abe-kap/appshell-sdk (GitHub Packages) needs a read:packages token:
# locally: export NODE_AUTH_TOKEN=<token> before npm install
# Vercel: set NODE_AUTH_TOKEN as a project env var
+2
View File
@@ -0,0 +1,2 @@
@abe-kap:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
+57
View File
@@ -0,0 +1,57 @@
# AppShell integration (lynkeduppro-crm)
This app now consumes **`@abe-kap/appshell-sdk`** for authentication, identity, and
(eventually) the data door. The integration follows a **strangler pattern**: the SDK
is wired in everywhere, but every real call is gated behind `isShellConfigured()`. When
the Shell isn't configured (no `NEXT_PUBLIC_SUPABASE_URL`), the app behaves exactly as
before — the existing mock portal and static demo user remain the fallback, so the
deployed demo keeps working until the Shell BFF is live.
## What flips it on
Set these (see `.env.local.example`) and redeploy:
| Var | Purpose |
| --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | Presence flips the app from mock → real auth (the `isShellConfigured()` switch). |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser-safe publishable anon key. |
| `NEXT_PUBLIC_BFF_BASE_URL` | Where the browser reaches the BFF (default `/shell`). |
| `BFF_ORIGIN` | Deployed Shell BFF origin; the `/shell/*` rewrite proxies here. |
| `NODE_AUTH_TOKEN` | `read:packages` token to install the SDK from GitHub Packages (local + Vercel). |
## How it's wired
- **`src/app/providers.tsx`** — mounts `<AppShellProvider>` app-wide (via the root
layout). `auth` is passed **only when Supabase is configured**; otherwise the provider
mounts without auth and `useAuth()` reports `unauthenticated` (never crashes).
- **`next.config.ts`** — `transpilePackages` for the SDK + a `/shell/* → ${BFF_ORIGIN}/api/*`
rewrite so the HttpOnly session cookie flows same-origin. We use `/shell` (not `/api`)
to avoid clobbering the existing `/api/geo` route.
- **Login** (`components/portal/login-flow.tsx`) — password sign-in → `login()`,
social buttons → `loginWithOAuth()` (google/microsoft→azure/apple), and the OAuth
return is finished with `completeOAuthLogin()`. A resolved ACE goes straight to
`/dashboard` (Supabase handles MFA server-side; no mock 2-step).
- **Register** (`components/portal/register-flow.tsx`) — email/password sign-ups call
`register()` on finish. (SSO sign-ups already hold a session from OAuth.)
- **Dashboard gate** (`components/dashboard/auth-gate.tsx`) — bounces unauthenticated
visitors to the portal; pass-through when the Shell is off.
- **Topbar** (`components/dashboard/topbar.tsx`) — shows the real identity from the
App Context Envelope (`useAuth().user`), falling back to the static demo user.
## Local build note
`next build` needs Node ≥ 20.9. Installing the SDK needs a GitHub Packages token; for a
token-free local build you can symlink the workspace copy:
```
mkdir -p node_modules/@abe-kap
ln -s /abs/path/to/appshell-sdk node_modules/@abe-kap/appshell-sdk
```
## Not yet wired (follow-ups)
- Register-via-OAuth still uses the mock `startSocial` (login OAuth is real).
- Email/phone OTP steps in register are still mock (SDK has `sendEmailOtp`/`verifyEmailOtp`).
- Passkey enrollment, tenant switcher, logout menu, and the data door (`useQuery`/
`command`) are available in the SDK but not surfaced in the UI yet.
- Backend integration (frontend ↔ be-crm through the data door) is deliberately deferred.
+9 -1
View File
@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// The SDK ships ESM/TS; let Next transpile it.
transpilePackages: ["@abe-kap/appshell-sdk"],
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
async rewrites() {
const bff = process.env.BFF_ORIGIN ?? "http://localhost:4000";
return [{ source: "/shell/:path*", destination: `${bff}/api/:path*` }];
},
};
export default nextConfig;
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@abe-kap/appshell-sdk": "^0.1.0",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+6 -1
View File
@@ -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
View File
@@ -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>
);
}
+29
View File
@@ -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>;
}
+31
View File
@@ -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}</>;
}
+12 -2
View File
@@ -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} />
+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>
);
}
+18 -3
View File
@@ -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>
);
+11
View File
@@ -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);
}