forked from Goutam/lynkeduppro-crm
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:
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@abe-kap:registry=https://npm.pkg.github.com
|
||||||
|
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
|
||||||
@@ -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
@@ -1,7 +1,15 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@abe-kap/appshell-sdk": "^0.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Dashboard } from "@/components/dashboard/dashboard";
|
import { Dashboard } from "@/components/dashboard/dashboard";
|
||||||
|
import { AuthGate } from "@/components/dashboard/auth-gate";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return <Dashboard />;
|
return (
|
||||||
|
<AuthGate>
|
||||||
|
<Dashboard />
|
||||||
|
</AuthGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { Providers } from "./providers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -31,7 +32,9 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</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 { Sun, Moon, ChevronDown } from "lucide-react";
|
||||||
import { Icon } from "./ui";
|
import { Icon } from "./ui";
|
||||||
import { user } from "./account-data";
|
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 }) {
|
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 (
|
return (
|
||||||
<header className="dash-topbar">
|
<header className="dash-topbar">
|
||||||
<div className="dash-title">
|
<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)" }} />
|
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
||||||
</button>
|
</button>
|
||||||
<button className="top-user">
|
<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 style={{ textAlign: "left" }}>
|
||||||
<div className="nm">{user.name}</div>
|
<div className="nm">{name}</div>
|
||||||
<div className="rl">{user.role}</div>
|
<div className="rl">{user.role}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial,
|
RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial,
|
||||||
PasswordStrength,
|
PasswordStrength,
|
||||||
} from "./bits";
|
} 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 =
|
type Step =
|
||||||
| "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password"
|
| "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password"
|
||||||
@@ -19,6 +24,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||||||
|
|
||||||
export function LoginFlow() {
|
export function LoginFlow() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
|
||||||
const [step, setStep] = useState<Step>("identify");
|
const [step, setStep] = useState<Step>("identify");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [account, setAccount] = useState<Account | null>(null);
|
const [account, setAccount] = useState<Account | null>(null);
|
||||||
@@ -47,6 +53,17 @@ export function LoginFlow() {
|
|||||||
return () => window.removeEventListener("popstate", onPop);
|
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 ---- */
|
/* ---- auth resolution ---- */
|
||||||
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
||||||
if (factor === "password") { setFlash(""); push("otp"); return; }
|
if (factor === "password") { setFlash(""); push("otp"); return; }
|
||||||
@@ -56,6 +73,14 @@ export function LoginFlow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSocial(p: "google" | "microsoft" | "apple") {
|
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);
|
const real = startSocial(p);
|
||||||
if (real) return; // redirected away
|
if (real) return; // redirected away
|
||||||
setProvider(p);
|
setProvider(p);
|
||||||
@@ -65,6 +90,19 @@ export function LoginFlow() {
|
|||||||
|
|
||||||
function identifyEmail() {
|
function identifyEmail() {
|
||||||
if (!EMAIL_RE.test(email)) return;
|
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");
|
push("connecting");
|
||||||
setProvider("");
|
setProvider("");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -124,7 +162,7 @@ export function LoginFlow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "password" && account && (
|
{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 && (
|
{step === "otp" && account && (
|
||||||
@@ -274,16 +312,27 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
|
function Password({ account, email, login, onAuthenticated, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
|
||||||
account: Account; flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void;
|
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 [pw, setPw] = useState("");
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
function submit(e: React.FormEvent) {
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
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.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; }
|
||||||
if (!pw) return;
|
|
||||||
onOk();
|
onOk();
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -304,13 +353,13 @@ function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
<div className="row between" style={{ marginTop: 16 }}>
|
<div className="row between" style={{ marginTop: 16 }}>
|
||||||
<button className="link" onClick={onForgot}>Forgot password?</button>
|
<button className="link" onClick={onForgot}>Forgot password?</button>
|
||||||
<button className="link" onClick={onOtp}>Sign in using email OTP</button>
|
<button className="link" onClick={onOtp}>Sign in using email OTP</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,17 @@ import {
|
|||||||
countryCodes, relationshipOptions, addressCountries,
|
countryCodes, relationshipOptions, addressCountries,
|
||||||
TERMS, PRIVACY, passwordStrength, type AddrCountry,
|
TERMS, PRIVACY, passwordStrength, type AddrCountry,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
|
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
|
import { isShellConfigured } from "@/lib/appshell";
|
||||||
|
|
||||||
const STEPS = ["Account", "Verify", "Address"];
|
const STEPS = ["Account", "Verify", "Address"];
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function RegisterFlow() {
|
export function RegisterFlow() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { register } = useAuth();
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
|
const [submitErr, setSubmitErr] = useState("");
|
||||||
|
|
||||||
// shared form state
|
// shared form state
|
||||||
const [sso, setSso] = useState<{ provider: string; email: string } | null>(null);
|
const [sso, setSso] = useState<{ provider: string; email: string } | null>(null);
|
||||||
@@ -51,15 +55,23 @@ export function RegisterFlow() {
|
|||||||
|
|
||||||
const step2Valid = emailVerified && phoneVerified;
|
const step2Valid = emailVerified && phoneVerified;
|
||||||
|
|
||||||
function finish() {
|
async function finish() {
|
||||||
|
const finalEmail = sso?.email || email;
|
||||||
const profile = {
|
const profile = {
|
||||||
name: `${first} ${last}`.trim(),
|
name: `${first} ${last}`.trim(),
|
||||||
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
|
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
|
||||||
email: sso?.email || email,
|
email: finalEmail,
|
||||||
isAllottee: isSelf,
|
isAllottee: isSelf,
|
||||||
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
|
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
|
||||||
};
|
};
|
||||||
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
|
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");
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +104,10 @@ export function RegisterFlow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
||||||
<StepAddress onBack={() => setStep(1)} onFinish={finish} />
|
<StepAddress onBack={() => setStep(1)} onFinish={finish} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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