task-l.2
This commit is contained in:
45
docs/implementation/task-l2-user-management-integration.md
Normal file
45
docs/implementation/task-l2-user-management-integration.md
Normal 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.
|
||||||
@@ -378,6 +378,18 @@ Future:
|
|||||||
- update user-management flows to assign CRM roles through assignment rows instead of membership payloads
|
- 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
|
- 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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"seed:super-admin": "node scripts/seed-super-admin.js",
|
"seed:super-admin": "node scripts/seed-super-admin.js",
|
||||||
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
"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",
|
"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",
|
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
"verify:task-h1": "node scripts/verify-task-h1.js",
|
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||||
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
||||||
|
|||||||
512
plans/task-l.2.md
Normal file
512
plans/task-l.2.md
Normal 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
|
||||||
172
scripts/migrate-membership-business-roles.ts
Normal file
172
scripts/migrate-membership-business-roles.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ type RouteProps = {
|
|||||||
export async function PATCH(request: NextRequest, props: RouteProps) {
|
export async function PATCH(request: NextRequest, props: RouteProps) {
|
||||||
try {
|
try {
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmRoleAssignmentManage
|
permission: PERMISSIONS.crmUserRoleAssignmentManage
|
||||||
});
|
});
|
||||||
const payload = updateAssignmentSchema.parse(await request.json());
|
const payload = updateAssignmentSchema.parse(await request.json());
|
||||||
const { id } = await props.params;
|
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) {
|
export async function DELETE(_request: NextRequest, props: RouteProps) {
|
||||||
try {
|
try {
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmRoleAssignmentManage
|
permission: PERMISSIONS.crmUserRoleAssignmentManage
|
||||||
});
|
});
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
await deleteCrmUserRoleAssignment(organization.id, session.user.id, id);
|
await deleteCrmUserRoleAssignment(organization.id, session.user.id, id);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const assignmentSchema = z.object({
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmRoleAssignmentRead
|
permission: PERMISSIONS.crmUserRoleAssignmentRead
|
||||||
});
|
});
|
||||||
const data = await listResolvedCrmRoleAssignments(organization.id, session.user.id);
|
const data = await listResolvedCrmRoleAssignments(organization.id, session.user.id);
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmRoleAssignmentManage
|
permission: PERMISSIONS.crmUserRoleAssignmentManage
|
||||||
});
|
});
|
||||||
const payload = assignmentSchema.parse(await request.json());
|
const payload = assignmentSchema.parse(await request.json());
|
||||||
const assignment = await createCrmUserRoleAssignment(
|
const assignment = await createCrmUserRoleAssignment(
|
||||||
|
|||||||
53
src/app/api/users/[id]/effective-access-preview/route.ts
Normal file
53
src/app/api/users/[id]/effective-access-preview/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import {
|
import {
|
||||||
assertManageableTargetUser,
|
assertManageableTargetUser,
|
||||||
buildMembershipValues,
|
buildMembershipValues,
|
||||||
|
buildUserResponseModels,
|
||||||
parseUserMutationPayload,
|
parseUserMutationPayload,
|
||||||
requireUserManagementAccess,
|
requireUserManagementAccess,
|
||||||
|
syncCrmRoleAssignmentsForUser,
|
||||||
validateManagedOrganizations
|
validateManagedOrganizations
|
||||||
} from '../_lib';
|
} from '../_lib';
|
||||||
|
|
||||||
@@ -21,7 +23,12 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
|||||||
const payload = parseUserMutationPayload(await request.json());
|
const payload = parseUserMutationPayload(await request.json());
|
||||||
const targetUser = await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
|
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') {
|
if (!isSuperAdmin && payload.systemRole !== 'user') {
|
||||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
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.update(users).set(updateValues).where(eq(users.id, id));
|
||||||
|
|
||||||
await db.delete(memberships).where(eq(memberships.userId, 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 (
|
if (
|
||||||
session.user.id === id &&
|
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) {
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -108,6 +155,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
|
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(memberships).where(eq(memberships.userId, id));
|
||||||
await db.delete(users).where(eq(users.id, id));
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,47 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
import { memberships, organizations, users } from '@/db/schema';
|
import {
|
||||||
|
crmRoleProfiles,
|
||||||
|
crmUserRoleAssignments,
|
||||||
|
memberships,
|
||||||
|
organizations,
|
||||||
|
users
|
||||||
|
} from '@/db/schema';
|
||||||
|
import {
|
||||||
|
createCrmUserRoleAssignment,
|
||||||
|
deleteCrmUserRoleAssignment,
|
||||||
|
updateCrmUserRoleAssignment
|
||||||
|
} from '@/features/foundation/crm-role-assignments/server/service';
|
||||||
import {
|
import {
|
||||||
type BusinessRole,
|
|
||||||
getDefaultBusinessRole,
|
getDefaultBusinessRole,
|
||||||
getDefaultPermissions,
|
getDefaultPermissions,
|
||||||
isBusinessRole,
|
|
||||||
isMembershipRole,
|
isMembershipRole,
|
||||||
|
isRoleAssignmentScopeMode,
|
||||||
isSystemRole
|
isSystemRole
|
||||||
} from '@/lib/auth/rbac';
|
} from '@/lib/auth/rbac';
|
||||||
|
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
|
||||||
import { AuthError, requireSession } from '@/lib/auth/session';
|
import { AuthError, requireSession } from '@/lib/auth/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import type {
|
||||||
|
User,
|
||||||
|
UserCrmRoleAssignment,
|
||||||
|
UserCrmAccessSummary
|
||||||
|
} from '@/features/users/api/types';
|
||||||
|
|
||||||
export type UserMembershipInput = {
|
export type UserMembershipInput = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
membershipRole: 'admin' | 'user';
|
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 = {
|
export type UserMutationInput = {
|
||||||
@@ -23,6 +50,7 @@ export type UserMutationInput = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
systemRole: 'super_admin' | 'user';
|
systemRole: 'super_admin' | 'user';
|
||||||
memberships: UserMembershipInput[];
|
memberships: UserMembershipInput[];
|
||||||
|
crmRoleAssignments: UserCrmRoleAssignmentInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function requireUserManagementAccess() {
|
export async function requireUserManagementAccess() {
|
||||||
@@ -64,6 +92,9 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
|||||||
|
|
||||||
const payload = body as Record<string, unknown>;
|
const payload = body as Record<string, unknown>;
|
||||||
const membershipsPayload = Array.isArray(payload.memberships) ? payload.memberships : [];
|
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 name = typeof payload.name === 'string' ? payload.name.trim() : '';
|
||||||
const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : '';
|
const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : '';
|
||||||
const password = typeof payload.password === 'string' ? payload.password.trim() : undefined;
|
const password = typeof payload.password === 'string' ? payload.password.trim() : undefined;
|
||||||
@@ -83,25 +114,65 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
|||||||
const organizationId =
|
const organizationId =
|
||||||
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
|
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
|
||||||
const membershipRole = typeof value.membershipRole === 'string' ? value.membershipRole : '';
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
organizationId,
|
organizationId,
|
||||||
membershipRole,
|
membershipRole
|
||||||
businessRole
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((membership): membership is UserMembershipInput => membership !== null);
|
.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 = [
|
const uniqueMemberships = [
|
||||||
...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()
|
...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()
|
||||||
];
|
];
|
||||||
@@ -115,16 +186,23 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
systemRole: systemRoleValue,
|
systemRole: systemRoleValue,
|
||||||
memberships: uniqueMemberships
|
memberships: uniqueMemberships,
|
||||||
|
crmRoleAssignments
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateManagedOrganizations(
|
export async function validateManagedOrganizations(
|
||||||
targetMemberships: UserMembershipInput[],
|
targetMemberships: UserMembershipInput[],
|
||||||
|
crmRoleAssignments: UserCrmRoleAssignmentInput[],
|
||||||
manageableOrganizationIds: string[],
|
manageableOrganizationIds: string[],
|
||||||
isSuperAdmin: boolean
|
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
|
const organizationRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -143,6 +221,16 @@ export async function validateManagedOrganizations(
|
|||||||
) {
|
) {
|
||||||
throw new AuthError('Forbidden', 403);
|
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) {
|
export async function getUserMembershipRows(userId: string) {
|
||||||
@@ -184,13 +272,379 @@ export async function assertManageableTargetUser(
|
|||||||
return user;
|
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) => ({
|
return targetMemberships.map((membership) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
organizationId: membership.organizationId,
|
organizationId: membership.organizationId,
|
||||||
role: membership.membershipRole,
|
role: membership.membershipRole,
|
||||||
businessRole: membership.businessRole,
|
businessRole: getLegacyBusinessRoleForMembership({
|
||||||
permissions: getDefaultPermissions(membership.membershipRole, membership.businessRole)
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
105
src/app/api/users/crm-role-assignment-reference/route.ts
Normal file
105
src/app/api/users/crm-role-assignment-reference/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,100 +3,17 @@ import { asc, eq } from 'drizzle-orm';
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { memberships, organizations, users } from '@/db/schema';
|
import { memberships, organizations, users } from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { isBusinessRole } from '@/lib/auth/rbac';
|
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import {
|
import {
|
||||||
buildMembershipValues,
|
buildMembershipValues,
|
||||||
|
buildUserResponseModels,
|
||||||
parseUserMutationPayload,
|
parseUserMutationPayload,
|
||||||
requireUserManagementAccess,
|
requireUserManagementAccess,
|
||||||
|
syncCrmRoleAssignmentsForUser,
|
||||||
validateManagedOrganizations
|
validateManagedOrganizations
|
||||||
} from './_lib';
|
} from './_lib';
|
||||||
|
|
||||||
type UserRow = {
|
function applyRoleFilter(usersList: Awaited<ReturnType<typeof buildUserResponseModels>>, roles?: string) {
|
||||||
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) {
|
|
||||||
if (!roles) {
|
if (!roles) {
|
||||||
return usersList;
|
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));
|
const defaultSort = usersList.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
if (!sort) {
|
if (!sort) {
|
||||||
@@ -132,12 +49,28 @@ function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
|
|||||||
const left =
|
const left =
|
||||||
primarySort.id === 'role'
|
primarySort.id === 'role'
|
||||||
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
|
? `${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'
|
: primarySort.id === 'organizations'
|
||||||
? a.memberships.map((membership) => membership.organizationName).join(', ')
|
? a.memberships.map((membership) => membership.organizationName).join(', ')
|
||||||
: (a[primarySort.id as 'name' | 'email'] ?? a.name);
|
: (a[primarySort.id as 'name' | 'email'] ?? a.name);
|
||||||
const right =
|
const right =
|
||||||
primarySort.id === 'role'
|
primarySort.id === 'role'
|
||||||
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
|
? `${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'
|
: primarySort.id === 'organizations'
|
||||||
? b.memberships.map((membership) => membership.organizationName).join(', ')
|
? b.memberships.map((membership) => membership.organizationName).join(', ')
|
||||||
: (b[primarySort.id as 'name' | 'email'] ?? b.name);
|
: (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 search = searchParams.get('search') ?? undefined;
|
||||||
const sort = searchParams.get('sort') ?? undefined;
|
const sort = searchParams.get('sort') ?? undefined;
|
||||||
|
|
||||||
const rows = await db
|
const usersList = await buildUserResponseModels({
|
||||||
.select({
|
...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds })
|
||||||
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 searchTerm = search?.trim().toLowerCase();
|
const searchTerm = search?.trim().toLowerCase();
|
||||||
const searchedRows = searchTerm
|
const searchedUsers = searchTerm
|
||||||
? scopedRows.filter(
|
? usersList.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
row.name.toLowerCase().includes(searchTerm) ||
|
row.name.toLowerCase().includes(searchTerm) ||
|
||||||
row.email.toLowerCase().includes(searchTerm)
|
row.email.toLowerCase().includes(searchTerm)
|
||||||
)
|
)
|
||||||
: scopedRows;
|
: usersList;
|
||||||
const formattedUsers = formatUsers(searchedRows);
|
const filteredUsers = applyRoleFilter(searchedUsers, roles);
|
||||||
const filteredUsers = applyRoleFilter(formattedUsers, roles);
|
|
||||||
const sortedUsers = sortUsers(filteredUsers, sort);
|
const sortedUsers = sortUsers(filteredUsers, sort);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const pagedUsers = sortedUsers.slice(offset, offset + limit);
|
const pagedUsers = sortedUsers.slice(offset, offset + limit);
|
||||||
@@ -215,7 +130,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
|
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
|
||||||
const payload = parseUserMutationPayload(await request.json());
|
const payload = parseUserMutationPayload(await request.json());
|
||||||
|
|
||||||
if (!payload.password || payload.password.length < 8) {
|
if (!payload.password || payload.password.length < 8) {
|
||||||
@@ -234,6 +149,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
await validateManagedOrganizations(
|
await validateManagedOrganizations(
|
||||||
payload.memberships,
|
payload.memberships,
|
||||||
|
payload.crmRoleAssignments,
|
||||||
manageableOrganizationIds,
|
manageableOrganizationIds,
|
||||||
isSuperAdmin
|
isSuperAdmin
|
||||||
);
|
);
|
||||||
@@ -261,7 +177,19 @@ export async function POST(request: NextRequest) {
|
|||||||
activeOrganizationId: payload.memberships[0]?.organizationId ?? null
|
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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default async function CrmUserRoleAssignmentsRoute() {
|
|||||||
const canRead =
|
const canRead =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmRoleAssignmentRead));
|
session.user.activePermissions.includes(PERMISSIONS.crmUserRoleAssignmentRead));
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
url: "/dashboard/crm/settings/master-options",
|
url: "/dashboard/crm/settings/master-options",
|
||||||
icon: "settings",
|
icon: "settings",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
access: { requireOrg: true, permission: "crm.role.assignment.read" },
|
access: { requireOrg: true, permission: "crm.user_role_assignment.read" },
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Roles & Permissions",
|
title: "Roles & Permissions",
|
||||||
@@ -148,7 +148,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
url: "/dashboard/crm/settings/user-role-assignments",
|
url: "/dashboard/crm/settings/user-role-assignments",
|
||||||
access: {
|
access: {
|
||||||
requireOrg: true,
|
requireOrg: true,
|
||||||
permission: "crm.role.assignment.read",
|
permission: "crm.user_role_assignment.read",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
export interface UserOrganization {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,7 +16,38 @@ export interface UserMembership {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
membershipRole: 'admin' | 'user';
|
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 {
|
export interface User {
|
||||||
@@ -22,6 +59,13 @@ export interface User {
|
|||||||
activeOrganizationId: string | null;
|
activeOrganizationId: string | null;
|
||||||
organizations: UserOrganization[];
|
organizations: UserOrganization[];
|
||||||
memberships: UserMembership[];
|
memberships: UserMembership[];
|
||||||
|
crmRoleAssignments?: UserCrmRoleAssignment[];
|
||||||
|
crmAccessSummaries?: UserCrmAccessSummary[];
|
||||||
|
primaryCrmRole?: string | null;
|
||||||
|
crmRoleCount?: number;
|
||||||
|
branchScopeSummary?: string;
|
||||||
|
productScopeSummary?: string;
|
||||||
|
hasLegacyBusinessRoleFallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserFilters = {
|
export type UserFilters = {
|
||||||
@@ -50,6 +94,16 @@ export type UserMutationPayload = {
|
|||||||
memberships: Array<{
|
memberships: Array<{
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
membershipRole: 'admin' | 'user';
|
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;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import { createUserMutation, updateUserMutation } from '../api/mutations';
|
|||||||
import type { User } from '../api/types';
|
import type { User } from '../api/types';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { type UserFormValues, userSchema } from '../schemas/user';
|
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 {
|
interface UserFormSheetProps {
|
||||||
user?: User;
|
user?: User;
|
||||||
@@ -37,29 +40,50 @@ type OrganizationOption = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
businessRole?: string;
|
|
||||||
canManageUsers: boolean;
|
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 = [
|
const membershipRoleOptions = [
|
||||||
{ value: 'admin', label: 'Organization Admin' },
|
{ value: 'admin', label: 'Organization Admin' },
|
||||||
{ value: 'user', label: 'User' }
|
{ value: 'user', label: 'User' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const businessRoleOptions = [
|
const scopeModeOptions = [
|
||||||
{ value: 'marketing', label: 'Marketing' },
|
{ value: 'inherit', label: 'Inherit' },
|
||||||
{ value: 'sales_manager', label: 'Sales Manager' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'sales', label: 'Sales' },
|
{ value: 'selected', label: 'Selected' },
|
||||||
{ value: 'sales_support', label: 'Sales Support' },
|
{ value: 'none', label: 'None' }
|
||||||
{ value: 'department_manager', label: 'Department Manager' },
|
|
||||||
{ value: 'top_manager', label: 'Top Manager' },
|
|
||||||
{ value: 'crm_admin', label: 'CRM Admin' }
|
|
||||||
] as const;
|
] 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) {
|
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
|
||||||
const isEdit = !!user;
|
const isEdit = !!user;
|
||||||
const [organizations, setOrganizations] = useState<OrganizationOption[]>([]);
|
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 [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||||
|
const [isLoadingAssignments, setIsLoadingAssignments] = useState(false);
|
||||||
|
const [previewUser, setPreviewUser] = useState<User | null>(null);
|
||||||
|
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createUserMutation,
|
...createUserMutation,
|
||||||
@@ -67,6 +91,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
toast.success('User created successfully');
|
toast.success('User created successfully');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setPreviewUser(null);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create user')
|
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) => ({
|
user?.memberships.map((membership) => ({
|
||||||
organizationId: membership.organizationId,
|
organizationId: membership.organizationId,
|
||||||
membershipRole: membership.membershipRole,
|
membershipRole: membership.membershipRole
|
||||||
businessRole: membership.businessRole
|
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
[user?.memberships]
|
[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({
|
const form = useAppForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: user?.name ?? '',
|
name: user?.name ?? '',
|
||||||
email: user?.email ?? '',
|
email: user?.email ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
systemRole: user?.systemRole ?? 'user',
|
systemRole: user?.systemRole ?? 'user',
|
||||||
memberships: initialMemberships
|
memberships: initialMemberships,
|
||||||
|
crmRoleAssignments: initialAssignments
|
||||||
} as UserFormValues,
|
} as UserFormValues,
|
||||||
validators: {
|
validators: {
|
||||||
onSubmit: userSchema
|
onSubmit: userSchema
|
||||||
@@ -122,6 +163,32 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
|
|
||||||
const { FormTextField } = useFormFields<UserFormValues>();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
@@ -164,187 +231,652 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [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;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className='flex flex-col sm:max-w-2xl'>
|
<SheetContent className='flex flex-col sm:max-w-5xl'>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{isEdit
|
{isEdit
|
||||||
? 'Update the user details and workspace memberships below.'
|
? 'Update membership, CRM role assignments, and effective access behavior.'
|
||||||
: 'Create a user account and assign workspace-specific roles.'}
|
: 'Create a user with organization memberships and optional CRM role assignments.'}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className='flex-1 overflow-auto'>
|
<div className='flex-1 overflow-auto pr-1'>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.Form id='user-form-sheet' className='space-y-4'>
|
<form.Form id='user-form-sheet' className='space-y-6'>
|
||||||
<FormTextField name='name' label='Full Name' required placeholder='Jane Doe' />
|
<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'
|
||||||
|
required
|
||||||
|
type='email'
|
||||||
|
placeholder='jane@example.com'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormTextField
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
name='email'
|
<FormTextField
|
||||||
label='Email'
|
name='password'
|
||||||
required
|
label={isEdit ? 'Password (optional)' : 'Password'}
|
||||||
type='email'
|
required={!isEdit}
|
||||||
placeholder='jane@example.com'
|
type='password'
|
||||||
/>
|
placeholder={
|
||||||
|
isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'
|
||||||
<FormTextField
|
}
|
||||||
name='password'
|
/>
|
||||||
label={isEdit ? 'Password (optional)' : 'Password'}
|
<div className='rounded-lg border border-dashed p-3 text-sm'>
|
||||||
required={!isEdit}
|
<span className='font-medium'>System Role:</span>{' '}
|
||||||
type='password'
|
{isEdit && user?.systemRole === 'super_admin' ? 'Super Admin' : 'User'}
|
||||||
placeholder={
|
</div>
|
||||||
isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'
|
</div>
|
||||||
}
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
|
|
||||||
{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
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form.AppField
|
<form.AppField
|
||||||
name='memberships'
|
name='memberships'
|
||||||
children={(field) => (
|
children={(field) => (
|
||||||
<field.FieldSet>
|
<Card>
|
||||||
<field.Field>
|
<CardHeader>
|
||||||
<div className='text-sm font-medium'>Workspace Memberships *</div>
|
<CardTitle>Workspace Memberships</CardTitle>
|
||||||
<div className='text-muted-foreground text-xs'>
|
<CardDescription>
|
||||||
One user can have different roles in different workspaces.
|
Membership grants organization access. CRM authorization now comes from role
|
||||||
</div>
|
assignments instead of `membership.businessRole`.
|
||||||
<div className='space-y-2 rounded-lg border p-3'>
|
</CardDescription>
|
||||||
{isLoadingOrganizations ? (
|
</CardHeader>
|
||||||
<div className='text-muted-foreground text-sm'>Loading workspaces...</div>
|
<CardContent className='space-y-3'>
|
||||||
) : organizations.length ? (
|
{isLoadingOrganizations ? (
|
||||||
organizations.map((organization) => {
|
<div className='text-muted-foreground text-sm'>Loading workspaces...</div>
|
||||||
const membership = field.state.value.find(
|
) : organizations.length ? (
|
||||||
(value) => value.organizationId === organization.id
|
organizations.map((organization) => {
|
||||||
);
|
const membership = field.state.value.find(
|
||||||
const checked = !!membership;
|
(value) => value.organizationId === organization.id
|
||||||
|
);
|
||||||
|
const checked = !!membership;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={organization.id}
|
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'>
|
<div className='flex items-start gap-3'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`Select workspace ${organization.name}`}
|
checked={checked}
|
||||||
checked={checked}
|
onCheckedChange={(value) => {
|
||||||
onCheckedChange={(value) => {
|
if (!value) {
|
||||||
if (!value) {
|
const nextMemberships = field.state.value.filter(
|
||||||
field.handleChange(
|
(item) => item.organizationId !== organization.id
|
||||||
field.state.value.filter(
|
);
|
||||||
(item) => item.organizationId !== organization.id
|
field.handleChange(nextMemberships);
|
||||||
)
|
|
||||||
);
|
|
||||||
field.handleBlur();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
field.handleChange([
|
|
||||||
...field.state.value,
|
|
||||||
{
|
|
||||||
organizationId: organization.id,
|
|
||||||
membershipRole: 'user',
|
|
||||||
businessRole: 'sales_support'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
field.handleBlur();
|
field.handleBlur();
|
||||||
}}
|
setSelectedOrganizationIds(
|
||||||
/>
|
nextMemberships.map((item) => item.organizationId)
|
||||||
<div className='flex flex-col'>
|
);
|
||||||
<span className='font-medium'>{organization.name}</span>
|
form.setFieldValue(
|
||||||
<span className='text-muted-foreground text-xs'>
|
'crmRoleAssignments',
|
||||||
Your role here: {organization.role}
|
form.state.values.crmRoleAssignments.filter(
|
||||||
</span>
|
(item) => item.organizationId !== organization.id
|
||||||
</div>
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMemberships = [
|
||||||
|
...field.state.value,
|
||||||
|
{
|
||||||
|
organizationId: organization.id,
|
||||||
|
membershipRole: 'user' as const
|
||||||
|
}
|
||||||
|
];
|
||||||
|
field.handleChange(nextMemberships);
|
||||||
|
field.handleBlur();
|
||||||
|
setSelectedOrganizationIds(
|
||||||
|
nextMemberships.map((item) => item.organizationId)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<span className='font-medium'>{organization.name}</span>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
Your role here: {organization.role}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
|
||||||
value={membership?.membershipRole ?? 'user'}
|
|
||||||
disabled={!checked}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
field.handleChange(
|
|
||||||
field.state.value.map((item) =>
|
|
||||||
item.organizationId === organization.id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
membershipRole: value as 'admin' | 'user'
|
|
||||||
}
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
field.handleBlur();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger aria-label={`Role for ${organization.name}`}>
|
|
||||||
<SelectValue placeholder='Select role' />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{membershipRoleOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
|
||||||
})
|
<Select
|
||||||
) : (
|
value={membership?.membershipRole ?? 'user'}
|
||||||
<div className='text-muted-foreground text-sm'>
|
disabled={!checked}
|
||||||
No manageable workspaces available.
|
onValueChange={(value) => {
|
||||||
</div>
|
field.handleChange(
|
||||||
)}
|
field.state.value.map((item) =>
|
||||||
</div>
|
item.organizationId === organization.id
|
||||||
</field.Field>
|
? {
|
||||||
<field.FieldError />
|
...item,
|
||||||
</field.FieldSet>
|
membershipRole: value as 'admin' | 'user'
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
field.handleBlur();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select role' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{membershipRoleOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
No manageable workspaces available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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.Form>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,9 +889,10 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
type='submit'
|
type='submit'
|
||||||
form='user-form-sheet'
|
form='user-form-sheet'
|
||||||
isLoading={isPending}
|
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>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -67,6 +67,45 @@ export const columns: ColumnDef<User>[] = [
|
|||||||
</div>
|
</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',
|
id: 'actions',
|
||||||
cell: ({ row }) => <CellAction data={row.original} />
|
cell: ({ row }) => <CellAction data={row.original} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { ROLE_ASSIGNMENT_SCOPE_MODES } from '@/lib/auth/rbac';
|
||||||
export const userSchema = z.object({
|
export const userSchema = z.object({
|
||||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
email: z.string().email('Please enter a valid email'),
|
email: z.string().email('Please enter a valid email'),
|
||||||
@@ -8,11 +9,23 @@ export const userSchema = z.object({
|
|||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string().min(1),
|
organizationId: z.string().min(1),
|
||||||
membershipRole: z.enum(['admin', 'user']),
|
membershipRole: z.enum(['admin', 'user'])
|
||||||
businessRole: z.string().min(1)
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.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<typeof userSchema>;
|
export type UserFormValues = z.infer<typeof userSchema>;
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export const PERMISSIONS = {
|
|||||||
crmRoleAssignmentUpdate: 'crm.role.assignment.update',
|
crmRoleAssignmentUpdate: 'crm.role.assignment.update',
|
||||||
crmRoleAssignmentDelete: 'crm.role.assignment.delete',
|
crmRoleAssignmentDelete: 'crm.role.assignment.delete',
|
||||||
crmRoleAssignmentManage: 'crm.role.assignment.manage',
|
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',
|
crmMasterOptionRead: 'crm.master_option.read',
|
||||||
crmMasterOptionCreate: 'crm.master_option.create',
|
crmMasterOptionCreate: 'crm.master_option.create',
|
||||||
crmMasterOptionUpdate: 'crm.master_option.update',
|
crmMasterOptionUpdate: 'crm.master_option.update',
|
||||||
@@ -258,6 +263,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
PERMISSIONS.crmRoleAssignmentRead,
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -290,6 +296,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
PERMISSIONS.crmRoleAssignmentRead,
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -322,6 +329,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
PERMISSIONS.crmRoleAssignmentRead,
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -349,6 +357,11 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmRoleAssignmentUpdate,
|
PERMISSIONS.crmRoleAssignmentUpdate,
|
||||||
PERMISSIONS.crmRoleAssignmentDelete,
|
PERMISSIONS.crmRoleAssignmentDelete,
|
||||||
PERMISSIONS.crmRoleAssignmentManage,
|
PERMISSIONS.crmRoleAssignmentManage,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentRead,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentCreate,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentUpdate,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentDelete,
|
||||||
|
PERMISSIONS.crmUserRoleAssignmentManage,
|
||||||
PERMISSIONS.crmMasterOptionRead,
|
PERMISSIONS.crmMasterOptionRead,
|
||||||
PERMISSIONS.crmMasterOptionCreate,
|
PERMISSIONS.crmMasterOptionCreate,
|
||||||
PERMISSIONS.crmMasterOptionUpdate,
|
PERMISSIONS.crmMasterOptionUpdate,
|
||||||
@@ -459,6 +472,11 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmRoleAssignmentUpdate, label: 'Update user role assignments' },
|
{ key: PERMISSIONS.crmRoleAssignmentUpdate, label: 'Update user role assignments' },
|
||||||
{ key: PERMISSIONS.crmRoleAssignmentDelete, label: 'Delete user role assignments' },
|
{ key: PERMISSIONS.crmRoleAssignmentDelete, label: 'Delete user role assignments' },
|
||||||
{ key: PERMISSIONS.crmRoleAssignmentManage, label: 'Manage 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.crmMasterOptionRead, label: 'Read master options' },
|
||||||
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
|
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
|
||||||
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
|
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
|
||||||
|
|||||||
Reference in New Issue
Block a user