From 1d375931024778d2a623fd0e26594e5c7faf2249 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Thu, 9 Jul 2026 20:51:17 +0530 Subject: [PATCH] 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 (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 --- .env.local.example | 16 ++++++ .npmrc | 2 + docs/APPSHELL_INTEGRATION.md | 57 ++++++++++++++++++++++ next.config.ts | 10 +++- package.json | 1 + src/app/dashboard/page.tsx | 7 ++- src/app/layout.tsx | 5 +- src/app/providers.tsx | 29 +++++++++++ src/components/dashboard/auth-gate.tsx | 31 ++++++++++++ src/components/dashboard/topbar.tsx | 14 +++++- src/components/portal/login-flow.tsx | 65 ++++++++++++++++++++++--- src/components/portal/register-flow.tsx | 21 ++++++-- src/lib/appshell.ts | 11 +++++ 13 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 .env.local.example create mode 100644 .npmrc create mode 100644 docs/APPSHELL_INTEGRATION.md create mode 100644 src/app/providers.tsx create mode 100644 src/components/dashboard/auth-gate.tsx create mode 100644 src/lib/appshell.ts diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..e002499 --- /dev/null +++ b/.env.local.example @@ -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://.supabase.co +NEXT_PUBLIC_SUPABASE_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= before npm install +# Vercel: set NODE_AUTH_TOKEN as a project env var diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..de1a953 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@abe-kap:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} diff --git a/docs/APPSHELL_INTEGRATION.md b/docs/APPSHELL_INTEGRATION.md new file mode 100644 index 0000000..13cf35a --- /dev/null +++ b/docs/APPSHELL_INTEGRATION.md @@ -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 `` 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. diff --git a/next.config.ts b/next.config.ts index e9ffa30..aa1c5aa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/package.json b/package.json index c12422c..eafbae6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 551bee9..be082d6 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,5 +1,10 @@ import { Dashboard } from "@/components/dashboard/dashboard"; +import { AuthGate } from "@/components/dashboard/auth-gate"; export default function DashboardPage() { - return ; + return ( + + + + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3622122..6f9654b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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`} > - {children} + + {children} + ); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..0bbe179 --- /dev/null +++ b/src/app/providers.tsx @@ -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 {children}; +} diff --git a/src/components/dashboard/auth-gate.tsx b/src/components/dashboard/auth-gate.tsx new file mode 100644 index 0000000..fb12945 --- /dev/null +++ b/src/components/dashboard/auth-gate.tsx @@ -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 ( +
+ {status === "loading" ? "Loading your workspace…" : "Redirecting to sign in…"} +
+ ); + } + return <>{children}; +} diff --git a/src/components/dashboard/topbar.tsx b/src/components/dashboard/topbar.tsx index 68c9b56..ff0b452 100644 --- a/src/components/dashboard/topbar.tsx +++ b/src/components/dashboard/topbar.tsx @@ -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 (
@@ -21,9 +31,9 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
- +
-

Demo: any password works; type “wrong” to see the lockout. Password always needs 2-step next.

+ {!isShellConfigured() &&

Demo: any password works; type “wrong” to see the lockout. Password always needs 2-step next.

} ); } diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx index 635435d..162eb31 100644 --- a/src/components/portal/register-flow.tsx +++ b/src/components/portal/register-flow.tsx @@ -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 && ( - setStep(1)} onFinish={finish} /> + <> + {submitErr &&
{submitErr}
} + setStep(1)} onFinish={finish} /> + )} ); diff --git a/src/lib/appshell.ts b/src/lib/appshell.ts new file mode 100644 index 0000000..a4e4718 --- /dev/null +++ b/src/lib/appshell.ts @@ -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); +}