diff --git a/docs/implementation/task-l2-user-management-integration.md b/docs/implementation/task-l2-user-management-integration.md new file mode 100644 index 0000000..df8fc8e --- /dev/null +++ b/docs/implementation/task-l2-user-management-integration.md @@ -0,0 +1,45 @@ +# Task L.2: User Management Integration + +## Summary + +Task L.2 moves user-management flows onto CRM role assignments so `memberships.businessRole` is no longer the primary CRM authorization source. + +## Implemented + +- Refactored user create/edit payloads to use: + - `memberships` + - `crmRoleAssignments` +- Updated user-management server utilities to: + - sync CRM role assignments during create/edit + - compute legacy `membership.businessRole` as a compatibility mirror from the primary assignment + - build user response models with CRM role summaries and resolved access summaries +- Updated user listing to show: + - primary CRM role + - CRM role count + - branch scope summary + - product scope summary +- Updated user form sheet to manage CRM role assignments directly: + - add role + - remove role + - set primary + - branch/product scope editing + - effective access preview inside the sheet +- Added user effective-access preview endpoint with audit logging +- Added CRM role-assignment reference endpoint for user-management forms +- Added migration utility: + - `scripts/migrate-membership-business-roles.ts` + +## Compatibility + +- `memberships.businessRole` still exists and is still populated +- when CRM assignments exist, resolved CRM authorization uses them as the primary source +- when no assignments exist, fallback remains functional and is surfaced in admin UI as legacy fallback + +## Verification + +- Run: `npx tsc --noEmit` + +## Notes + +- User management now acts as the primary operational screen for CRM role assignment, so admins do not need to leave the user workflow to adjust CRM authorization. +- The sheet-based effective access preview also serves as the administrator troubleshooting panel for mixed-role users. diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index 39f1b2b..d456c83 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -378,6 +378,18 @@ Future: - update user-management flows to assign CRM roles through assignment rows instead of membership payloads - remove the deprecated column only after all CRM modules and admin flows have migrated +## After Task L.2 + +### Legacy compatibility mirror on membership.businessRole + +Current: +User management now writes CRM role assignments directly, but `memberships.businessRole` is still mirrored from the primary CRM role for compatibility with remaining legacy seams. + +Future: + +- remove mirror writes once every consumer fully resolves CRM access from `crm_user_role_assignments` +- delete the column only after migration tooling, admin screens, and downstream integrations no longer depend on it + ### Global project-party filter coverage Task J adds a global `Project Party Role` filter in the dashboard UI, but the strongest enforcement today is in revenue analytics and export sections. diff --git a/package.json b/package.json index 1a0c55f..f1cf201 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "seed:super-admin": "node scripts/seed-super-admin.js", "seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", "seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", + "migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts", "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", "verify:task-h1": "node scripts/verify-task-h1.js", "verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js", diff --git a/plans/task-l.2.md b/plans/task-l.2.md new file mode 100644 index 0000000..6e89a23 --- /dev/null +++ b/plans/task-l.2.md @@ -0,0 +1,512 @@ +# Task L.2: User Management Integration & CRM Role Assignment Migration + +## Objective + +Complete the transition from legacy membership-based business roles to CRM role assignments. + +After Task L.2: + +```txt +membership.businessRole +``` + +becomes a legacy compatibility field only. + +The primary CRM authorization source becomes: + +```txt +crm_user_role_assignments +``` + +User creation, user editing, user visibility, and CRM authorization administration must all operate through CRM role assignments. + +--- + +# Background + +Task L introduced: + +```txt +crmRoleProfiles +resolved CRM access +role management +permission matrix +``` + +Task L.1 introduced: + +```txt +crm_user_role_assignments +multi-role users +scope union +permission union +``` + +Current gap: + +```txt +User Create +User Edit +Organization Membership UI +``` + +still centers around: + +```txt +membership.businessRole +``` + +instead of CRM role assignments. + +--- + +# Scope L2.1 User Form Refactor + +Update: + +```txt +src/features/users/components/user-form-sheet.tsx +``` + +Remove CRM dependency on: + +```txt +membership.businessRole +``` + +Replace with: + +```txt +CRM Role Assignments +``` + +New section: + +```txt +CRM Roles +``` + +Display: + +```txt +Role Profile +Branch Scope +Product Scope +Primary +Active +``` + +Support: + +```txt +Add Role +Edit Role +Remove Role +Set Primary +``` + +--- + +# Scope L2.2 User Detail Page + +Add CRM authorization panel. + +Display: + +```txt +Primary CRM Role + +Assigned CRM Roles + +Effective Permissions + +Effective Branch Scope + +Effective Product Scope + +Approval Authority +``` + +Show resolved access result from: + +```txt +resolveCrmAccess() +``` + +This becomes the troubleshooting screen for administrators. + +--- + +# Scope L2.3 User Create Flow + +When creating user: + +Do not require: + +```txt +businessRole +``` + +Instead: + +```txt +Organization Membership ++ +Optional CRM Role Assignments +``` + +Supported: + +```txt +Create User +Assign CRM Roles +Assign Branch Scope +Assign Product Scope +``` + +during creation. + +--- + +# Scope L2.4 User Edit Flow + +Allow: + +```txt +Add CRM Role +Remove CRM Role +Deactivate CRM Role +Change Scope +Change Primary Role +``` + +directly from user management. + +No navigation to CRM Settings required. + +--- + +# Scope L2.5 User Listing + +Enhance user table. + +Add columns: + +```txt +Primary CRM Role +CRM Role Count +Branch Scope Summary +Product Scope Summary +``` + +Examples: + +```txt +Sales Manager +3 Roles +Bangkok + Rayong +Crane + Solar +``` + +--- + +# Scope L2.6 Effective Access Preview + +Add: + +```txt +Preview Effective Access +``` + +Dialog or drawer. + +Display: + +```txt +Resolved Permissions +Resolved Ownership Scope +Resolved Branch Scope +Resolved Product Scope +Resolved Approval Authority +``` + +This is for administrator verification. + +--- + +# Scope L2.7 Membership Cleanup + +Keep: + +```txt +membership.businessRole +``` + +for compatibility. + +Behavior: + +```txt +If CRM assignments exist +→ ignore businessRole + +If CRM assignments do not exist +→ fallback businessRole +``` + +Add warning: + +```txt +Legacy Role +Deprecated +``` + +in admin screens. + +--- + +# Scope L2.8 Migration Utility + +Add: + +```txt +scripts/migrate-membership-business-roles.ts +``` + +Purpose: + +Convert: + +```txt +membership.businessRole +``` + +into: + +```txt +crm_user_role_assignments +``` + +Features: + +```txt +Dry Run +Execute +Report +Rollback Snapshot +``` + +Output: + +```txt +Migrated Users +Skipped Users +Unknown Roles +Duplicate Assignments +``` + +--- + +# Scope L2.9 Permission Updates + +Add permissions: + +```txt +crm.user_role_assignment.read +crm.user_role_assignment.create +crm.user_role_assignment.update +crm.user_role_assignment.delete +crm.user_role_assignment.manage +``` + +Default: + +```txt +system_admin +crm_admin +``` + +Optional: + +```txt +department_manager (read only) +top_manager (read only) +``` + +--- + +# Scope L2.10 Audit + +Entity: + +```txt +crm_user_role_assignment +``` + +Actions: + +```txt +create +update +delete +activate +deactivate +set_primary +migrate +``` + +Also audit: + +```txt +effective_access_preview +``` + +for troubleshooting history. + +--- + +# Scope L2.11 Verification + +Scenario A + +```txt +User: +Sales ++ +Sales Manager +``` + +Expected: + +```txt +Role Count = 2 +Primary = Sales Manager +Permissions = Union +``` + +--- + +Scenario B + +```txt +User: +Marketing ++ +Sales Support +``` + +Expected: + +```txt +Lead Access +Quotation Support Access +No Approval Access +``` + +--- + +Scenario C + +```txt +User: +CRM Admin ++ +Department Manager +``` + +Expected: + +```txt +Settings Access +Approval Access +Analytics Access +``` + +--- + +Scenario D + +Legacy User + +```txt +membership.businessRole = sales +``` + +No CRM assignment. + +Expected: + +```txt +Still works +Warning shown +Migration available +``` + +--- + +# Documentation + +Create: + +```txt +docs/implementation/task-l2-user-management-integration.md +``` + +Update: + +```txt +docs/implementation/technical-debt.md +``` + +Document: + +```txt +businessRole deprecation +migration strategy +effective access preview +CRM role assignment lifecycle +``` + +--- + +# Explicit Non-Scope + +Do NOT implement: + +```txt +Keycloak synchronization +Role request workflow +Approval delegation +Temporary permissions +Organization hierarchy +External identity mapping +``` + +--- + +# Deliverables + +1. User Form CRM Role Integration +2. User Detail CRM Access Panel +3. Effective Access Preview +4. CRM Role Assignment Management +5. Migration Utility +6. Membership Deprecation Path +7. Audit Coverage +8. Verification Matrix + +--- + +# Definition of Done + +Task L.2 is complete when: + +* user creation supports CRM role assignments +* user editing supports CRM role assignments +* effective access preview exists +* user list shows CRM role summary +* migration utility exists +* businessRole is no longer the primary CRM authorization source +* compatibility fallback remains functional +* audits are recorded +* verification scenarios pass diff --git a/scripts/migrate-membership-business-roles.ts b/scripts/migrate-membership-business-roles.ts new file mode 100644 index 0000000..7000c75 --- /dev/null +++ b/scripts/migrate-membership-business-roles.ts @@ -0,0 +1,172 @@ +import { writeFileSync } from 'node:fs'; +import { and, eq, isNull } from 'drizzle-orm'; +import { crmRoleProfiles, crmUserRoleAssignments, memberships } from '@/db/schema'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { db } from '@/lib/db'; + +type MigrationReport = { + mode: 'dry-run' | 'execute'; + startedAt: string; + completedAt?: string; + migratedUsers: number; + skippedUsers: number; + unknownRoles: Array<{ membershipId: string; organizationId: string; userId: string; businessRole: string }>; + duplicateAssignments: Array<{ membershipId: string; assignmentId: string; organizationId: string; userId: string }>; + actions: Array>; +}; + +function getArg(name: string) { + const match = process.argv.find((arg) => arg === name || arg.startsWith(`${name}=`)); + + if (!match) { + return null; + } + + if (match === name) { + return 'true'; + } + + return match.slice(name.length + 1); +} + +async function main() { + const execute = getArg('--execute') === 'true'; + const reportPath = getArg('--report'); + const snapshotPath = getArg('--snapshot'); + const report: MigrationReport = { + mode: execute ? 'execute' : 'dry-run', + startedAt: new Date().toISOString(), + migratedUsers: 0, + skippedUsers: 0, + unknownRoles: [], + duplicateAssignments: [], + actions: [] + }; + + const membershipRows = await db.query.memberships.findMany(); + + if (snapshotPath) { + writeFileSync(snapshotPath, JSON.stringify(membershipRows, null, 2)); + } + + for (const membership of membershipRows) { + const [roleProfile] = await db + .select({ + id: crmRoleProfiles.id + }) + .from(crmRoleProfiles) + .where( + and( + eq(crmRoleProfiles.organizationId, membership.organizationId), + eq(crmRoleProfiles.code, membership.businessRole), + isNull(crmRoleProfiles.deletedAt) + ) + ) + .limit(1); + + if (!roleProfile) { + report.skippedUsers += 1; + report.unknownRoles.push({ + membershipId: membership.id, + organizationId: membership.organizationId, + userId: membership.userId, + businessRole: membership.businessRole + }); + continue; + } + + const existing = await db.query.crmUserRoleAssignments.findFirst({ + where: and( + eq(crmUserRoleAssignments.organizationId, membership.organizationId), + eq(crmUserRoleAssignments.userId, membership.userId), + eq(crmUserRoleAssignments.roleProfileId, roleProfile.id) + ) + }); + + if (existing && !existing.deletedAt) { + report.skippedUsers += 1; + report.duplicateAssignments.push({ + membershipId: membership.id, + assignmentId: existing.id, + organizationId: membership.organizationId, + userId: membership.userId + }); + continue; + } + + const actionPayload = { + membershipId: membership.id, + organizationId: membership.organizationId, + userId: membership.userId, + roleProfileId: roleProfile.id, + businessRole: membership.businessRole, + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [] + }; + + report.actions.push(actionPayload); + report.migratedUsers += 1; + + if (!execute) { + continue; + } + + const assignmentId = existing?.id ?? crypto.randomUUID(); + + if (existing) { + await db + .update(crmUserRoleAssignments) + .set({ + branchScopeMode: (membership.branchScopeIds?.length ?? 0) > 0 ? 'selected' : 'all', + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeMode: + (membership.productTypeScopeIds?.length ?? 0) > 0 ? 'selected' : 'all', + productTypeScopeIds: membership.productTypeScopeIds ?? [], + isPrimary: true, + isActive: true, + assignedBy: 'system:migration', + assignedAt: new Date(), + updatedAt: new Date(), + deletedAt: null + }) + .where(eq(crmUserRoleAssignments.id, existing.id)); + } else { + await db.insert(crmUserRoleAssignments).values({ + id: assignmentId, + organizationId: membership.organizationId, + userId: membership.userId, + roleProfileId: roleProfile.id, + branchScopeMode: (membership.branchScopeIds?.length ?? 0) > 0 ? 'selected' : 'all', + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeMode: + (membership.productTypeScopeIds?.length ?? 0) > 0 ? 'selected' : 'all', + productTypeScopeIds: membership.productTypeScopeIds ?? [], + isPrimary: true, + isActive: true, + assignedBy: 'system:migration' + }); + } + + await auditAction({ + organizationId: membership.organizationId, + userId: 'system:migration', + entityType: 'crm_user_role_assignment', + entityId: assignmentId, + action: 'migrate', + afterData: actionPayload + }); + } + + report.completedAt = new Date().toISOString(); + + if (reportPath) { + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + } + + console.info(JSON.stringify(report, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/app/api/crm/settings/user-role-assignments/[id]/route.ts b/src/app/api/crm/settings/user-role-assignments/[id]/route.ts index 944f66e..4c924f9 100644 --- a/src/app/api/crm/settings/user-role-assignments/[id]/route.ts +++ b/src/app/api/crm/settings/user-role-assignments/[id]/route.ts @@ -23,7 +23,7 @@ type RouteProps = { export async function PATCH(request: NextRequest, props: RouteProps) { try { const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmRoleAssignmentManage + permission: PERMISSIONS.crmUserRoleAssignmentManage }); const payload = updateAssignmentSchema.parse(await request.json()); const { id } = await props.params; @@ -65,7 +65,7 @@ export async function PATCH(request: NextRequest, props: RouteProps) { export async function DELETE(_request: NextRequest, props: RouteProps) { try { const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmRoleAssignmentManage + permission: PERMISSIONS.crmUserRoleAssignmentManage }); const { id } = await props.params; await deleteCrmUserRoleAssignment(organization.id, session.user.id, id); diff --git a/src/app/api/crm/settings/user-role-assignments/route.ts b/src/app/api/crm/settings/user-role-assignments/route.ts index 0f4fb64..d395afe 100644 --- a/src/app/api/crm/settings/user-role-assignments/route.ts +++ b/src/app/api/crm/settings/user-role-assignments/route.ts @@ -21,7 +21,7 @@ const assignmentSchema = z.object({ export async function GET() { try { const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmRoleAssignmentRead + permission: PERMISSIONS.crmUserRoleAssignmentRead }); const data = await listResolvedCrmRoleAssignments(organization.id, session.user.id); @@ -46,7 +46,7 @@ export async function GET() { export async function POST(request: NextRequest) { try { const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmRoleAssignmentManage + permission: PERMISSIONS.crmUserRoleAssignmentManage }); const payload = assignmentSchema.parse(await request.json()); const assignment = await createCrmUserRoleAssignment( diff --git a/src/app/api/users/[id]/effective-access-preview/route.ts b/src/app/api/users/[id]/effective-access-preview/route.ts new file mode 100644 index 0000000..796b0ee --- /dev/null +++ b/src/app/api/users/[id]/effective-access-preview/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { AuthError } from '@/lib/auth/session'; +import { + assertManageableTargetUser, + buildUserResponseModels, + requireUserManagementAccess +} from '@/app/api/users/_lib'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess(); + await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin); + const [user] = await buildUserResponseModels({ + scopedUserIds: [id], + ...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds }) + }); + + if (!user) { + return NextResponse.json({ message: 'User not found' }, { status: 404 }); + } + + await auditAction({ + organizationId: session.user.activeOrganizationId ?? user.organizations[0]?.id, + userId: session.user.id, + entityType: 'effective_access_preview', + entityId: id, + action: 'preview', + afterData: { + userId: user.id, + crmAccessSummaries: user.crmAccessSummaries + } + }); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Effective access preview loaded successfully', + user + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to preview effective access' }, { status: 500 }); + } +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 15a3170..3971ec5 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -1,14 +1,16 @@ import { hash } from 'bcryptjs'; import { eq } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; -import { memberships, users } from '@/db/schema'; +import { crmUserRoleAssignments, memberships, users } from '@/db/schema'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; import { assertManageableTargetUser, buildMembershipValues, + buildUserResponseModels, parseUserMutationPayload, requireUserManagementAccess, + syncCrmRoleAssignmentsForUser, validateManagedOrganizations } from '../_lib'; @@ -21,7 +23,12 @@ export async function PUT(request: NextRequest, { params }: Params) { const payload = parseUserMutationPayload(await request.json()); const targetUser = await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin); - await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin); + await validateManagedOrganizations( + payload.memberships, + payload.crmRoleAssignments, + manageableOrganizationIds, + isSuperAdmin + ); if (!isSuperAdmin && payload.systemRole !== 'user') { return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); @@ -66,7 +73,18 @@ export async function PUT(request: NextRequest, { params }: Params) { await db.update(users).set(updateValues).where(eq(users.id, id)); await db.delete(memberships).where(eq(memberships.userId, id)); - await db.insert(memberships).values(buildMembershipValues(id, payload.memberships)); + const membershipValues = await buildMembershipValues( + id, + payload.memberships, + payload.crmRoleAssignments + ); + await db.insert(memberships).values(membershipValues); + await syncCrmRoleAssignmentsForUser( + id, + session.user.id, + payload.memberships, + payload.crmRoleAssignments + ); if ( session.user.id === id && @@ -97,6 +115,35 @@ export async function PUT(request: NextRequest, { params }: Params) { } } +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess(); + await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin); + const [user] = await buildUserResponseModels({ + scopedUserIds: [id], + ...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds }) + }); + + if (!user) { + return NextResponse.json({ message: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'User loaded successfully', + user + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load user' }, { status: 500 }); + } +} + export async function DELETE(_request: NextRequest, { params }: Params) { try { const { id } = await params; @@ -108,6 +155,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin); + await db.delete(crmUserRoleAssignments).where(eq(crmUserRoleAssignments.userId, id)); await db.delete(memberships).where(eq(memberships.userId, id)); await db.delete(users).where(eq(users.id, id)); diff --git a/src/app/api/users/_lib.ts b/src/app/api/users/_lib.ts index 5873949..d11dea9 100644 --- a/src/app/api/users/_lib.ts +++ b/src/app/api/users/_lib.ts @@ -1,20 +1,47 @@ -import { eq, inArray } from 'drizzle-orm'; -import { memberships, organizations, users } from '@/db/schema'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { + crmRoleProfiles, + crmUserRoleAssignments, + memberships, + organizations, + users +} from '@/db/schema'; +import { + createCrmUserRoleAssignment, + deleteCrmUserRoleAssignment, + updateCrmUserRoleAssignment +} from '@/features/foundation/crm-role-assignments/server/service'; import { - type BusinessRole, getDefaultBusinessRole, getDefaultPermissions, - isBusinessRole, isMembershipRole, + isRoleAssignmentScopeMode, isSystemRole } from '@/lib/auth/rbac'; +import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access'; import { AuthError, requireSession } from '@/lib/auth/session'; import { db } from '@/lib/db'; +import type { + User, + UserCrmRoleAssignment, + UserCrmAccessSummary +} from '@/features/users/api/types'; export type UserMembershipInput = { organizationId: string; membershipRole: 'admin' | 'user'; - businessRole: BusinessRole; +}; + +export type UserCrmRoleAssignmentInput = { + id?: string; + organizationId: string; + roleProfileId: string; + branchScopeMode: 'all' | 'selected' | 'none' | 'inherit'; + branchScopeIds: string[]; + productTypeScopeMode: 'all' | 'selected' | 'none' | 'inherit'; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; }; export type UserMutationInput = { @@ -23,6 +50,7 @@ export type UserMutationInput = { password?: string; systemRole: 'super_admin' | 'user'; memberships: UserMembershipInput[]; + crmRoleAssignments: UserCrmRoleAssignmentInput[]; }; export async function requireUserManagementAccess() { @@ -64,6 +92,9 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput { const payload = body as Record; const membershipsPayload = Array.isArray(payload.memberships) ? payload.memberships : []; + const crmRoleAssignmentsPayload = Array.isArray(payload.crmRoleAssignments) + ? payload.crmRoleAssignments + : []; const name = typeof payload.name === 'string' ? payload.name.trim() : ''; const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : ''; const password = typeof payload.password === 'string' ? payload.password.trim() : undefined; @@ -83,25 +114,65 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput { const organizationId = typeof value.organizationId === 'string' ? value.organizationId.trim() : ''; const membershipRole = typeof value.membershipRole === 'string' ? value.membershipRole : ''; - const businessRole = - typeof value.businessRole === 'string' - ? value.businessRole - : getDefaultBusinessRole( - membershipRole === 'admin' || membershipRole === 'user' ? membershipRole : 'user' - ); - if (!organizationId || !isMembershipRole(membershipRole) || !isBusinessRole(businessRole)) { + if (!organizationId || !isMembershipRole(membershipRole)) { return null; } return { organizationId, - membershipRole, - businessRole + membershipRole }; }) .filter((membership): membership is UserMembershipInput => membership !== null); + const crmRoleAssignments = crmRoleAssignmentsPayload + .map((assignment) => { + if (!assignment || typeof assignment !== 'object') { + return null; + } + + const value = assignment as Record; + const organizationId = + typeof value.organizationId === 'string' ? value.organizationId.trim() : ''; + const roleProfileId = + typeof value.roleProfileId === 'string' ? value.roleProfileId.trim() : ''; + const id = typeof value.id === 'string' ? value.id.trim() : undefined; + const branchScopeMode = typeof value.branchScopeMode === 'string' ? value.branchScopeMode : ''; + const productTypeScopeMode = + typeof value.productTypeScopeMode === 'string' ? value.productTypeScopeMode : ''; + const branchScopeIds = Array.isArray(value.branchScopeIds) + ? value.branchScopeIds.filter((item): item is string => typeof item === 'string') + : []; + const productTypeScopeIds = Array.isArray(value.productTypeScopeIds) + ? value.productTypeScopeIds.filter((item): item is string => typeof item === 'string') + : []; + const isPrimary = value.isPrimary === true; + const isActive = value.isActive !== false; + + if ( + !organizationId || + !roleProfileId || + !isRoleAssignmentScopeMode(branchScopeMode) || + !isRoleAssignmentScopeMode(productTypeScopeMode) + ) { + return null; + } + + return { + ...(id ? { id } : {}), + organizationId, + roleProfileId, + branchScopeMode, + branchScopeIds, + productTypeScopeMode, + productTypeScopeIds, + isPrimary, + isActive + } satisfies UserCrmRoleAssignmentInput; + }) + .filter((assignment): assignment is UserCrmRoleAssignmentInput => assignment !== null); + const uniqueMemberships = [ ...new Map(memberships.map((membership) => [membership.organizationId, membership])).values() ]; @@ -115,16 +186,23 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput { email, password, systemRole: systemRoleValue, - memberships: uniqueMemberships + memberships: uniqueMemberships, + crmRoleAssignments }; } export async function validateManagedOrganizations( targetMemberships: UserMembershipInput[], + crmRoleAssignments: UserCrmRoleAssignmentInput[], manageableOrganizationIds: string[], isSuperAdmin: boolean ) { - const organizationIds = targetMemberships.map((membership) => membership.organizationId); + const organizationIds = [ + ...new Set([ + ...targetMemberships.map((membership) => membership.organizationId), + ...crmRoleAssignments.map((assignment) => assignment.organizationId) + ]) + ]; const organizationRows = await db .select({ @@ -143,6 +221,16 @@ export async function validateManagedOrganizations( ) { throw new AuthError('Forbidden', 403); } + + const membershipOrganizationIds = new Set( + targetMemberships.map((membership) => membership.organizationId) + ); + + if ( + crmRoleAssignments.some((assignment) => !membershipOrganizationIds.has(assignment.organizationId)) + ) { + throw new Error('CRM role assignments must belong to a selected organization membership'); + } } export async function getUserMembershipRows(userId: string) { @@ -184,13 +272,379 @@ export async function assertManageableTargetUser( return user; } -export function buildMembershipValues(userId: string, targetMemberships: UserMembershipInput[]) { +function getLegacyBusinessRoleForMembership(input: { + membership: UserMembershipInput; + crmRoleAssignments: UserCrmRoleAssignmentInput[]; + roleProfilesById: Map; +}) { + const orgAssignments = input.crmRoleAssignments.filter( + (assignment) => assignment.organizationId === input.membership.organizationId && assignment.isActive + ); + const primaryAssignment = + orgAssignments.find((assignment) => assignment.isPrimary) ?? orgAssignments[0] ?? null; + + if (primaryAssignment) { + return ( + input.roleProfilesById.get(primaryAssignment.roleProfileId)?.code ?? + getDefaultBusinessRole(input.membership.membershipRole) + ); + } + + return getDefaultBusinessRole(input.membership.membershipRole); +} + +export async function buildMembershipValues( + userId: string, + targetMemberships: UserMembershipInput[], + crmRoleAssignments: UserCrmRoleAssignmentInput[] +) { + const roleProfileIds = [...new Set(crmRoleAssignments.map((assignment) => assignment.roleProfileId))]; + const roleProfiles = roleProfileIds.length + ? await db + .select({ + id: crmRoleProfiles.id, + code: crmRoleProfiles.code + }) + .from(crmRoleProfiles) + .where(and(inArray(crmRoleProfiles.id, roleProfileIds), isNull(crmRoleProfiles.deletedAt))) + : []; + const roleProfilesById = new Map(roleProfiles.map((profile) => [profile.id, profile])); + return targetMemberships.map((membership) => ({ id: crypto.randomUUID(), userId, organizationId: membership.organizationId, role: membership.membershipRole, - businessRole: membership.businessRole, - permissions: getDefaultPermissions(membership.membershipRole, membership.businessRole) + businessRole: getLegacyBusinessRoleForMembership({ + membership, + crmRoleAssignments, + roleProfilesById + }), + permissions: getDefaultPermissions( + membership.membershipRole, + getLegacyBusinessRoleForMembership({ + membership, + crmRoleAssignments, + roleProfilesById + }) + ) })); } + +export async function syncCrmRoleAssignmentsForUser( + userId: string, + actorUserId: string, + membershipsForUser: UserMembershipInput[], + crmRoleAssignments: UserCrmRoleAssignmentInput[] +) { + const membershipOrganizationIds = membershipsForUser.map((membership) => membership.organizationId); + const existingAssignments = await db + .select() + .from(crmUserRoleAssignments) + .where(eq(crmUserRoleAssignments.userId, userId)); + const assignmentsForManagedMemberships = existingAssignments.filter((assignment) => + membershipOrganizationIds.includes(assignment.organizationId) + ); + const payloadIds = new Set(crmRoleAssignments.map((assignment) => assignment.id).filter(Boolean)); + + for (const assignment of assignmentsForManagedMemberships) { + if (payloadIds.has(assignment.id)) { + continue; + } + + const stillReferenced = crmRoleAssignments.some( + (item) => + item.organizationId === assignment.organizationId && + item.roleProfileId === assignment.roleProfileId && + !item.id + ); + + if (!stillReferenced) { + await deleteCrmUserRoleAssignment(assignment.organizationId, actorUserId, assignment.id); + } + } + + for (const assignment of crmRoleAssignments) { + if (assignment.id) { + await updateCrmUserRoleAssignment(assignment.organizationId, actorUserId, assignment.id, { + branchScopeMode: assignment.branchScopeMode, + branchScopeIds: assignment.branchScopeIds, + productTypeScopeMode: assignment.productTypeScopeMode, + productTypeScopeIds: assignment.productTypeScopeIds, + isPrimary: assignment.isPrimary, + isActive: assignment.isActive + }); + continue; + } + + await createCrmUserRoleAssignment(assignment.organizationId, actorUserId, { + userId, + roleProfileId: assignment.roleProfileId, + branchScopeMode: assignment.branchScopeMode, + branchScopeIds: assignment.branchScopeIds, + productTypeScopeMode: assignment.productTypeScopeMode, + productTypeScopeIds: assignment.productTypeScopeIds, + isPrimary: assignment.isPrimary, + isActive: assignment.isActive + }); + } +} + +function summarizeScope(mode: UserCrmAccessSummary['branchScopeMode'], ids: string[]) { + if (mode === 'all') { + return 'All'; + } + + if (mode === 'none' || ids.length === 0) { + return 'None'; + } + + return ids.length <= 2 ? ids.join(' + ') : `${ids.slice(0, 2).join(' + ')} +${ids.length - 2}`; +} + +export async function buildUserResponseModels(params: { + scopedOrganizationIds?: string[]; + scopedUserIds?: string[]; +}) { + const membershipRows = await db + .select({ + membershipId: memberships.id, + userId: memberships.userId, + organizationId: memberships.organizationId, + role: memberships.role, + businessRole: memberships.businessRole, + permissions: memberships.permissions, + branchScopeIds: memberships.branchScopeIds, + productTypeScopeIds: memberships.productTypeScopeIds, + createdAt: memberships.createdAt, + updatedAt: memberships.updatedAt, + organizationName: organizations.name + }) + .from(memberships) + .innerJoin(organizations, eq(organizations.id, memberships.organizationId)) + .where( + and( + ...(params.scopedOrganizationIds?.length + ? [inArray(memberships.organizationId, params.scopedOrganizationIds)] + : []), + ...(params.scopedUserIds?.length ? [inArray(memberships.userId, params.scopedUserIds)] : []) + ) + ); + + if (!membershipRows.length) { + return []; + } + + const userIds = [...new Set(membershipRows.map((row) => row.userId))]; + const organizationIds = [...new Set(membershipRows.map((row) => row.organizationId))]; + const [userRows, assignmentRows, roleProfileRows] = await Promise.all([ + db + .select({ + id: users.id, + name: users.name, + email: users.email, + systemRole: users.systemRole, + activeOrganizationId: users.activeOrganizationId + }) + .from(users) + .where(inArray(users.id, userIds)), + db + .select() + .from(crmUserRoleAssignments) + .where( + and( + inArray(crmUserRoleAssignments.userId, userIds), + inArray(crmUserRoleAssignments.organizationId, organizationIds), + isNull(crmUserRoleAssignments.deletedAt) + ) + ), + db + .select({ + id: crmRoleProfiles.id, + code: crmRoleProfiles.code, + name: crmRoleProfiles.name + }) + .from(crmRoleProfiles) + .where( + and( + inArray(crmRoleProfiles.organizationId, organizationIds), + isNull(crmRoleProfiles.deletedAt) + ) + ) + ]); + const userMap = new Map(userRows.map((row) => [row.id, row])); + const roleProfileMap = new Map(roleProfileRows.map((row) => [row.id, row])); + const assignmentsByUserOrg = new Map(); + + for (const assignment of assignmentRows) { + const roleProfile = roleProfileMap.get(assignment.roleProfileId); + if (!roleProfile) { + continue; + } + + const key = `${assignment.userId}:${assignment.organizationId}`; + const current = assignmentsByUserOrg.get(key) ?? []; + current.push({ + id: assignment.id, + organizationId: assignment.organizationId, + roleProfileId: assignment.roleProfileId, + roleProfileCode: roleProfile.code, + roleProfileName: roleProfile.name, + branchScopeMode: isRoleAssignmentScopeMode(assignment.branchScopeMode) + ? assignment.branchScopeMode + : 'inherit', + branchScopeIds: assignment.branchScopeIds ?? [], + productTypeScopeMode: isRoleAssignmentScopeMode(assignment.productTypeScopeMode) + ? assignment.productTypeScopeMode + : 'inherit', + productTypeScopeIds: assignment.productTypeScopeIds ?? [], + isPrimary: assignment.isPrimary, + isActive: assignment.isActive + }); + assignmentsByUserOrg.set(key, current); + } + + const usersMap = new Map(); + + for (const membershipRow of membershipRows) { + const dbUser = userMap.get(membershipRow.userId); + + if (!dbUser) { + continue; + } + + const resolvedAccess = await resolveCrmMembershipAccess({ + systemRole: dbUser.systemRole === 'super_admin' ? 'super_admin' : 'user', + organizationId: membershipRow.organizationId, + membership: { + id: membershipRow.membershipId, + userId: membershipRow.userId, + organizationId: membershipRow.organizationId, + role: membershipRow.role, + businessRole: membershipRow.businessRole, + permissions: membershipRow.permissions, + branchScopeIds: membershipRow.branchScopeIds, + productTypeScopeIds: membershipRow.productTypeScopeIds, + createdAt: membershipRow.createdAt, + updatedAt: membershipRow.updatedAt + } + }); + const userKey = membershipRow.userId; + const existing = usersMap.get(userKey); + const assignments = + assignmentsByUserOrg.get(`${membershipRow.userId}:${membershipRow.organizationId}`) ?? []; + const accessSummary: UserCrmAccessSummary = { + organizationId: membershipRow.organizationId, + organizationName: membershipRow.organizationName, + primaryCrmRole: resolvedAccess.businessRole, + primaryCrmRoleLabel: + assignments.find((assignment) => assignment.isPrimary)?.roleProfileName ?? + assignments[0]?.roleProfileName ?? + resolvedAccess.roleProfileName, + roleCount: assignments.filter((assignment) => assignment.isActive).length, + permissions: resolvedAccess.permissions, + ownershipScope: resolvedAccess.ownershipScope, + branchScopeMode: resolvedAccess.branchScopeMode, + branchScopeIds: resolvedAccess.branchScopeIds, + productScopeMode: resolvedAccess.productScopeMode, + productTypeScopeIds: resolvedAccess.productTypeScopeIds, + approvalAuthority: resolvedAccess.approvalAuthority, + usedLegacyFallback: assignments.length === 0 + }; + + if (existing) { + existing.crmRoleAssignments ??= []; + existing.crmAccessSummaries ??= []; + existing.organizations.push({ + id: membershipRow.organizationId, + name: membershipRow.organizationName, + role: membershipRow.role === 'admin' ? 'admin' : 'user' + }); + existing.memberships.push({ + organizationId: membershipRow.organizationId, + organizationName: membershipRow.organizationName, + membershipRole: membershipRow.role === 'admin' ? 'admin' : 'user', + businessRole: membershipRow.businessRole, + isLegacyBusinessRoleDeprecated: assignments.length > 0 + }); + existing.crmRoleAssignments.push(...assignments); + existing.crmAccessSummaries.push(accessSummary); + continue; + } + + usersMap.set(userKey, { + id: dbUser.id, + name: dbUser.name, + email: dbUser.email, + systemRole: dbUser.systemRole === 'super_admin' ? 'super_admin' : 'user', + activeMembershipRole: + dbUser.activeOrganizationId === membershipRow.organizationId && membershipRow.role === 'admin' + ? 'admin' + : dbUser.activeOrganizationId === membershipRow.organizationId + ? 'user' + : null, + activeOrganizationId: dbUser.activeOrganizationId, + organizations: [ + { + id: membershipRow.organizationId, + name: membershipRow.organizationName, + role: membershipRow.role === 'admin' ? 'admin' : 'user' + } + ], + memberships: [ + { + organizationId: membershipRow.organizationId, + organizationName: membershipRow.organizationName, + membershipRole: membershipRow.role === 'admin' ? 'admin' : 'user', + businessRole: membershipRow.businessRole, + isLegacyBusinessRoleDeprecated: assignments.length > 0 + } + ], + crmRoleAssignments: [...assignments], + crmAccessSummaries: [accessSummary], + primaryCrmRole: null, + crmRoleCount: 0, + branchScopeSummary: 'None', + productScopeSummary: 'None', + hasLegacyBusinessRoleFallback: assignments.length === 0 + }); + } + + return [...usersMap.values()].map((user) => { + const crmAccessSummaries = user.crmAccessSummaries ?? []; + const crmRoleAssignments = user.crmRoleAssignments ?? []; + const primaryAccess = + crmAccessSummaries.find( + (summary) => summary.organizationId === user.activeOrganizationId + ) ?? crmAccessSummaries[0]; + + return { + ...user, + organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)), + memberships: user.memberships.toSorted((a, b) => + a.organizationName.localeCompare(b.organizationName) + ), + crmRoleAssignments: crmRoleAssignments.toSorted((a, b) => { + if (a.isPrimary !== b.isPrimary) { + return a.isPrimary ? -1 : 1; + } + + return a.roleProfileName.localeCompare(b.roleProfileName); + }), + crmAccessSummaries: crmAccessSummaries.toSorted((a, b) => + a.organizationName.localeCompare(b.organizationName) + ), + primaryCrmRole: primaryAccess?.primaryCrmRoleLabel ?? primaryAccess?.primaryCrmRole ?? null, + crmRoleCount: crmRoleAssignments.filter((assignment) => assignment.isActive).length, + branchScopeSummary: primaryAccess + ? summarizeScope(primaryAccess.branchScopeMode, primaryAccess.branchScopeIds) + : 'None', + productScopeSummary: primaryAccess + ? summarizeScope(primaryAccess.productScopeMode, primaryAccess.productTypeScopeIds) + : 'None', + hasLegacyBusinessRoleFallback: crmAccessSummaries.some( + (summary) => summary.usedLegacyFallback + ) + } satisfies User; + }); +} diff --git a/src/app/api/users/crm-role-assignment-reference/route.ts b/src/app/api/users/crm-role-assignment-reference/route.ts new file mode 100644 index 0000000..9cbf319 --- /dev/null +++ b/src/app/api/users/crm-role-assignment-reference/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, asc, eq, inArray, isNull } from 'drizzle-orm'; +import { crmRoleProfiles, memberships, msOptions } from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { requireUserManagementAccess } from '../_lib'; + +export async function GET(request: NextRequest) { + try { + const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess(); + const requestedOrganizationIds = request.nextUrl.searchParams + .get('organizationIds') + ?.split(',') + .map((value) => value.trim()) + .filter(Boolean) ?? []; + const organizationIds = requestedOrganizationIds.length + ? requestedOrganizationIds + : manageableOrganizationIds; + + if ( + !isSuperAdmin && + organizationIds.some((organizationId) => !manageableOrganizationIds.includes(organizationId)) + ) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const [roleProfiles, branches, productTypes] = await Promise.all([ + db + .select({ + organizationId: crmRoleProfiles.organizationId, + id: crmRoleProfiles.id, + code: crmRoleProfiles.code, + name: crmRoleProfiles.name + }) + .from(crmRoleProfiles) + .where( + and( + inArray(crmRoleProfiles.organizationId, organizationIds), + eq(crmRoleProfiles.isActive, true), + isNull(crmRoleProfiles.deletedAt) + ) + ) + .orderBy(asc(crmRoleProfiles.name)), + db + .select({ + organizationId: msOptions.organizationId, + id: msOptions.id, + code: msOptions.code, + label: msOptions.label + }) + .from(msOptions) + .where( + and( + inArray(msOptions.organizationId, organizationIds), + eq(msOptions.category, 'crm_branch'), + eq(msOptions.isActive, true), + isNull(msOptions.deletedAt) + ) + ) + .orderBy(asc(msOptions.label)), + db + .select({ + organizationId: msOptions.organizationId, + id: msOptions.id, + code: msOptions.code, + label: msOptions.label + }) + .from(msOptions) + .where( + and( + inArray(msOptions.organizationId, organizationIds), + eq(msOptions.category, 'crm_product_type'), + eq(msOptions.isActive, true), + isNull(msOptions.deletedAt) + ) + ) + .orderBy(asc(msOptions.label)) + ]); + + const membershipsByOrganization = await db + .select({ + organizationId: memberships.organizationId, + userId: memberships.userId + }) + .from(memberships) + .where(inArray(memberships.organizationId, organizationIds)); + + return NextResponse.json({ + success: true, + roleProfiles, + branches, + productTypes, + organizationUserIds: membershipsByOrganization + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to load CRM role assignment reference data' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index c722330..76338d5 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -3,100 +3,17 @@ import { asc, eq } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; import { memberships, organizations, users } from '@/db/schema'; import { db } from '@/lib/db'; -import { isBusinessRole } from '@/lib/auth/rbac'; import { AuthError } from '@/lib/auth/session'; import { buildMembershipValues, + buildUserResponseModels, parseUserMutationPayload, requireUserManagementAccess, + syncCrmRoleAssignmentsForUser, validateManagedOrganizations } from './_lib'; -type UserRow = { - userId: string; - name: string; - email: string; - systemRole: string; - activeOrganizationId: string | null; - organizationId: string; - organizationName: string; - membershipRole: string; - businessRole: string; -}; - -function formatUsers(rows: UserRow[]) { - const usersMap = new Map< - string, - { - id: string; - name: string; - email: string; - systemRole: 'super_admin' | 'user'; - activeMembershipRole: 'admin' | 'user' | null; - activeOrganizationId: string | null; - organizations: Array<{ id: string; name: string; role: 'admin' | 'user' }>; - memberships: Array<{ - organizationId: string; - organizationName: string; - membershipRole: 'admin' | 'user'; - businessRole: string; - }>; - } - >(); - - for (const row of rows) { - const existing = usersMap.get(row.userId); - const organization = { - id: row.organizationId, - name: row.organizationName, - role: row.membershipRole === 'admin' ? 'admin' : 'user' - } as const; - const membership = { - organizationId: row.organizationId, - organizationName: row.organizationName, - membershipRole: organization.role, - businessRole: isBusinessRole(row.businessRole) ? row.businessRole : 'sales_support' - } as const; - - if (existing) { - if (!existing.organizations.some((item) => item.id === organization.id)) { - existing.organizations.push(organization); - } - - if (!existing.memberships.some((item) => item.organizationId === membership.organizationId)) { - existing.memberships.push(membership); - } - - if (row.organizationId === existing.activeOrganizationId) { - existing.activeMembershipRole = organization.role; - } - - continue; - } - - usersMap.set(row.userId, { - id: row.userId, - name: row.name, - email: row.email, - systemRole: row.systemRole === 'super_admin' ? 'super_admin' : 'user', - activeMembershipRole: - row.organizationId === row.activeOrganizationId ? organization.role : null, - activeOrganizationId: row.activeOrganizationId, - organizations: [organization], - memberships: [membership] - }); - } - - return [...usersMap.values()].map((user) => ({ - ...user, - organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)), - memberships: user.memberships.toSorted((a, b) => - a.organizationName.localeCompare(b.organizationName) - ) - })); -} - -function applyRoleFilter(usersList: ReturnType, roles?: string) { +function applyRoleFilter(usersList: Awaited>, roles?: string) { if (!roles) { return usersList; } @@ -113,7 +30,7 @@ function applyRoleFilter(usersList: ReturnType, roles?: stri ); } -function sortUsers(usersList: ReturnType, sort?: string) { +function sortUsers(usersList: Awaited>, sort?: string) { const defaultSort = usersList.toSorted((a, b) => a.name.localeCompare(b.name)); if (!sort) { @@ -132,12 +49,28 @@ function sortUsers(usersList: ReturnType, sort?: string) { const left = primarySort.id === 'role' ? `${a.systemRole}:${a.activeMembershipRole ?? ''}` + : primarySort.id === 'primaryCrmRole' + ? a.primaryCrmRole ?? '' + : primarySort.id === 'crmRoleCount' + ? a.crmRoleCount + : primarySort.id === 'branchScopeSummary' + ? a.branchScopeSummary + : primarySort.id === 'productScopeSummary' + ? a.productScopeSummary : primarySort.id === 'organizations' ? a.memberships.map((membership) => membership.organizationName).join(', ') : (a[primarySort.id as 'name' | 'email'] ?? a.name); const right = primarySort.id === 'role' ? `${b.systemRole}:${b.activeMembershipRole ?? ''}` + : primarySort.id === 'primaryCrmRole' + ? b.primaryCrmRole ?? '' + : primarySort.id === 'crmRoleCount' + ? b.crmRoleCount + : primarySort.id === 'branchScopeSummary' + ? b.branchScopeSummary + : primarySort.id === 'productScopeSummary' + ? b.productScopeSummary : primarySort.id === 'organizations' ? b.memberships.map((membership) => membership.organizationName).join(', ') : (b[primarySort.id as 'name' | 'email'] ?? b.name); @@ -161,36 +94,18 @@ export async function GET(request: NextRequest) { const search = searchParams.get('search') ?? undefined; const sort = searchParams.get('sort') ?? undefined; - const rows = await db - .select({ - userId: users.id, - name: users.name, - email: users.email, - systemRole: users.systemRole, - activeOrganizationId: users.activeOrganizationId, - organizationId: memberships.organizationId, - organizationName: organizations.name, - membershipRole: memberships.role, - businessRole: memberships.businessRole - }) - .from(users) - .innerJoin(memberships, eq(memberships.userId, users.id)) - .innerJoin(organizations, eq(organizations.id, memberships.organizationId)) - .orderBy(asc(users.name)); - - const scopedRows = isSuperAdmin - ? rows - : rows.filter((row) => manageableOrganizationIds.includes(row.organizationId)); + const usersList = await buildUserResponseModels({ + ...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds }) + }); const searchTerm = search?.trim().toLowerCase(); - const searchedRows = searchTerm - ? scopedRows.filter( + const searchedUsers = searchTerm + ? usersList.filter( (row) => row.name.toLowerCase().includes(searchTerm) || row.email.toLowerCase().includes(searchTerm) ) - : scopedRows; - const formattedUsers = formatUsers(searchedRows); - const filteredUsers = applyRoleFilter(formattedUsers, roles); + : usersList; + const filteredUsers = applyRoleFilter(searchedUsers, roles); const sortedUsers = sortUsers(filteredUsers, sort); const offset = (page - 1) * limit; const pagedUsers = sortedUsers.slice(offset, offset + limit); @@ -215,7 +130,7 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { - const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess(); + const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess(); const payload = parseUserMutationPayload(await request.json()); if (!payload.password || payload.password.length < 8) { @@ -234,6 +149,7 @@ export async function POST(request: NextRequest) { await validateManagedOrganizations( payload.memberships, + payload.crmRoleAssignments, manageableOrganizationIds, isSuperAdmin ); @@ -261,7 +177,19 @@ export async function POST(request: NextRequest) { activeOrganizationId: payload.memberships[0]?.organizationId ?? null }); - await db.insert(memberships).values(buildMembershipValues(userId, payload.memberships)); + const membershipValues = await buildMembershipValues( + userId, + payload.memberships, + payload.crmRoleAssignments + ); + + await db.insert(memberships).values(membershipValues); + await syncCrmRoleAssignmentsForUser( + userId, + session.user.id, + payload.memberships, + payload.crmRoleAssignments + ); return NextResponse.json( { diff --git a/src/app/dashboard/crm/settings/user-role-assignments/page.tsx b/src/app/dashboard/crm/settings/user-role-assignments/page.tsx index 3f52ffd..b8f1b6b 100644 --- a/src/app/dashboard/crm/settings/user-role-assignments/page.tsx +++ b/src/app/dashboard/crm/settings/user-role-assignments/page.tsx @@ -11,7 +11,7 @@ export default async function CrmUserRoleAssignmentsRoute() { const canRead = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && - session.user.activePermissions.includes(PERMISSIONS.crmRoleAssignmentRead)); + session.user.activePermissions.includes(PERMISSIONS.crmUserRoleAssignmentRead)); const queryClient = getQueryClient(); if (canRead) { diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index c23be86..c7e6782 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -136,7 +136,7 @@ export const navGroups: NavGroup[] = [ url: "/dashboard/crm/settings/master-options", icon: "settings", isActive: false, - access: { requireOrg: true, permission: "crm.role.assignment.read" }, + access: { requireOrg: true, permission: "crm.user_role_assignment.read" }, items: [ { title: "Roles & Permissions", @@ -148,7 +148,7 @@ export const navGroups: NavGroup[] = [ url: "/dashboard/crm/settings/user-role-assignments", access: { requireOrg: true, - permission: "crm.role.assignment.read", + permission: "crm.user_role_assignment.read", }, }, { diff --git a/src/features/users/api/types.ts b/src/features/users/api/types.ts index e8e3ba3..8c918b9 100644 --- a/src/features/users/api/types.ts +++ b/src/features/users/api/types.ts @@ -1,4 +1,10 @@ -import type { BusinessRole } from '@/lib/auth/rbac'; +import type { + CrmApprovalAuthority, + CrmEffectiveScopeMode, + CrmOwnershipScope, + CrmRoleAssignmentScopeMode, + Permission +} from '@/lib/auth/rbac'; export interface UserOrganization { id: string; @@ -10,7 +16,38 @@ export interface UserMembership { organizationId: string; organizationName: string; membershipRole: 'admin' | 'user'; - businessRole: BusinessRole; + businessRole: string; + isLegacyBusinessRoleDeprecated?: boolean; +} + +export interface UserCrmRoleAssignment { + id: string; + organizationId: string; + roleProfileId: string; + roleProfileCode: string; + roleProfileName: string; + branchScopeMode: CrmRoleAssignmentScopeMode; + branchScopeIds: string[]; + productTypeScopeMode: CrmRoleAssignmentScopeMode; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; +} + +export interface UserCrmAccessSummary { + organizationId: string; + organizationName: string; + primaryCrmRole: string | null; + primaryCrmRoleLabel: string | null; + roleCount: number; + permissions: Permission[]; + ownershipScope: CrmOwnershipScope; + branchScopeMode: CrmEffectiveScopeMode; + branchScopeIds: string[]; + productScopeMode: CrmEffectiveScopeMode; + productTypeScopeIds: string[]; + approvalAuthority: CrmApprovalAuthority; + usedLegacyFallback: boolean; } export interface User { @@ -22,6 +59,13 @@ export interface User { activeOrganizationId: string | null; organizations: UserOrganization[]; memberships: UserMembership[]; + crmRoleAssignments?: UserCrmRoleAssignment[]; + crmAccessSummaries?: UserCrmAccessSummary[]; + primaryCrmRole?: string | null; + crmRoleCount?: number; + branchScopeSummary?: string; + productScopeSummary?: string; + hasLegacyBusinessRoleFallback?: boolean; } export type UserFilters = { @@ -50,6 +94,16 @@ export type UserMutationPayload = { memberships: Array<{ organizationId: string; membershipRole: 'admin' | 'user'; - businessRole: BusinessRole; + }>; + crmRoleAssignments: Array<{ + id?: string; + organizationId: string; + roleProfileId: string; + branchScopeMode: CrmRoleAssignmentScopeMode; + branchScopeIds: string[]; + productTypeScopeMode: CrmRoleAssignmentScopeMode; + productTypeScopeIds: string[]; + isPrimary: boolean; + isActive: boolean; }>; }; diff --git a/src/features/users/components/user-form-sheet.tsx b/src/features/users/components/user-form-sheet.tsx index 234c2ee..8f39348 100644 --- a/src/features/users/components/user-form-sheet.tsx +++ b/src/features/users/components/user-form-sheet.tsx @@ -25,7 +25,10 @@ import { createUserMutation, updateUserMutation } from '../api/mutations'; import type { User } from '../api/types'; import { toast } from 'sonner'; import { type UserFormValues, userSchema } from '../schemas/user'; -import type { BusinessRole } from '@/lib/auth/rbac'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; interface UserFormSheetProps { user?: User; @@ -37,29 +40,50 @@ type OrganizationOption = { id: string; name: string; role: string; - businessRole?: string; canManageUsers: boolean; }; +type AssignmentReferenceResponse = { + success?: boolean; + roleProfiles?: Array<{ organizationId: string; id: string; code: string; name: string }>; + branches?: Array<{ organizationId: string; id: string; code: string; label: string }>; + productTypes?: Array<{ organizationId: string; id: string; code: string; label: string }>; +}; + const membershipRoleOptions = [ { value: 'admin', label: 'Organization Admin' }, { value: 'user', label: 'User' } ] as const; -const businessRoleOptions = [ - { value: 'marketing', label: 'Marketing' }, - { value: 'sales_manager', label: 'Sales Manager' }, - { value: 'sales', label: 'Sales' }, - { value: 'sales_support', label: 'Sales Support' }, - { value: 'department_manager', label: 'Department Manager' }, - { value: 'top_manager', label: 'Top Manager' }, - { value: 'crm_admin', label: 'CRM Admin' } +const scopeModeOptions = [ + { value: 'inherit', label: 'Inherit' }, + { value: 'all', label: 'All' }, + { value: 'selected', label: 'Selected' }, + { value: 'none', label: 'None' } ] as const; +function scopeSummary(mode: string, ids: string[]) { + if (mode === 'all') return 'All'; + if (mode === 'inherit') return 'Inherit'; + if (mode === 'none' || ids.length === 0) return 'None'; + return ids.join(', '); +} + export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) { const isEdit = !!user; const [organizations, setOrganizations] = useState([]); + const [selectedOrganizationIds, setSelectedOrganizationIds] = useState( + user?.memberships.map((membership) => membership.organizationId) ?? [] + ); + const [assignmentReference, setAssignmentReference] = useState({ + roleProfiles: [], + branches: [], + productTypes: [] + }); const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false); + const [isLoadingAssignments, setIsLoadingAssignments] = useState(false); + const [previewUser, setPreviewUser] = useState(null); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); const createMutation = useMutation({ ...createUserMutation, @@ -67,6 +91,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) toast.success('User created successfully'); onOpenChange(false); form.reset(); + setPreviewUser(null); }, onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create user') @@ -86,19 +111,35 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) () => user?.memberships.map((membership) => ({ organizationId: membership.organizationId, - membershipRole: membership.membershipRole, - businessRole: membership.businessRole + membershipRole: membership.membershipRole })) ?? [], [user?.memberships] ); + const initialAssignments = useMemo( + () => + (user?.crmRoleAssignments ?? []).map((assignment) => ({ + id: assignment.id, + organizationId: assignment.organizationId, + roleProfileId: assignment.roleProfileId, + branchScopeMode: assignment.branchScopeMode, + branchScopeIds: assignment.branchScopeIds, + productTypeScopeMode: assignment.productTypeScopeMode, + productTypeScopeIds: assignment.productTypeScopeIds, + isPrimary: assignment.isPrimary, + isActive: assignment.isActive + })), + [user?.crmRoleAssignments] + ); + const form = useAppForm({ defaultValues: { name: user?.name ?? '', email: user?.email ?? '', password: '', systemRole: user?.systemRole ?? 'user', - memberships: initialMemberships + memberships: initialMemberships, + crmRoleAssignments: initialAssignments } as UserFormValues, validators: { onSubmit: userSchema @@ -122,6 +163,32 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) const { FormTextField } = useFormFields(); + useEffect(() => { + if (!open) { + setPreviewUser(null); + return; + } + + setSelectedOrganizationIds(initialMemberships.map((membership) => membership.organizationId)); + setPreviewUser(null); + form.reset({ + name: user?.name ?? '', + email: user?.email ?? '', + password: '', + systemRole: user?.systemRole ?? 'user', + memberships: initialMemberships, + crmRoleAssignments: initialAssignments + }); + }, [ + form, + initialAssignments, + initialMemberships, + open, + user?.email, + user?.name, + user?.systemRole + ]); + useEffect(() => { if (!open) { return; @@ -164,187 +231,652 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) }; }, [open]); + useEffect(() => { + if (!open || selectedOrganizationIds.length === 0) { + setAssignmentReference({ roleProfiles: [], branches: [], productTypes: [] }); + return; + } + + let cancelled = false; + + async function loadAssignmentReference() { + setIsLoadingAssignments(true); + + try { + const response = await fetch( + `/api/users/crm-role-assignment-reference?organizationIds=${selectedOrganizationIds.join(',')}` + ); + const data = (await response.json()) as AssignmentReferenceResponse & { message?: string }; + + if (!response.ok) { + throw new Error(data.message ?? 'Unable to load CRM role assignment reference data'); + } + + if (!cancelled) { + setAssignmentReference(data); + } + } catch (error) { + if (!cancelled) { + toast.error( + error instanceof Error + ? error.message + : 'Unable to load CRM role assignment reference data' + ); + } + } finally { + if (!cancelled) { + setIsLoadingAssignments(false); + } + } + } + + void loadAssignmentReference(); + + return () => { + cancelled = true; + }; + }, [open, selectedOrganizationIds]); + + const roleProfilesByOrganization = useMemo(() => { + const map = new Map>(); + + for (const roleProfile of assignmentReference.roleProfiles ?? []) { + const current = map.get(roleProfile.organizationId) ?? []; + current.push({ id: roleProfile.id, code: roleProfile.code, name: roleProfile.name }); + map.set(roleProfile.organizationId, current); + } + + return map; + }, [assignmentReference.roleProfiles]); + + const branchesByOrganization = useMemo(() => { + const map = new Map>(); + + for (const branch of assignmentReference.branches ?? []) { + const current = map.get(branch.organizationId) ?? []; + current.push({ id: branch.id, code: branch.code, label: branch.label }); + map.set(branch.organizationId, current); + } + + return map; + }, [assignmentReference.branches]); + + const productTypesByOrganization = useMemo(() => { + const map = new Map>(); + + for (const productType of assignmentReference.productTypes ?? []) { + const current = map.get(productType.organizationId) ?? []; + current.push({ id: productType.id, code: productType.code, label: productType.label }); + map.set(productType.organizationId, current); + } + + return map; + }, [assignmentReference.productTypes]); + + async function handlePreviewEffectiveAccess() { + if (!user) { + return; + } + + setIsLoadingPreview(true); + + try { + const response = await fetch(`/api/users/${user.id}/effective-access-preview`); + const data = (await response.json()) as { user?: User; message?: string }; + + if (!response.ok || !data.user) { + throw new Error(data.message ?? 'Unable to preview effective access'); + } + + setPreviewUser(data.user); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Unable to preview effective access'); + } finally { + setIsLoadingPreview(false); + } + } + + const effectivePreviewSource = previewUser ?? user ?? null; const isPending = createMutation.isPending || updateMutation.isPending; return ( - + {isEdit ? 'Edit User' : 'New User'} {isEdit - ? 'Update the user details and workspace memberships below.' - : 'Create a user account and assign workspace-specific roles.'} + ? 'Update membership, CRM role assignments, and effective access behavior.' + : 'Create a user with organization memberships and optional CRM role assignments.'} -
+
- - + + + + Account + + +
+ + +
- - - - - {isEdit && user?.systemRole === 'super_admin' && ( -
- System Role: Super Admin -
- )} - - {!isEdit && ( -
- New users are created with system role user. Creating new - super_admin users is intentionally not exposed in this form. -
- )} +
+ +
+ System Role:{' '} + {isEdit && user?.systemRole === 'super_admin' ? 'Super Admin' : 'User'} +
+
+
+
( - - -
Workspace Memberships *
-
- One user can have different roles in different workspaces. -
-
- {isLoadingOrganizations ? ( -
Loading workspaces...
- ) : organizations.length ? ( - organizations.map((organization) => { - const membership = field.state.value.find( - (value) => value.organizationId === organization.id - ); - const checked = !!membership; + + + Workspace Memberships + + Membership grants organization access. CRM authorization now comes from role + assignments instead of `membership.businessRole`. + + + + {isLoadingOrganizations ? ( +
Loading workspaces...
+ ) : organizations.length ? ( + organizations.map((organization) => { + const membership = field.state.value.find( + (value) => value.organizationId === organization.id + ); + const checked = !!membership; - return ( -
-
- { - if (!value) { - field.handleChange( - field.state.value.filter( - (item) => item.organizationId !== organization.id - ) - ); - field.handleBlur(); - return; - } - - field.handleChange([ - ...field.state.value, - { - organizationId: organization.id, - membershipRole: 'user', - businessRole: 'sales_support' - } - ]); + return ( +
+
+ { + if (!value) { + const nextMemberships = field.state.value.filter( + (item) => item.organizationId !== organization.id + ); + field.handleChange(nextMemberships); field.handleBlur(); - }} - /> -
- {organization.name} - - Your role here: {organization.role} - -
+ setSelectedOrganizationIds( + nextMemberships.map((item) => item.organizationId) + ); + form.setFieldValue( + 'crmRoleAssignments', + form.state.values.crmRoleAssignments.filter( + (item) => item.organizationId !== organization.id + ) + ); + return; + } + + const nextMemberships = [ + ...field.state.value, + { + organizationId: organization.id, + membershipRole: 'user' as const + } + ]; + field.handleChange(nextMemberships); + field.handleBlur(); + setSelectedOrganizationIds( + nextMemberships.map((item) => item.organizationId) + ); + }} + /> +
+ {organization.name} + + Your role here: {organization.role} +
- - - -
- ); - }) - ) : ( -
- No manageable workspaces available. -
- )} -
- - - + + +
+ ); + }) + ) : ( +
+ No manageable workspaces available. +
+ )} + + )} /> + + ( + + + CRM Roles + + Assign one or many CRM role profiles with branch and product-type scope per + organization. + + + +
+ +
+ + {isLoadingAssignments ? ( +
Loading CRM role options...
+ ) : field.state.value.length ? ( + field.state.value.map((assignment, index) => { + const roleProfiles = + roleProfilesByOrganization.get(assignment.organizationId) ?? []; + const branches = + branchesByOrganization.get(assignment.organizationId) ?? []; + const productTypes = + productTypesByOrganization.get(assignment.organizationId) ?? []; + + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + + {assignment.branchScopeMode === 'selected' ? ( +
+ {branches.map((branch) => ( + + ))} +
+ ) : null} +
+ +
+ + + {assignment.productTypeScopeMode === 'selected' ? ( +
+ {productTypes.map((productType) => ( + + ))} +
+ ) : null} +
+
+
+ ); + }) + ) : ( +
+ No CRM role assignments yet. Users can still fall back to the legacy + `membership.businessRole` path until assignments are added. +
+ )} +
+
+ )} + /> + + + +
+ Effective Access + + Preview resolved CRM permissions, scopes, and approval authority for troubleshooting. + +
+ {isEdit ? ( + + ) : null} +
+ + {effectivePreviewSource?.crmAccessSummaries?.length ? ( + effectivePreviewSource.crmAccessSummaries.map((summary) => ( +
+
+
+
{summary.organizationName}
+
+ Primary CRM Role: {summary.primaryCrmRoleLabel ?? summary.primaryCrmRole ?? 'None'} +
+
+
+ {summary.roleCount} Roles + {summary.usedLegacyFallback ? ( + Legacy Fallback + ) : null} +
+
+
+
+
Ownership
+
{summary.ownershipScope}
+
+
+
Approval
+
{summary.approvalAuthority}
+
+
+
Branch Scope
+
+ {scopeSummary(summary.branchScopeMode, summary.branchScopeIds)} +
+
+
+
Product Scope
+
+ {scopeSummary(summary.productScopeMode, summary.productTypeScopeIds)} +
+
+
+
+
Resolved Permissions
+
+ {summary.permissions.map((permission) => ( + + {permission} + + ))} +
+
+
+ )) + ) : ( +
+ {isEdit + ? 'Use Preview Effective Access to inspect the resolved CRM authorization.' + : 'Effective access preview becomes available after the user is created.'} +
+ )} +
+
@@ -357,9 +889,10 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) type='submit' form='user-form-sheet' isLoading={isPending} - disabled={isLoadingOrganizations || organizations.length === 0} + disabled={isLoadingOrganizations} > - {isEdit ? 'Update User' : 'Create User'} + + {isEdit ? 'Update User' : 'Create User'} diff --git a/src/features/users/components/users-table/columns.tsx b/src/features/users/components/users-table/columns.tsx index e202c14..1c4a72a 100644 --- a/src/features/users/components/users-table/columns.tsx +++ b/src/features/users/components/users-table/columns.tsx @@ -67,6 +67,45 @@ export const columns: ColumnDef[] = [
) }, + { + id: 'primaryCrmRole', + accessorFn: (row) => row.primaryCrmRole ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.primaryCrmRole ?? 'None'} + {row.original.hasLegacyBusinessRoleFallback ? ( + Legacy fallback active + ) : null} +
+ ) + }, + { + id: 'crmRoleCount', + accessorFn: (row) => row.crmRoleCount, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.crmRoleCount} Roles + }, + { + id: 'branchScopeSummary', + accessorFn: (row) => row.branchScopeSummary, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.branchScopeSummary} + }, + { + id: 'productScopeSummary', + accessorFn: (row) => row.productScopeSummary, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.productScopeSummary} + }, { id: 'actions', cell: ({ row }) => diff --git a/src/features/users/schemas/user.ts b/src/features/users/schemas/user.ts index a595772..9234a15 100644 --- a/src/features/users/schemas/user.ts +++ b/src/features/users/schemas/user.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { ROLE_ASSIGNMENT_SCOPE_MODES } from '@/lib/auth/rbac'; export const userSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Please enter a valid email'), @@ -8,11 +9,23 @@ export const userSchema = z.object({ .array( z.object({ organizationId: z.string().min(1), - membershipRole: z.enum(['admin', 'user']), - businessRole: z.string().min(1) + membershipRole: z.enum(['admin', 'user']) }) ) - .min(1, 'Select at least one organization') + .min(1, 'Select at least one organization'), + crmRoleAssignments: z.array( + z.object({ + id: z.string().optional(), + organizationId: z.string().min(1), + roleProfileId: z.string().min(1), + branchScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES), + branchScopeIds: z.array(z.string()), + productTypeScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES), + productTypeScopeIds: z.array(z.string()), + isPrimary: z.boolean(), + isActive: z.boolean() + }) + ) }); export type UserFormValues = z.infer; diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 6371a46..e8d19a1 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -99,6 +99,11 @@ export const PERMISSIONS = { crmRoleAssignmentUpdate: 'crm.role.assignment.update', crmRoleAssignmentDelete: 'crm.role.assignment.delete', crmRoleAssignmentManage: 'crm.role.assignment.manage', + crmUserRoleAssignmentRead: 'crm.user_role_assignment.read', + crmUserRoleAssignmentCreate: 'crm.user_role_assignment.create', + crmUserRoleAssignmentUpdate: 'crm.user_role_assignment.update', + crmUserRoleAssignmentDelete: 'crm.user_role_assignment.delete', + crmUserRoleAssignmentManage: 'crm.user_role_assignment.manage', crmMasterOptionRead: 'crm.master_option.read', crmMasterOptionCreate: 'crm.master_option.create', crmMasterOptionUpdate: 'crm.master_option.update', @@ -258,6 +263,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record