From 103f3ae3a12d0e391643a9829edcc5cbd03392f2 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Mon, 13 Jul 2026 16:25:53 +0530 Subject: [PATCH] feat(rbac): no role at registration; invitation-based membership; nav gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Registration no longer asks for a role/persona (removed the role select + allottee block); crm.account.register sends no persona. - New users have no permissions → the sidebar now shows only Dashboard + Profile for them, gated on crm.account.me (membership + permissions). Members see the areas their permissions allow. - Add /portal/invite?token=… : accepts the invite for a signed-in registered user, or routes an unregistered invitee through register/onboarding, which redeems the stashed token on completion (granting the invited role). - Team Management: 'Copy link' on pending invites builds the invite link from the invitation token (no email delivery yet). --- src/app/portal/invite/page.tsx | 91 ++++++++++++++++++++ src/components/dashboard/sidebar.tsx | 38 +++++++- src/components/dashboard/team-management.tsx | 7 ++ src/components/portal/register-flow.tsx | 78 ++++------------- src/lib/access.ts | 66 ++++++++++++++ src/lib/team-api.ts | 6 +- 6 files changed, 220 insertions(+), 66 deletions(-) create mode 100644 src/app/portal/invite/page.tsx create mode 100644 src/lib/access.ts diff --git a/src/app/portal/invite/page.tsx b/src/app/portal/invite/page.tsx new file mode 100644 index 0000000..f011fa6 --- /dev/null +++ b/src/app/portal/invite/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react"; +import { PortalAside, PanelBrand } from "@/components/portal/parts"; +import { CookieBanner, Spinner } from "@/components/portal/bits"; +import { isShellConfigured } from "@/lib/appshell"; + +/** + * Team invitation landing page (/portal/invite?token=…). Redeems the invite: + * - not signed in → send to registration; it redeems the stashed token on finish. + * - signed in but no CRM profile → send to onboarding; it redeems on finish. + * - signed in + registered → accept now (creates the membership with the invited role). + * The token (a 128-hex secret emailed to the invitee) is the authorization. + */ +export default function InvitePage() { + const router = useRouter(); + const { status, getUserEmail } = useAuth(); + const { ready, sdk } = useAppShell(); + const [error, setError] = useState(""); + + useEffect(() => { + let token = ""; + try { token = new URLSearchParams(window.location.search).get("token") ?? ""; } catch { /* ignore */ } + if (!token) { setError("This invitation link is invalid or incomplete."); return; } + try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } + + // No Shell (local/mock) → just go register. + if (!isShellConfigured()) { router.replace("/portal/register"); return; } + if (!ready) return; + + let cancelled = false; + (async () => { + if (status === "unauthenticated") { + router.replace("/portal/register"); // sign up first; finish() redeems the token + return; + } + // Authenticated: registered users accept immediately; profile-less users onboard. + try { + const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus"); + if (!st?.registered) { + try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ } + router.replace("/portal/onboarding"); + return; + } + } catch { /* fall through to accept */ } + + try { + await sdk.command("crm.team.invitation.accept", { token }); + try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ } + router.replace("/dashboard"); + } catch { + if (!cancelled) setError("We couldn't accept this invitation — it may have expired or already been used."); + } + })(); + return () => { cancelled = true; }; + }, [ready, status, router, sdk, getUserEmail]); + + return ( +
+ +
+ +
+
+ +
+ {error ? ( + <> +

Invitation problem

+

{error}

+ + + ) : ( +
+ +

Accepting your invitation…

+

Setting up your team access.

+
+ )} +
+
+
+
+ +
+ ); +} diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 5f90f0d..c818e60 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -6,6 +6,7 @@ import { ChevronsUpDown, LogOut } from "lucide-react"; import { useAuth } from "@abe-kap/appshell-sdk/react"; import { Icon } from "./ui"; import { user } from "./account-data"; +import { useMyAccess } from "@/lib/access"; function initialsOf(name: string): string { return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; @@ -67,10 +68,45 @@ export const NAV_GROUPS: NavGroup[] = [ export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items); +// Nav visibility by CRM permission. Dashboard + Profile are always visible (even to a +// brand-new user with no membership); every other item requires membership, and the +// items mapped here additionally require the given permission. Unmapped items are +// shown to any member. This is UX only — be-crm still enforces every action. +const ALWAYS_VISIBLE = new Set(["dashboard", "profile"]); +const NAV_PERMISSION: Record = { + team: "team.manage", + people: "team.manage", + leads: "leads.manage", + verify: "leads.manage", + pipeline: "pipeline.manage", + estimates: "estimates.create", + procanvas: "estimates.create", + dispatch: "dispatch.manage", + schedule: "dispatch.manage", + storm: "dispatch.manage", + territory: "dispatch.manage", + leaderboard: "reports.view", + settings: "settings.manage", +}; + export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) { const router = useRouter(); const { user: me, logout, context } = useAuth(); + const access = useMyAccess(); const [menuOpen, setMenuOpen] = useState(false); + + // A new user with no membership sees only Dashboard + Profile. Members see the areas + // their permissions allow. While access is still loading, keep it minimal to avoid + // flashing items the user can't actually use. + const canSee = (key: string): boolean => { + if (ALWAYS_VISIBLE.has(key)) return true; + if (access.loading || !access.isMember) return false; + const perm = NAV_PERMISSION[key]; + return perm ? access.can(perm) : true; + }; + const visibleGroups = NAV_GROUPS + .map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.key)) })) + .filter((g) => g.items.length > 0); // Real signed-in identity from the App Context Envelope; fall back to the static // demo user only when the Shell isn't wired. const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : ""; @@ -92,7 +128,7 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st