From 1d375931024778d2a623fd0e26594e5c7faf2249 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Thu, 9 Jul 2026 20:51:17 +0530 Subject: [PATCH 1/3] 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); +} -- 2.52.0 From 565019a75c9535dd9c6272b1d5ed7dd52434c619 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Sat, 11 Jul 2026 04:44:54 +0530 Subject: [PATCH 2/3] feat(team+auth): live multi-role team management + email/SMS OTP login - team-management: new useTeamData() data layer serves live be-crm data door (crm.team.member.search/role.list/invitation.list + set-roles/perm/invite commands) or the mock fallback, gated on isShellConfigured(). A member can hold MANY roles: role chips + multi-select picker in Edit/Invite modals. - login OTP: OtpVerify wired to real Supabase email + SMS OTP (passwordless) via appshell sendEmailOtp/verifyEmailOtp + sendPhoneOtp/verifyPhoneOtp. - bump @abe-kap/appshell-sdk ^0.2.0 (phone OTP). --- package.json | 2 +- src/app/dashboard/dashboard.css | 11 +- src/components/dashboard/team-management.tsx | 380 +++++++++---------- src/components/portal/login-flow.tsx | 84 +++- src/lib/team-api.ts | 202 ++++++++++ 5 files changed, 462 insertions(+), 217 deletions(-) create mode 100644 src/lib/team-api.ts diff --git a/package.json b/package.json index eafbae6..6051c7d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "@abe-kap/appshell-sdk": "^0.1.0", + "@abe-kap/appshell-sdk": "^0.2.0", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index 5322c9b..4697180 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -962,7 +962,16 @@ .dash-root .tm-roledot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; background: var(--rc); box-shadow: 0 0 0 3px color-mix(in srgb, var(--rc) 22%, transparent); } .dash-root .tm-roleselect .ds-select { border: 0; background-color: transparent; height: 34px; padding: 0 26px 0 0; font-weight: 600; font-size: 12.5px; box-shadow: none; background-position: calc(100% - 6px) 15px, calc(100% - 1px) 15px; } .dash-root .tm-roleselect .ds-select:focus { box-shadow: none; } - + + /* Multi-role: chips (read-only) + picker (toggle) */ + .dash-root .tm-rolechips { display: inline-flex; flex-wrap: wrap; gap: 6px; } + .dash-root .tm-rolepicker { display: flex; flex-wrap: wrap; gap: 8px; } + .dash-root .tm-rolepick { --rc: var(--text-2); display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px 0 9px; border-radius: 99px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--text-1); font-size: 12.5px; font-weight: 600; cursor: pointer; transition: 0.14s; } + .dash-root .tm-rolepick:hover { border-color: color-mix(in srgb, var(--rc) 55%, transparent); } + .dash-root .tm-rolepick.on { color: var(--rc); background: color-mix(in srgb, var(--rc) 12%, transparent); border-color: color-mix(in srgb, var(--rc) 40%, transparent); } + .dash-root .tm-rolepick-check { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 5px; border: 1.5px solid var(--border-2); color: #fff; flex: 0 0 auto; } + .dash-root .tm-rolepick.on .tm-rolepick-check { background: var(--rc); border-color: var(--rc); } + .dash-root .tm-statuscell { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; } .dash-root .tm-lastactive { color: var(--faint); font-weight: 500; font-size: 11.5px; margin-left: 2px; } diff --git a/src/components/dashboard/team-management.tsx b/src/components/dashboard/team-management.tsx index 7c96e87..6400cdb 100644 --- a/src/components/dashboard/team-management.tsx +++ b/src/components/dashboard/team-management.tsx @@ -5,43 +5,44 @@ // a creative "crew" experience. // · Crew hero : avatar stack, live presence, headline stats // · Members : people gallery (cards) ⇄ compact list, with -// role filter chips, search, inline role swap, +// role filter chips, search, MULTI-role chips, // deal-load meters and row actions // · Roles : permission-coverage rings + live toggle matrix // · Invites : envelope timeline with resend / revoke -// Everything is local state so the screen is fully clickable. +// Data comes from useTeamData(): the local mock when the Shell +// isn't configured, or the live be-crm data door (crm.team.*) +// when it is. A member can hold MANY roles. // ============================================================ import { useEffect, useMemo, useState } from "react"; import { Avatar, Btn, Field, Icon, Modal, Pill, StatusDot, Toggle, useToast, } from "./ui"; -import { - members as seedMembers, roles as seedRoles, pendingInvites as seedInvites, - permissionGroups, allPermissionIds, type Member, type Role, type Invite, -} from "./team-data"; +import { permissionGroups, allPermissionIds } from "./team-data"; +import { useTeamData, type UiMember, type UiRole, type UiStatus } from "@/lib/team-api"; -const STATUS_LABEL: Record = { active: "Active", away: "Away", offline: "Offline" }; -const toDot = (s: Member["status"]) => (s === "offline" ? "offline" : s === "away" ? "away" : "online"); +const STATUS_LABEL: Record = { active: "Active", away: "Away", offline: "Offline" }; +const toDot = (s: UiStatus) => (s === "offline" ? "offline" : s === "away" ? "away" : "online"); +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; export function TeamManagement() { const toast = useToast(); + const team = useTeamData(); + const { members, roles, invites, live } = team; + const [tab, setTab] = useState("members"); const [view, setView] = useState<"grid" | "list">("grid"); - const [members, setMembers] = useState(seedMembers); - const [roles, setRoles] = useState(seedRoles); - const [invites, setInvites] = useState(seedInvites); - const [query, setQuery] = useState(""); const [roleFilter, setRoleFilter] = useState("all"); const [inviteOpen, setInviteOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [confirmRemove, setConfirmRemove] = useState(null); + const [editing, setEditing] = useState(null); + const [confirmRemove, setConfirmRemove] = useState(null); const roleById = useMemo(() => Object.fromEntries(roles.map((r) => [r.id, r])), [roles]); + const assignableRoles = useMemo(() => roles.filter((r) => !r.isOwner), [roles]); const countByRole = useMemo(() => { const m: Record = {}; - for (const mem of members) m[mem.roleId] = (m[mem.roleId] ?? 0) + 1; + for (const mem of members) for (const rid of mem.roleIds) m[rid] = (m[rid] ?? 0) + 1; return m; }, [members]); const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]); @@ -49,31 +50,29 @@ export function TeamManagement() { const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return members.filter((m) => { - const matchRole = roleFilter === "all" || m.roleId === roleFilter; + const matchRole = roleFilter === "all" || m.roleIds.includes(roleFilter); const matchQ = !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.title.toLowerCase().includes(q); return matchRole && matchQ; }); }, [members, query, roleFilter]); const activeCount = members.filter((m) => m.status === "active").length; - const totalDeals = members.reduce((sum, m) => sum + m.deals, 0); - function changeRole(id: string, roleId: string) { - setMembers((list) => list.map((m) => (m.id === id ? { ...m, roleId } : m))); - const m = members.find((x) => x.id === id); - toast.push({ tone: "success", title: "Role updated", desc: `${m?.name ?? "Member"} is now ${roleById[roleId]?.name ?? roleId}.` }); + /* ---- mutations (async; live → data door, mock → local) ---- */ + async function saveRoles(m: UiMember, roleIds: string[]) { + try { + await team.setMemberRoles(m.id, roleIds); + toast.push({ tone: "success", title: "Roles updated", desc: `${m.name} now holds ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` }); + } catch (e) { toast.push({ tone: "error", title: "Couldn't update roles", desc: (e as Error).message }); } } - function removeMember(m: Member) { - setMembers((list) => list.filter((x) => x.id !== m.id)); + async function removeMember(m: UiMember) { setConfirmRemove(null); - toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` }); + try { await team.removeMember(m.id); toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` }); } + catch (e) { toast.push({ tone: "error", title: "Couldn't remove member", desc: (e as Error).message }); } } - function togglePerm(roleId: string, permId: string) { - setRoles((list) => list.map((r) => { - if (r.id !== roleId) return r; - const has = r.perms.includes(permId); - return { ...r, perms: has ? r.perms.filter((p) => p !== permId) : [...r.perms, permId] }; - })); + async function togglePerm(roleId: string, permId: string, granted: boolean) { + try { await team.setPermission(roleId, permId, granted); } + catch (e) { toast.push({ tone: "error", title: "Couldn't update permission", desc: (e as Error).message }); } } // Crew stack — show online folks first, capped to 6 with a +N bubble. @@ -97,7 +96,10 @@ export function TeamManagement() { {stackRest > 0 && +{stackRest}}
-
Your crew · LynkedUp Pro
+
+ Your crew · LynkedUp Pro + {live && Live} +

Team Management

Manage your crew, assign roles and control exactly what each person can do.

@@ -112,14 +114,6 @@ export function TeamManagement() {
- {/* ---- Metric strip ------------------------------------- */} - {/*
- - - !r.system).length} custom`} /> - -
*/} - {/* ---- Tabs --------------------------------------------- */}
{[ @@ -134,6 +128,8 @@ export function TeamManagement() { ))}
+ {team.error &&

Couldn't load team data: {team.error}

} + {/* ---- MEMBERS ------------------------------------------ */} {tab === "members" && (
@@ -161,88 +157,62 @@ export function TeamManagement() { ))}
- {filtered.length === 0 ? ( + {team.live && team.loading && members.length === 0 ? ( +

Loading your crew…

+ ) : filtered.length === 0 ? (

No members match your search.

{ setQuery(""); setRoleFilter("all"); }}>Clear filters
) : view === "grid" ? (
- {filtered.map((m) => { - const role = roleById[m.roleId]; - const isOwner = m.roleId === "owner"; - return ( -
- -
- - setEditing(m)} onRemove={() => setConfirmRemove(m)} /> -
-
{m.name}{isOwner && }
-
{m.title}
-
{m.email}
+ {filtered.map((m) => ( +
+ +
+ + setEditing(m)} onRemove={() => setConfirmRemove(m)} /> +
+
{m.name}{m.isOwner && }
+
{m.title || No title}
+
{m.email}
-
- {isOwner ? ( - {role?.name} - ) : ( -
- - -
- )} -
+
+ +
-
-
Deal load{m.deals}
- -
+
+
Deal load{m.deals}
+ +
-
- {STATUS_LABEL[m.status]} - {m.lastActive} -
-
- ); - })} +
+ {STATUS_LABEL[m.status]} + {m.lastActive} +
+
+ ))}
) : (
- MemberRoleStatusOpen deals + MemberRolesStatusOpen deals
- {filtered.map((m) => { - const role = roleById[m.roleId]; - const isOwner = m.roleId === "owner"; - return ( -
-
- -
-
{m.name}{isOwner && You · Owner}
-
{m.title} · {m.email}
-
+ {filtered.map((m) => ( +
+
+ +
+
{m.name}{m.isYou && You{m.isOwner ? " · Owner" : ""}}
+
{m.title ? `${m.title} · ` : ""}{m.email}
-
- {isOwner ? ( - {role?.name} - ) : ( -
- - -
- )} -
-
- {STATUS_LABEL[m.status]} - {m.lastActive} -
-
{m.deals > 0 ? {m.deals} : }
-
setEditing(m)} onRemove={() => setConfirmRemove(m)} />
- ); - })} +
+
+ {STATUS_LABEL[m.status]} + {m.lastActive} +
+
{m.deals > 0 ? {m.deals} : }
+
setEditing(m)} onRemove={() => setConfirmRemove(m)} />
+
+ ))}
)}
@@ -257,31 +227,30 @@ export function TeamManagement() {
- +
{r.name}{r.system && System}
{countByRole[r.id] ?? 0} {(countByRole[r.id] ?? 0) === 1 ? "member" : "members"} · {r.perms.length}/{allPermissionIds.length} permissions
-

{r.description}

+ {r.description &&

{r.description}

} -
-
Attire & appearance
-
    - {r.attire.map((a) => ( -
  • {a}
  • - ))} -
-
- -
- -
- Common overlap -

{r.overlap}

+ {r.attire.length > 0 && ( +
+
Attire & appearance
+
    + {r.attire.map((a) => (
  • {a}
  • ))} +
-
+ )} + + {r.overlap && ( +
+ +
Common overlap

{r.overlap}

+
+ )}
{permissionGroups.map((g) => ( @@ -289,14 +258,14 @@ export function TeamManagement() {
{g.title}
{g.perms.map((p) => { const granted = r.perms.includes(p.id); - const locked = r.id === "owner"; + const locked = r.isOwner; return (
{p.label} {p.desc}
- togglePerm(r.id, p.id)} label={p.label} /> + togglePerm(r.id, p.id, !granted)} label={p.label} />
); })} @@ -323,23 +292,26 @@ export function TeamManagement() {

No pending invites. Everyone's on board.

) : (
- {invites.map((inv) => { - const role = roleById[inv.roleId]; - return ( -
- -
-
{inv.email}
-
Invited by {inv.invitedBy} · {inv.sentAt}
-
- {role?.name} -
- toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` })}>Resend - { setInvites((l) => l.filter((x) => x.id !== inv.id)); toast.push({ tone: "info", title: "Invite revoked" }); }}>Revoke -
+ {invites.map((inv) => ( +
+ +
+
{inv.email}
+
Invited by {inv.invitedBy} · {inv.sentAt}
- ); - })} + +
+ { + try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` }); } + catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); } + }}>Resend + { + try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); } + catch (e) { toast.push({ tone: "error", title: "Couldn't revoke", desc: (e as Error).message }); } + }}>Revoke +
+
+ ))}
)}
@@ -347,24 +319,30 @@ export function TeamManagement() { setInviteOpen(false)} - onInvite={(email, roleId) => { - setInvites((l) => [{ id: `inv_${l.length + Date.now()}`, email, roleId, invitedBy: "James Carter", sentAt: "Just now" }, ...l]); + onInvite={async (email, roleIds) => { setInviteOpen(false); - setTab("invites"); - toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited as ${roleById[roleId]?.name}.` }); + try { + await team.invite(email, roleIds); + setTab("invites"); + toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited with ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` }); + } catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); } }} /> setEditing(null)} - onSave={(id, patch) => { - setMembers((list) => list.map((m) => (m.id === id ? { ...m, ...patch } : m))); + onSave={async (id, title, roleIds) => { + const m = editing; setEditing(null); - toast.push({ tone: "success", title: "Member updated" }); + if (!m) return; + try { + await team.updateMember(id, { title, roleIds }); + toast.push({ tone: "success", title: "Member updated" }); + } catch (e) { toast.push({ tone: "error", title: "Couldn't update member", desc: (e as Error).message }); } }} /> @@ -389,22 +367,22 @@ export function TeamManagement() { } /* ---------------------------------------------------------- */ -/* Stat card */ +/* Role chips — show every role a member/invite holds */ /* ---------------------------------------------------------- */ -function StatCard({ icon, tone, value, label, foot, bar }: { icon: string; tone: string; value: number; label: string; foot?: string; bar?: number }) { +function RoleChips({ roleIds, roleById }: { roleIds: string[]; roleById: Record }) { + if (roleIds.length === 0) return No role; return ( -
-
- -
-
{value}
-
{label}
-
-
- {bar != null && } - {foot &&
{foot}
} -
+ + {roleIds.map((rid) => { + const role = roleById[rid]; + return ( + + {role?.name ?? rid} + + ); + })} + ); } @@ -423,7 +401,7 @@ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove:
setOpen(false)} />
- +
@@ -433,21 +411,37 @@ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove: ); } +/* ---------------------------------------------------------- */ +/* Multi-role picker — checkbox chips, ≥1 required */ +/* ---------------------------------------------------------- */ + +function RolePicker({ roles, selected, onToggle }: { roles: UiRole[]; selected: string[]; onToggle: (id: string) => void }) { + return ( +
+ {roles.map((r) => { + const on = selected.includes(r.id); + return ( + + ); + })} +
+ ); +} + /* ---------------------------------------------------------- */ /* Invite modal */ /* ---------------------------------------------------------- */ -function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: Role[]; onClose: () => void; onInvite: (email: string, roleId: string) => void }) { +function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: UiRole[]; onClose: () => void; onInvite: (email: string, roleIds: string[]) => void }) { const [email, setEmail] = useState(""); - const [roleId, setRoleId] = useState("sales-rep"); - const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim()); - const role = roles.find((r) => r.id === roleId); + const [roleIds, setRoleIds] = useState([]); + const valid = EMAIL_RE.test(email.trim()) && roleIds.length > 0; - function submit() { - if (!valid) return; - onInvite(email.trim(), roleId); - setEmail(""); setRoleId("sales-rep"); - } + function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); } + function submit() { if (!valid) return; onInvite(email.trim(), roleIds); setEmail(""); setRoleIds([]); } return (
- + setEmail(e.target.value)} /> - - + + - {role && ( -
-
{role.name}{role.perms.length} permissions
-

{role.description}

-
- )}
); } /* ---------------------------------------------------------- */ -/* Edit member modal */ +/* Edit member modal — job title + multi-role */ /* ---------------------------------------------------------- */ -function EditMemberModal({ member, roles, onClose, onSave }: { member: Member | null; roles: Role[]; onClose: () => void; onSave: (id: string, patch: Partial) => void }) { +function EditMemberModal({ member, roles, onClose, onSave }: { member: UiMember | null; roles: UiRole[]; onClose: () => void; onSave: (id: string, title: string, roleIds: string[]) => void }) { const [title, setTitle] = useState(""); - const [roleId, setRoleId] = useState("sales-rep"); + const [roleIds, setRoleIds] = useState([]); - // Sync local form when a different member is opened. const key = member?.id ?? ""; - useEffect(() => { if (member) { setTitle(member.title); setRoleId(member.roleId); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/set-state-in-effect + useEffect(() => { if (member) { setTitle(member.title); setRoleIds(member.roleIds); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps if (!member) return null; + const valid = roleIds.length > 0; + function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); } + return ( Cancel - onSave(member.id, { title, roleId })}>Save changes + onSave(member.id, title, roleIds)}>Save changes } > @@ -519,12 +507,10 @@ function EditMemberModal({ member, roles, onClose, onSave }: { member: Member |
- setTitle(e.target.value)} /> + setTitle(e.target.value)} placeholder="e.g. Senior Sales Rep" /> - - + +
diff --git a/src/components/portal/login-flow.tsx b/src/components/portal/login-flow.tsx index 507994a..96ced16 100644 --- a/src/components/portal/login-flow.tsx +++ b/src/components/portal/login-flow.tsx @@ -166,7 +166,7 @@ export function LoginFlow() { )} {step === "otp" && account && ( - afterAuth("otp")} /> + afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} /> )} {step === "another" && account && ( @@ -364,35 +364,83 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor ); } -function OtpVerify({ account, remember, setRemember, onBack, onVerified }: { - account: Account; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; +function OtpVerify({ account, email, remember, setRemember, onBack, onVerified, onAuthenticated }: { + account: Account; email: string; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void; }) { + const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth(); + const shell = isShellConfigured(); + // In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp. const [channel, setChannel] = useState<"email" | "sms" | "wa">("email"); - const [error, setError] = useState(false); - const target = channel === "email" ? account.maskedEmail : channel === "sms" ? account.maskedPhone : account.maskedWa; - function complete(code: string) { - if (code === "000000") { setError(true); return; } - setError(false); onVerified(); + const [phone, setPhone] = useState(""); + const [sent, setSent] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const target = channel === "email" ? account.maskedEmail : channel === "sms" ? (phone || account.maskedPhone) : account.maskedWa; + + const PHONE_RE = /^\+[1-9]\d{6,14}$/; + + // Real Supabase OTP: auto-send the email code when this screen opens. + useEffect(() => { + if (shell && channel === "email" && !sent) void send(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shell, channel]); + + async function send() { + setError(""); + try { + if (channel === "email") { await sendEmailOtp(email); setSent(true); } + else if (channel === "sms") { + if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; } + await sendPhoneOtp(phone); setSent(true); + } + } catch (e) { setError((e as Error).message); } } + + async function complete(code: string) { + if (!shell) { if (code === "000000") { setError("bad"); return; } setError(""); onVerified(); return; } + setBusy(true); setError(""); + try { + if (channel === "email") await verifyEmailOtp(email, code); + else await verifyPhoneOtp(phone, code); + onAuthenticated(); + } catch { setError("Incorrect or expired code. Please try again."); } + finally { setBusy(false); } + } + + const showBoxes = !shell || sent; return (
-

2-step verification

-

Enter the 6-digit code we sent you to finish signing in.

+

{shell ? "One-time passcode" : "2-step verification"}

+

{shell ? "We'll text or email you a 6-digit code to sign in." : "Enter the 6-digit code we sent you to finish signing in."}

- - - + + + {!shell && }
-

Code sent to {target}

- - {error &&
Incorrect code. Please try again.
} + + {shell && channel === "sms" && !sent ? ( +
{ e.preventDefault(); void send(); }}> +
+ + setPhone(e.target.value)} /> +
+ +
+ ) : ( + <> +

Code sent to {target}

+ {showBoxes && void complete(c)} error={!!error} />} + + )} + + {error &&
{error === "bad" ? "Incorrect code. Please try again." : error}
}
- + {shell ? : }
-

Demo: any 6 digits verify; “000000” shows an error.

+ {!shell &&

Demo: any 6 digits verify; “000000” shows an error.

}
); } diff --git a/src/lib/team-api.ts b/src/lib/team-api.ts new file mode 100644 index 0000000..029ed33 --- /dev/null +++ b/src/lib/team-api.ts @@ -0,0 +1,202 @@ +"use client"; + +// Team-management data layer. Serves EITHER the local mock (when the Shell isn't +// configured — the polished demo keeps working) OR the live be-crm data door +// (crm.team.*), behind one interface so the component is mode-agnostic. +// +// Live contract (be-crm, multi-role): +// query crm.team.member.search { perPage } -> { items: MemberDTO[], meta } +// query crm.team.role.list { includeEmpty } -> { items: RoleDTO[], meta } +// query crm.team.invitation.list { status } -> { items: InvitationDTO[], meta } +// cmd crm.team.member.setRoles { id, roleIds } +// cmd crm.team.member.update { id, jobTitle?, roleIds? } +// cmd crm.team.member.remove { id } +// cmd crm.team.invitation.create/resend/revoke +// cmd crm.team.role.addPermission/removePermission { id, permissionId } + +import { useCallback, useMemo, useState } from "react"; +import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; +import { + members as seedMembers, roles as seedRoles, pendingInvites as seedInvites, + type Member as SeedMember, type Role as SeedRole, type Invite as SeedInvite, +} from "@/components/dashboard/team-data"; + +/* ---- UI-facing shapes (multi-role; superset of the mock) ---------------- */ + +export type UiStatus = "active" | "away" | "offline"; + +export interface UiRole { + id: string; slug: string; name: string; color: string; + description: string; system: boolean; isOwner: boolean; + perms: string[]; attire: string[]; overlap: string; memberCount?: number; +} +export interface UiMember { + id: string; principalId?: string; name: string; initials: string; email: string; + title: string; roleIds: string[]; gradient: string; status: UiStatus; + lastActive: string; deals: number; joined: string; isOwner: boolean; isYou: boolean; +} +export interface UiInvite { + id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string; +} + +export interface TeamData { + live: boolean; loading: boolean; error: string | null; + members: UiMember[]; roles: UiRole[]; invites: UiInvite[]; + setMemberRoles: (id: string, roleIds: string[]) => Promise; + updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise; + removeMember: (id: string) => Promise; + invite: (email: string, roleIds: string[]) => Promise; + resendInvite: (id: string) => Promise; + revokeInvite: (id: string) => Promise; + setPermission: (roleId: string, permId: string, granted: boolean) => Promise; + refetch: () => void; +} + +/* ---- helpers ------------------------------------------------------------ */ + +const GRADIENTS = [ + "linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)", + "linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)", + "linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#f59e0b,#ef4444)", + "linear-gradient(135deg,#8b5cf6,#d946ef)", "linear-gradient(135deg,#14b8a6,#0ea5e9)", +]; +const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length]; +const initialsOf = (name: string) => + name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; + +/* ---- be-crm DTO types (subset used here) -------------------------------- */ + +interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean } +interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number } +interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number } +interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string } + +const relTime = (iso: string): string => { + const then = Date.parse(iso); + if (Number.isNaN(then)) return iso; + return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); +}; + +/* ---- mock → UI ---------------------------------------------------------- */ + +const mockRole = (r: SeedRole): UiRole => ({ + id: r.id, slug: r.id, name: r.name, color: r.color, description: r.description, + system: r.system, isOwner: r.id === "owner", perms: r.perms, attire: r.attire, overlap: r.overlap, +}); +const mockMember = (m: SeedMember): UiMember => ({ + id: m.id, name: m.name, initials: m.initials, email: m.email, title: m.title, + roleIds: [m.roleId], gradient: m.gradient, status: m.status, lastActive: m.lastActive, + deals: m.deals, joined: m.joined, isOwner: m.roleId === "owner", isYou: m.roleId === "owner", +}); +const mockInvite = (i: SeedInvite): UiInvite => ({ + id: i.id, email: i.email, roleIds: [i.roleId], invitedBy: i.invitedBy, sentAt: i.sentAt, +}); + +/* ======================================================================== */ +/* Mock implementation (no Shell configured) */ +/* ======================================================================== */ + +function useMockTeam(): TeamData { + const [members, setMembers] = useState(() => seedMembers.map(mockMember)); + const [roles, setRoles] = useState(() => seedRoles.map(mockRole)); + const [invites, setInvites] = useState(() => seedInvites.map(mockInvite)); + + const setMemberRoles = useCallback(async (id: string, roleIds: string[]) => { + setMembers((l) => l.map((m) => (m.id === id ? { ...m, roleIds } : m))); + }, []); + const updateMember = useCallback(async (id: string, patch: { title?: string; roleIds?: string[] }) => { + setMembers((l) => l.map((m) => (m.id === id + ? { ...m, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}) } + : m))); + }, []); + const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []); + const invite = useCallback(async (email: string, roleIds: string[]) => { + setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]); + }, []); + const resendInvite = useCallback(async () => {}, []); + const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []); + const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => { + setRoles((l) => l.map((r) => (r.id !== roleId ? r : { + ...r, perms: granted ? [...new Set([...r.perms, permId])] : r.perms.filter((p) => p !== permId), + }))); + }, []); + + return { live: false, loading: false, error: null, members, roles, invites, + setMemberRoles, updateMember, removeMember, invite, resendInvite, revokeInvite, setPermission, refetch: () => {} }; +} + +/* ======================================================================== */ +/* Live implementation (be-crm data door) */ +/* ======================================================================== */ + +function useLiveTeam(): TeamData { + const { sdk } = useAppShell(); + const { user } = useAuth(); + const meId = user?.id; + + const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 }); + const rolesQ = useQuery<{ items: RoleDTO[] }>("crm.team.role.list", { includeEmpty: true }); + const invitesQ = useQuery<{ items: InvitationDTO[] }>("crm.team.invitation.list", { status: "pending" }); + + const refetch = useCallback(() => { membersQ.refetch(); rolesQ.refetch(); invitesQ.refetch(); }, [membersQ, rolesQ, invitesQ]); + const cmd = useCallback(async (action: string, variables: Record) => { + await sdk.command(action, variables); + refetch(); + }, [sdk, refetch]); + + const roles: UiRole[] = useMemo(() => (rolesQ.data?.items ?? []).map((r) => ({ + id: r.id, slug: r.slug, name: r.name, color: r.color ?? "var(--text-2)", + description: r.description ?? "", system: r.isSystem, isOwner: r.isOwnerRole, + perms: r.permissions, attire: [], overlap: "", memberCount: r.memberCount, + })), [rolesQ.data]); + + const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => { + const isYou = !!meId && m.principalId === meId; + const name = isYou && user?.displayName + ? user.displayName + : (m.jobTitle?.trim() || `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`); + return { + id: m.id, principalId: m.principalId, name, initials: initialsOf(name), + email: isYou && user?.email ? user.email : m.principalId, + title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id), + status: (m.status === "active" ? "active" : "offline") as UiStatus, + lastActive: m.status === "active" ? "Active" : "—", + deals: m.openDeals, joined: relTime(m.joinedAt), + isOwner: m.roles.some((r) => r.isOwnerRole), isYou, + }; + }), [membersQ.data, meId, user]); + + const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({ + id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id), + invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), + })), [invitesQ.data]); + + return { + live: true, + loading: membersQ.loading || rolesQ.loading || invitesQ.loading, + error: (membersQ.error ?? rolesQ.error ?? invitesQ.error)?.message ?? null, + members, roles, invites, + setMemberRoles: (id, roleIds) => cmd("crm.team.member.setRoles", { id, roleIds }), + updateMember: (id, patch) => cmd("crm.team.member.update", { + id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}), + }), + removeMember: (id) => cmd("crm.team.member.remove", { id }), + invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }), + resendInvite: (id) => cmd("crm.team.invitation.resend", { id }), + revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }), + setPermission: (roleId, permId, granted) => + cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }), + refetch, + }; +} + +/* ---- public hook: pick the implementation at module-config time --------- */ + +const SHELL = isShellConfigured(); + +export function useTeamData(): TeamData { + // `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time), + // so the same hook path runs every render — Rules-of-Hooks safe. + return SHELL ? useLiveTeam() : useMockTeam(); +} -- 2.52.0 From 339e2b007a025ed8406ad18ca2fbf0aac6b00b04 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Sat, 11 Jul 2026 06:19:49 +0530 Subject: [PATCH 3/3] feat(register): persist full registration to crm.account.register Lift StepAddress/AddressBlock state up so finish() can send the complete sign-up payload (name, phone, persona, allottee id/names, registered+mailing address, consent) to be-crm crm.account.register after auth. Config-gated; non-fatal if the domain persist fails (auth account already created). --- src/components/portal/register-flow.tsx | 59 ++++++++++++++++++++----- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx index 162eb31..c9829e4 100644 --- a/src/components/portal/register-flow.tsx +++ b/src/components/portal/register-flow.tsx @@ -11,18 +11,26 @@ import { countryCodes, relationshipOptions, addressCountries, TERMS, PRIVACY, passwordStrength, type AddrCountry, } from "./data"; -import { useAuth } from "@abe-kap/appshell-sdk/react"; +import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react"; import { isShellConfigured } from "@/lib/appshell"; +type Addr = { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string; locality?: string }; + const STEPS = ["Account", "Verify", "Address"]; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function RegisterFlow() { const router = useRouter(); const { register } = useAuth(); + const { sdk } = useAppShell(); const [step, setStep] = useState(0); const [submitErr, setSubmitErr] = useState(""); + // address (lifted from StepAddress/AddressBlock so finish() can persist it) + const [regAddr, setRegAddr] = useState({}); + const [mailAddr, setMailAddr] = useState({}); + const [mailingSame, setMailingSame] = useState(true); + // shared form state const [sso, setSso] = useState<{ provider: string; email: string } | null>(null); const [email, setEmail] = useState(""); @@ -65,12 +73,33 @@ export function RegisterFlow() { 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) { + + if (isShellConfigured()) { setSubmitErr(""); - try { await register(finalEmail, pw); } - catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; } + // 1) Auth account — appshell → Supabase (SSO users already have a session). + if (!sso) { + try { await register(finalEmail, pw); } + catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; } + } + // 2) Persist the full CRM registration payload to be-crm (name, phone, persona, + // allottee, both addresses, consent). Non-fatal: the auth account exists either way. + try { + const mailing = mailingSame ? regAddr : mailAddr; + await sdk.command("crm.account.register", { + email: finalEmail, + firstName: first, lastName: last, + phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""), + persona: relationship, + isAllottee: isSelf, + ...(isSelf ? {} : { allotteeId: alloeNo, allotteeFirstName: alloeFirst, allotteeLastName: alloeLast }), + registeredAddress: regAddr, + mailingAddress: mailing, + mailingSameAsRegistered: mailingSame, + consentTerms: termsOk, consentPrivacy: privacyOk, + }); + } catch (e) { + console.warn("crm.account.register failed (continuing):", e); + } } router.push("/dashboard"); } @@ -106,7 +135,7 @@ export function RegisterFlow() { {step === 2 && ( <> {submitErr &&
{submitErr}
} - setStep(1)} onFinish={finish} /> + setStep(1)} onFinish={finish} /> )}
@@ -391,8 +420,7 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV } /* ====================== STEP 3 — ADDRESS ====================== */ -function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () => void }) { - const [sameAs, setSameAs] = useState(true); +function StepAddress({ sameAs, setSameAs, onRegAddr, onMailAddr, onBack, onFinish }: { sameAs: boolean; setSameAs: (v: boolean) => void; onRegAddr: (a: Addr) => void; onMailAddr: (a: Addr) => void; onBack: () => void; onFinish: () => void }) { return (
@@ -400,7 +428,7 @@ function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () =>

Add your registered address and where we should send mail.

- +