feat(rbac): no role at registration; invitation-based membership; nav gating

- 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).
This commit is contained in:
tanweer919
2026-07-13 16:25:53 +05:30
parent 68bf137d11
commit 103f3ae3a1
6 changed files with 220 additions and 66 deletions
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
/**
* The signed-in user's effective CRM access, from crm.account.me. Drives navigation
* gating: a user with no membership/permissions sees only Dashboard + Profile; members
* see the areas their permissions allow.
*
* Every CRM permission id, granted to superadmins / owners. Used as the mock default
* when the Shell isn't configured (local demo shows everything).
*/
export const ALL_CRM_PERMISSIONS = [
"leads.manage", "pipeline.manage", "estimates.create", "dispatch.manage",
"reports.view", "billing.view", "team.manage", "roles.manage", "settings.manage",
] as const;
export interface MyAccess {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
/** True while the access query is still resolving (nav stays minimal until then). */
loading: boolean;
can: (permission: string) => boolean;
}
interface MeDTO {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
}
function useLiveAccess(): MyAccess {
const { data, loading } = useQuery<MeDTO>("crm.account.me");
const permissions = data?.permissions ?? [];
return {
registered: data?.registered ?? false,
isMember: data?.isMember ?? false,
roleSlugs: data?.roleSlugs ?? [],
permissions,
isSuperadmin: data?.isSuperadmin ?? false,
loading,
can: (p: string) => permissions.includes(p),
};
}
function useMockAccess(): MyAccess {
// No Shell (local demo): show everything so the mock UI is fully browsable.
const permissions = [...ALL_CRM_PERMISSIONS];
return {
registered: true,
isMember: true,
roleSlugs: ["superadmin"],
permissions,
isSuperadmin: true,
loading: false,
can: () => true,
};
}
export const useMyAccess: () => MyAccess = isShellConfigured() ? useLiveAccess : useMockAccess;
+4 -2
View File
@@ -38,6 +38,8 @@ export interface UiMember {
}
export interface UiInvite {
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
/** Accept token for the invite link (pending invites only; no email delivery yet). */
token?: string;
}
export interface TeamData {
@@ -70,7 +72,7 @@ const initialsOf = (name: string) =>
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 }
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string; token?: string }
const relTime = (iso: string): string => {
const then = Date.parse(iso);
@@ -169,7 +171,7 @@ function useLiveTeam(): TeamData {
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),
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), token: i.token,
})), [invitesQ.data]);
return {