This commit is contained in:
phaichayon
2026-06-22 11:39:41 +07:00
parent 771ebfc308
commit 827fd13fa7
19 changed files with 2309 additions and 322 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

512
plans/task-l.2.md Normal file
View File

@@ -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

View File

@@ -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<Record<string, unknown>>;
};
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);
});

View File

@@ -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);

View File

@@ -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(

View File

@@ -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 });
}
}

View File

@@ -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));

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, { code: string }>;
}) {
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<string, UserCrmRoleAssignment[]>();
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<string, User>();
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;
});
}

View File

@@ -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 }
);
}
}

View File

@@ -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<typeof formatUsers>, roles?: string) {
function applyRoleFilter(usersList: Awaited<ReturnType<typeof buildUserResponseModels>>, roles?: string) {
if (!roles) {
return usersList;
}
@@ -113,7 +30,7 @@ function applyRoleFilter(usersList: ReturnType<typeof formatUsers>, roles?: stri
);
}
function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
function sortUsers(usersList: Awaited<ReturnType<typeof buildUserResponseModels>>, sort?: string) {
const defaultSort = usersList.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
@@ -132,12 +49,28 @@ function sortUsers(usersList: ReturnType<typeof formatUsers>, 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(
{

View File

@@ -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) {

View File

@@ -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",
},
},
{

View File

@@ -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;
}>;
};

View File

@@ -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<OrganizationOption[]>([]);
const [selectedOrganizationIds, setSelectedOrganizationIds] = useState<string[]>(
user?.memberships.map((membership) => membership.organizationId) ?? []
);
const [assignmentReference, setAssignmentReference] = useState<AssignmentReferenceResponse>({
roleProfiles: [],
branches: [],
productTypes: []
});
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [isLoadingAssignments, setIsLoadingAssignments] = useState(false);
const [previewUser, setPreviewUser] = useState<User | null>(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<UserFormValues>();
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,25 +231,136 @@ 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<string, Array<{ id: string; code: string; name: string }>>();
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<string, Array<{ id: string; code: string; label: string }>>();
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<string, Array<{ id: string; code: string; label: string }>>();
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-2xl'>
<SheetContent className='flex flex-col sm:max-w-5xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
<SheetDescription>
{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.'}
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<div className='flex-1 overflow-auto pr-1'>
<form.AppForm>
<form.Form id='user-form-sheet' className='space-y-4'>
<form.Form id='user-form-sheet' className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FormTextField name='name' label='Full Name' required placeholder='Jane Doe' />
<FormTextField
name='email'
label='Email'
@@ -190,7 +368,9 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
type='email'
placeholder='jane@example.com'
/>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FormTextField
name='password'
label={isEdit ? 'Password (optional)' : 'Password'}
@@ -200,30 +380,26 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'
}
/>
{isEdit && user?.systemRole === 'super_admin' && (
<div className='rounded-lg border border-dashed p-3 text-sm'>
<span className='font-medium'>System Role:</span> Super Admin
<span className='font-medium'>System Role:</span>{' '}
{isEdit && user?.systemRole === 'super_admin' ? 'Super Admin' : 'User'}
</div>
)}
{!isEdit && (
<div className='text-muted-foreground rounded-lg border border-dashed p-3 text-sm'>
New users are created with system role <strong>user</strong>. Creating new
<strong> super_admin</strong> users is intentionally not exposed in this form.
</div>
)}
</CardContent>
</Card>
<form.AppField
name='memberships'
children={(field) => (
<field.FieldSet>
<field.Field>
<div className='text-sm font-medium'>Workspace Memberships *</div>
<div className='text-muted-foreground text-xs'>
One user can have different roles in different workspaces.
</div>
<div className='space-y-2 rounded-lg border p-3'>
<Card>
<CardHeader>
<CardTitle>Workspace Memberships</CardTitle>
<CardDescription>
Membership grants organization access. CRM authorization now comes from role
assignments instead of `membership.businessRole`.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{isLoadingOrganizations ? (
<div className='text-muted-foreground text-sm'>Loading workspaces...</div>
) : organizations.length ? (
@@ -236,32 +412,42 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
return (
<div
key={organization.id}
className='grid gap-3 rounded-md border p-3 md:grid-cols-[1fr_180px_180px]'
className='grid gap-3 rounded-md border p-3 md:grid-cols-[1fr_220px]'
>
<div className='flex items-start gap-3'>
<Checkbox
aria-label={`Select workspace ${organization.name}`}
checked={checked}
onCheckedChange={(value) => {
if (!value) {
field.handleChange(
field.state.value.filter(
const nextMemberships = field.state.value.filter(
(item) => item.organizationId !== organization.id
);
field.handleChange(nextMemberships);
field.handleBlur();
setSelectedOrganizationIds(
nextMemberships.map((item) => item.organizationId)
);
form.setFieldValue(
'crmRoleAssignments',
form.state.values.crmRoleAssignments.filter(
(item) => item.organizationId !== organization.id
)
);
field.handleBlur();
return;
}
field.handleChange([
const nextMemberships = [
...field.state.value,
{
organizationId: organization.id,
membershipRole: 'user',
businessRole: 'sales_support'
membershipRole: 'user' as const
}
]);
];
field.handleChange(nextMemberships);
field.handleBlur();
setSelectedOrganizationIds(
nextMemberships.map((item) => item.organizationId)
);
}}
/>
<div className='flex flex-col'>
@@ -289,7 +475,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
field.handleBlur();
}}
>
<SelectTrigger aria-label={`Role for ${organization.name}`}>
<SelectTrigger>
<SelectValue placeholder='Select role' />
</SelectTrigger>
<SelectContent>
@@ -300,37 +486,6 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
))}
</SelectContent>
</Select>
<Select
value={membership?.businessRole ?? 'sales_support'}
disabled={!checked}
onValueChange={(value) => {
field.handleChange(
field.state.value.map((item) =>
item.organizationId === organization.id
? {
...item,
businessRole: value as BusinessRole
}
: item
)
);
field.handleBlur();
}}
>
<SelectTrigger
aria-label={`Business role for ${organization.name}`}
>
<SelectValue placeholder='Select business role' />
</SelectTrigger>
<SelectContent>
{businessRoleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})
@@ -339,12 +494,389 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
No manageable workspaces available.
</div>
)}
</div>
</field.Field>
<field.FieldError />
</field.FieldSet>
</CardContent>
</Card>
)}
/>
<form.AppField
name='crmRoleAssignments'
children={(field) => (
<Card>
<CardHeader>
<CardTitle>CRM Roles</CardTitle>
<CardDescription>
Assign one or many CRM role profiles with branch and product-type scope per
organization.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex justify-end'>
<Button
type='button'
variant='outline'
disabled={selectedOrganizationIds.length === 0}
onClick={() => {
const organizationId = selectedOrganizationIds[0];
const firstRoleProfile =
roleProfilesByOrganization.get(organizationId)?.[0]?.id ?? '';
field.handleChange([
...field.state.value,
{
organizationId,
roleProfileId: firstRoleProfile,
branchScopeMode: 'inherit',
branchScopeIds: [],
productTypeScopeMode: 'inherit',
productTypeScopeIds: [],
isPrimary: field.state.value.length === 0,
isActive: true
}
]);
field.handleBlur();
}}
>
<Icons.add className='mr-2 h-4 w-4' />
Add CRM Role
</Button>
</div>
{isLoadingAssignments ? (
<div className='text-muted-foreground text-sm'>Loading CRM role options...</div>
) : 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 (
<div key={assignment.id ?? `${assignment.organizationId}-${index}`} className='space-y-4 rounded-lg border p-4'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<div className='space-y-2'>
<Label>Organization</Label>
<Select
value={assignment.organizationId}
onValueChange={(value) => {
const next = [...field.state.value];
next[index] = {
...assignment,
organizationId: value,
roleProfileId: roleProfilesByOrganization.get(value)?.[0]?.id ?? '',
branchScopeIds: [],
productTypeScopeIds: []
};
field.handleChange(next);
field.handleBlur();
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectedOrganizationIds.map((organizationId) => {
const organization = organizations.find(
(item) => item.id === organizationId
);
return organization ? (
<SelectItem key={organization.id} value={organization.id}>
{organization.name}
</SelectItem>
) : null;
})}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Role Profile</Label>
<Select
value={assignment.roleProfileId}
onValueChange={(value) => {
const next = [...field.state.value];
next[index] = { ...assignment, roleProfileId: value };
field.handleChange(next);
field.handleBlur();
}}
>
<SelectTrigger>
<SelectValue placeholder='Select role profile' />
</SelectTrigger>
<SelectContent>
{roleProfiles.map((roleProfile) => (
<SelectItem key={roleProfile.id} value={roleProfile.id}>
{roleProfile.name} ({roleProfile.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='flex items-end gap-6'>
<label className='flex items-center gap-3'>
<Checkbox
checked={assignment.isPrimary}
onCheckedChange={(value) => {
const next = field.state.value.map((item, itemIndex) => ({
...item,
isPrimary:
itemIndex === index ? value === true : false
}));
field.handleChange(next);
field.handleBlur();
}}
/>
<span className='text-sm font-medium'>Primary</span>
</label>
<label className='flex items-center gap-3'>
<Checkbox
checked={assignment.isActive}
onCheckedChange={(value) => {
const next = [...field.state.value];
next[index] = { ...assignment, isActive: value === true };
field.handleChange(next);
field.handleBlur();
}}
/>
<span className='text-sm font-medium'>Active</span>
</label>
</div>
<div className='flex items-end justify-end'>
<Button
type='button'
variant='destructive'
onClick={() => {
field.handleChange(
field.state.value.filter((_, itemIndex) => itemIndex !== index)
);
field.handleBlur();
}}
>
<Icons.trash className='mr-2 h-4 w-4' />
Remove
</Button>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<div className='space-y-3'>
<Label>Branch Scope</Label>
<Select
value={assignment.branchScopeMode}
onValueChange={(value) => {
const next = [...field.state.value];
next[index] = {
...assignment,
branchScopeMode: value as typeof assignment.branchScopeMode,
branchScopeIds:
value === 'selected' ? assignment.branchScopeIds : []
};
field.handleChange(next);
field.handleBlur();
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopeModeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{assignment.branchScopeMode === 'selected' ? (
<div className='grid gap-2 rounded-md border p-3 md:grid-cols-2'>
{branches.map((branch) => (
<label key={branch.id} className='flex items-start gap-3 rounded-md border p-3'>
<Checkbox
checked={assignment.branchScopeIds.includes(branch.id)}
onCheckedChange={(value) => {
const nextIds =
value === true
? [...new Set([...assignment.branchScopeIds, branch.id])]
: assignment.branchScopeIds.filter((item) => item !== branch.id);
const next = [...field.state.value];
next[index] = { ...assignment, branchScopeIds: nextIds };
field.handleChange(next);
field.handleBlur();
}}
/>
<div>
<div className='font-medium'>{branch.label}</div>
<div className='text-muted-foreground text-xs'>{branch.code}</div>
</div>
</label>
))}
</div>
) : null}
</div>
<div className='space-y-3'>
<Label>Product Scope</Label>
<Select
value={assignment.productTypeScopeMode}
onValueChange={(value) => {
const next = [...field.state.value];
next[index] = {
...assignment,
productTypeScopeMode:
value as typeof assignment.productTypeScopeMode,
productTypeScopeIds:
value === 'selected' ? assignment.productTypeScopeIds : []
};
field.handleChange(next);
field.handleBlur();
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopeModeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{assignment.productTypeScopeMode === 'selected' ? (
<div className='grid gap-2 rounded-md border p-3 md:grid-cols-2'>
{productTypes.map((productType) => (
<label key={productType.id} className='flex items-start gap-3 rounded-md border p-3'>
<Checkbox
checked={assignment.productTypeScopeIds.includes(productType.id)}
onCheckedChange={(value) => {
const nextIds =
value === true
? [
...new Set([
...assignment.productTypeScopeIds,
productType.id
])
]
: assignment.productTypeScopeIds.filter(
(item) => item !== productType.id
);
const next = [...field.state.value];
next[index] = {
...assignment,
productTypeScopeIds: nextIds
};
field.handleChange(next);
field.handleBlur();
}}
/>
<div>
<div className='font-medium'>{productType.label}</div>
<div className='text-muted-foreground text-xs'>
{productType.code}
</div>
</div>
</label>
))}
</div>
) : null}
</div>
</div>
</div>
);
})
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-sm'>
No CRM role assignments yet. Users can still fall back to the legacy
`membership.businessRole` path until assignments are added.
</div>
)}
</CardContent>
</Card>
)}
/>
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle>Effective Access</CardTitle>
<CardDescription>
Preview resolved CRM permissions, scopes, and approval authority for troubleshooting.
</CardDescription>
</div>
{isEdit ? (
<Button
type='button'
variant='outline'
onClick={() => void handlePreviewEffectiveAccess()}
isLoading={isLoadingPreview}
>
<Icons.sparkles className='mr-2 h-4 w-4' />
Preview Effective Access
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-4'>
{effectivePreviewSource?.crmAccessSummaries?.length ? (
effectivePreviewSource.crmAccessSummaries.map((summary) => (
<div key={summary.organizationId} className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<div className='font-medium'>{summary.organizationName}</div>
<div className='text-muted-foreground text-sm'>
Primary CRM Role: {summary.primaryCrmRoleLabel ?? summary.primaryCrmRole ?? 'None'}
</div>
</div>
<div className='flex gap-2'>
<Badge variant='secondary'>{summary.roleCount} Roles</Badge>
{summary.usedLegacyFallback ? (
<Badge variant='outline'>Legacy Fallback</Badge>
) : null}
</div>
</div>
<div className='mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
<div className='rounded-md border p-3'>
<div className='text-muted-foreground text-xs'>Ownership</div>
<div className='font-medium capitalize'>{summary.ownershipScope}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-muted-foreground text-xs'>Approval</div>
<div className='font-medium capitalize'>{summary.approvalAuthority}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-muted-foreground text-xs'>Branch Scope</div>
<div className='font-medium'>
{scopeSummary(summary.branchScopeMode, summary.branchScopeIds)}
</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-muted-foreground text-xs'>Product Scope</div>
<div className='font-medium'>
{scopeSummary(summary.productScopeMode, summary.productTypeScopeIds)}
</div>
</div>
</div>
<div className='mt-4'>
<div className='mb-2 text-sm font-medium'>Resolved Permissions</div>
<div className='flex flex-wrap gap-2'>
{summary.permissions.map((permission) => (
<Badge key={permission} variant='outline'>
{permission}
</Badge>
))}
</div>
</div>
</div>
))
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-sm'>
{isEdit
? 'Use Preview Effective Access to inspect the resolved CRM authorization.'
: 'Effective access preview becomes available after the user is created.'}
</div>
)}
</CardContent>
</Card>
</form.Form>
</form.AppForm>
</div>
@@ -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}
>
<Icons.check /> {isEdit ? 'Update User' : 'Create User'}
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update User' : 'Create User'}
</Button>
</SheetFooter>
</SheetContent>

View File

@@ -67,6 +67,45 @@ export const columns: ColumnDef<User>[] = [
</div>
)
},
{
id: 'primaryCrmRole',
accessorFn: (row) => row.primaryCrmRole ?? '',
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Primary CRM Role' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<span className='font-medium'>{row.original.primaryCrmRole ?? 'None'}</span>
{row.original.hasLegacyBusinessRoleFallback ? (
<span className='text-amber-600 text-xs'>Legacy fallback active</span>
) : null}
</div>
)
},
{
id: 'crmRoleCount',
accessorFn: (row) => row.crmRoleCount,
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='CRM Roles' />
),
cell: ({ row }) => <Badge variant='secondary'>{row.original.crmRoleCount} Roles</Badge>
},
{
id: 'branchScopeSummary',
accessorFn: (row) => row.branchScopeSummary,
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Branch Scope' />
),
cell: ({ row }) => <span className='text-sm'>{row.original.branchScopeSummary}</span>
},
{
id: 'productScopeSummary',
accessorFn: (row) => row.productScopeSummary,
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Product Scope' />
),
cell: ({ row }) => <span className='text-sm'>{row.original.productScopeSummary}</span>
},
{
id: 'actions',
cell: ({ row }) => <CellAction data={row.original} />

View File

@@ -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'),
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()
})
)
.min(1, 'Select at least one organization')
});
export type UserFormValues = z.infer<typeof userSchema>;

View File

@@ -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<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -290,6 +296,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -322,6 +329,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -349,6 +357,11 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmRoleAssignmentUpdate,
PERMISSIONS.crmRoleAssignmentDelete,
PERMISSIONS.crmRoleAssignmentManage,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentCreate,
PERMISSIONS.crmUserRoleAssignmentUpdate,
PERMISSIONS.crmUserRoleAssignmentDelete,
PERMISSIONS.crmUserRoleAssignmentManage,
PERMISSIONS.crmMasterOptionRead,
PERMISSIONS.crmMasterOptionCreate,
PERMISSIONS.crmMasterOptionUpdate,
@@ -459,6 +472,11 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmRoleAssignmentUpdate, label: 'Update user role assignments' },
{ key: PERMISSIONS.crmRoleAssignmentDelete, label: 'Delete user role assignments' },
{ key: PERMISSIONS.crmRoleAssignmentManage, label: 'Manage user role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentRead, label: 'Read user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentCreate, label: 'Create user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentUpdate, label: 'Update user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentDelete, label: 'Delete user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentManage, label: 'Manage user CRM role assignments' },
{ key: PERMISSIONS.crmMasterOptionRead, label: 'Read master options' },
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },