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

View File

@@ -23,7 +23,7 @@ type RouteProps = {
export async function PATCH(request: NextRequest, props: RouteProps) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleAssignmentManage
permission: PERMISSIONS.crmUserRoleAssignmentManage
});
const payload = updateAssignmentSchema.parse(await request.json());
const { id } = await props.params;
@@ -65,7 +65,7 @@ export async function PATCH(request: NextRequest, props: RouteProps) {
export async function DELETE(_request: NextRequest, props: RouteProps) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleAssignmentManage
permission: PERMISSIONS.crmUserRoleAssignmentManage
});
const { id } = await props.params;
await deleteCrmUserRoleAssignment(organization.id, session.user.id, id);

View File

@@ -21,7 +21,7 @@ const assignmentSchema = z.object({
export async function GET() {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleAssignmentRead
permission: PERMISSIONS.crmUserRoleAssignmentRead
});
const data = await listResolvedCrmRoleAssignments(organization.id, session.user.id);
@@ -46,7 +46,7 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleAssignmentManage
permission: PERMISSIONS.crmUserRoleAssignmentManage
});
const payload = assignmentSchema.parse(await request.json());
const assignment = await createCrmUserRoleAssignment(

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import { AuthError } from '@/lib/auth/session';
import {
assertManageableTargetUser,
buildUserResponseModels,
requireUserManagementAccess
} from '@/app/api/users/_lib';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
const [user] = await buildUserResponseModels({
scopedUserIds: [id],
...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds })
});
if (!user) {
return NextResponse.json({ message: 'User not found' }, { status: 404 });
}
await auditAction({
organizationId: session.user.activeOrganizationId ?? user.organizations[0]?.id,
userId: session.user.id,
entityType: 'effective_access_preview',
entityId: id,
action: 'preview',
afterData: {
userId: user.id,
crmAccessSummaries: user.crmAccessSummaries
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Effective access preview loaded successfully',
user
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to preview effective access' }, { status: 500 });
}
}

View File

@@ -1,14 +1,16 @@
import { hash } from 'bcryptjs';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, users } from '@/db/schema';
import { crmUserRoleAssignments, memberships, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
assertManageableTargetUser,
buildMembershipValues,
buildUserResponseModels,
parseUserMutationPayload,
requireUserManagementAccess,
syncCrmRoleAssignmentsForUser,
validateManagedOrganizations
} from '../_lib';
@@ -21,7 +23,12 @@ export async function PUT(request: NextRequest, { params }: Params) {
const payload = parseUserMutationPayload(await request.json());
const targetUser = await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin);
await validateManagedOrganizations(
payload.memberships,
payload.crmRoleAssignments,
manageableOrganizationIds,
isSuperAdmin
);
if (!isSuperAdmin && payload.systemRole !== 'user') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
@@ -66,7 +73,18 @@ export async function PUT(request: NextRequest, { params }: Params) {
await db.update(users).set(updateValues).where(eq(users.id, id));
await db.delete(memberships).where(eq(memberships.userId, id));
await db.insert(memberships).values(buildMembershipValues(id, payload.memberships));
const membershipValues = await buildMembershipValues(
id,
payload.memberships,
payload.crmRoleAssignments
);
await db.insert(memberships).values(membershipValues);
await syncCrmRoleAssignmentsForUser(
id,
session.user.id,
payload.memberships,
payload.crmRoleAssignments
);
if (
session.user.id === id &&
@@ -97,6 +115,35 @@ export async function PUT(request: NextRequest, { params }: Params) {
}
}
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
const [user] = await buildUserResponseModels({
scopedUserIds: [id],
...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds })
});
if (!user) {
return NextResponse.json({ message: 'User not found' }, { status: 404 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'User loaded successfully',
user
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load user' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
@@ -108,6 +155,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
await db.delete(crmUserRoleAssignments).where(eq(crmUserRoleAssignments.userId, id));
await db.delete(memberships).where(eq(memberships.userId, id));
await db.delete(users).where(eq(users.id, id));

View File

@@ -1,20 +1,47 @@
import { eq, inArray } from 'drizzle-orm';
import { memberships, organizations, users } from '@/db/schema';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import {
crmRoleProfiles,
crmUserRoleAssignments,
memberships,
organizations,
users
} from '@/db/schema';
import {
createCrmUserRoleAssignment,
deleteCrmUserRoleAssignment,
updateCrmUserRoleAssignment
} from '@/features/foundation/crm-role-assignments/server/service';
import {
type BusinessRole,
getDefaultBusinessRole,
getDefaultPermissions,
isBusinessRole,
isMembershipRole,
isRoleAssignmentScopeMode,
isSystemRole
} from '@/lib/auth/rbac';
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { AuthError, requireSession } from '@/lib/auth/session';
import { db } from '@/lib/db';
import type {
User,
UserCrmRoleAssignment,
UserCrmAccessSummary
} from '@/features/users/api/types';
export type UserMembershipInput = {
organizationId: string;
membershipRole: 'admin' | 'user';
businessRole: BusinessRole;
};
export type UserCrmRoleAssignmentInput = {
id?: string;
organizationId: string;
roleProfileId: string;
branchScopeMode: 'all' | 'selected' | 'none' | 'inherit';
branchScopeIds: string[];
productTypeScopeMode: 'all' | 'selected' | 'none' | 'inherit';
productTypeScopeIds: string[];
isPrimary: boolean;
isActive: boolean;
};
export type UserMutationInput = {
@@ -23,6 +50,7 @@ export type UserMutationInput = {
password?: string;
systemRole: 'super_admin' | 'user';
memberships: UserMembershipInput[];
crmRoleAssignments: UserCrmRoleAssignmentInput[];
};
export async function requireUserManagementAccess() {
@@ -64,6 +92,9 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
const payload = body as Record<string, unknown>;
const membershipsPayload = Array.isArray(payload.memberships) ? payload.memberships : [];
const crmRoleAssignmentsPayload = Array.isArray(payload.crmRoleAssignments)
? payload.crmRoleAssignments
: [];
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : '';
const password = typeof payload.password === 'string' ? payload.password.trim() : undefined;
@@ -83,25 +114,65 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
const organizationId =
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
const membershipRole = typeof value.membershipRole === 'string' ? value.membershipRole : '';
const businessRole =
typeof value.businessRole === 'string'
? value.businessRole
: getDefaultBusinessRole(
membershipRole === 'admin' || membershipRole === 'user' ? membershipRole : 'user'
);
if (!organizationId || !isMembershipRole(membershipRole) || !isBusinessRole(businessRole)) {
if (!organizationId || !isMembershipRole(membershipRole)) {
return null;
}
return {
organizationId,
membershipRole,
businessRole
membershipRole
};
})
.filter((membership): membership is UserMembershipInput => membership !== null);
const crmRoleAssignments = crmRoleAssignmentsPayload
.map((assignment) => {
if (!assignment || typeof assignment !== 'object') {
return null;
}
const value = assignment as Record<string, unknown>;
const organizationId =
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
const roleProfileId =
typeof value.roleProfileId === 'string' ? value.roleProfileId.trim() : '';
const id = typeof value.id === 'string' ? value.id.trim() : undefined;
const branchScopeMode = typeof value.branchScopeMode === 'string' ? value.branchScopeMode : '';
const productTypeScopeMode =
typeof value.productTypeScopeMode === 'string' ? value.productTypeScopeMode : '';
const branchScopeIds = Array.isArray(value.branchScopeIds)
? value.branchScopeIds.filter((item): item is string => typeof item === 'string')
: [];
const productTypeScopeIds = Array.isArray(value.productTypeScopeIds)
? value.productTypeScopeIds.filter((item): item is string => typeof item === 'string')
: [];
const isPrimary = value.isPrimary === true;
const isActive = value.isActive !== false;
if (
!organizationId ||
!roleProfileId ||
!isRoleAssignmentScopeMode(branchScopeMode) ||
!isRoleAssignmentScopeMode(productTypeScopeMode)
) {
return null;
}
return {
...(id ? { id } : {}),
organizationId,
roleProfileId,
branchScopeMode,
branchScopeIds,
productTypeScopeMode,
productTypeScopeIds,
isPrimary,
isActive
} satisfies UserCrmRoleAssignmentInput;
})
.filter((assignment): assignment is UserCrmRoleAssignmentInput => assignment !== null);
const uniqueMemberships = [
...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()
];
@@ -115,16 +186,23 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
email,
password,
systemRole: systemRoleValue,
memberships: uniqueMemberships
memberships: uniqueMemberships,
crmRoleAssignments
};
}
export async function validateManagedOrganizations(
targetMemberships: UserMembershipInput[],
crmRoleAssignments: UserCrmRoleAssignmentInput[],
manageableOrganizationIds: string[],
isSuperAdmin: boolean
) {
const organizationIds = targetMemberships.map((membership) => membership.organizationId);
const organizationIds = [
...new Set([
...targetMemberships.map((membership) => membership.organizationId),
...crmRoleAssignments.map((assignment) => assignment.organizationId)
])
];
const organizationRows = await db
.select({
@@ -143,6 +221,16 @@ export async function validateManagedOrganizations(
) {
throw new AuthError('Forbidden', 403);
}
const membershipOrganizationIds = new Set(
targetMemberships.map((membership) => membership.organizationId)
);
if (
crmRoleAssignments.some((assignment) => !membershipOrganizationIds.has(assignment.organizationId))
) {
throw new Error('CRM role assignments must belong to a selected organization membership');
}
}
export async function getUserMembershipRows(userId: string) {
@@ -184,13 +272,379 @@ export async function assertManageableTargetUser(
return user;
}
export function buildMembershipValues(userId: string, targetMemberships: UserMembershipInput[]) {
function getLegacyBusinessRoleForMembership(input: {
membership: UserMembershipInput;
crmRoleAssignments: UserCrmRoleAssignmentInput[];
roleProfilesById: Map<string, { code: string }>;
}) {
const orgAssignments = input.crmRoleAssignments.filter(
(assignment) => assignment.organizationId === input.membership.organizationId && assignment.isActive
);
const primaryAssignment =
orgAssignments.find((assignment) => assignment.isPrimary) ?? orgAssignments[0] ?? null;
if (primaryAssignment) {
return (
input.roleProfilesById.get(primaryAssignment.roleProfileId)?.code ??
getDefaultBusinessRole(input.membership.membershipRole)
);
}
return getDefaultBusinessRole(input.membership.membershipRole);
}
export async function buildMembershipValues(
userId: string,
targetMemberships: UserMembershipInput[],
crmRoleAssignments: UserCrmRoleAssignmentInput[]
) {
const roleProfileIds = [...new Set(crmRoleAssignments.map((assignment) => assignment.roleProfileId))];
const roleProfiles = roleProfileIds.length
? await db
.select({
id: crmRoleProfiles.id,
code: crmRoleProfiles.code
})
.from(crmRoleProfiles)
.where(and(inArray(crmRoleProfiles.id, roleProfileIds), isNull(crmRoleProfiles.deletedAt)))
: [];
const roleProfilesById = new Map(roleProfiles.map((profile) => [profile.id, profile]));
return targetMemberships.map((membership) => ({
id: crypto.randomUUID(),
userId,
organizationId: membership.organizationId,
role: membership.membershipRole,
businessRole: membership.businessRole,
permissions: getDefaultPermissions(membership.membershipRole, membership.businessRole)
businessRole: getLegacyBusinessRoleForMembership({
membership,
crmRoleAssignments,
roleProfilesById
}),
permissions: getDefaultPermissions(
membership.membershipRole,
getLegacyBusinessRoleForMembership({
membership,
crmRoleAssignments,
roleProfilesById
})
)
}));
}
export async function syncCrmRoleAssignmentsForUser(
userId: string,
actorUserId: string,
membershipsForUser: UserMembershipInput[],
crmRoleAssignments: UserCrmRoleAssignmentInput[]
) {
const membershipOrganizationIds = membershipsForUser.map((membership) => membership.organizationId);
const existingAssignments = await db
.select()
.from(crmUserRoleAssignments)
.where(eq(crmUserRoleAssignments.userId, userId));
const assignmentsForManagedMemberships = existingAssignments.filter((assignment) =>
membershipOrganizationIds.includes(assignment.organizationId)
);
const payloadIds = new Set(crmRoleAssignments.map((assignment) => assignment.id).filter(Boolean));
for (const assignment of assignmentsForManagedMemberships) {
if (payloadIds.has(assignment.id)) {
continue;
}
const stillReferenced = crmRoleAssignments.some(
(item) =>
item.organizationId === assignment.organizationId &&
item.roleProfileId === assignment.roleProfileId &&
!item.id
);
if (!stillReferenced) {
await deleteCrmUserRoleAssignment(assignment.organizationId, actorUserId, assignment.id);
}
}
for (const assignment of crmRoleAssignments) {
if (assignment.id) {
await updateCrmUserRoleAssignment(assignment.organizationId, actorUserId, assignment.id, {
branchScopeMode: assignment.branchScopeMode,
branchScopeIds: assignment.branchScopeIds,
productTypeScopeMode: assignment.productTypeScopeMode,
productTypeScopeIds: assignment.productTypeScopeIds,
isPrimary: assignment.isPrimary,
isActive: assignment.isActive
});
continue;
}
await createCrmUserRoleAssignment(assignment.organizationId, actorUserId, {
userId,
roleProfileId: assignment.roleProfileId,
branchScopeMode: assignment.branchScopeMode,
branchScopeIds: assignment.branchScopeIds,
productTypeScopeMode: assignment.productTypeScopeMode,
productTypeScopeIds: assignment.productTypeScopeIds,
isPrimary: assignment.isPrimary,
isActive: assignment.isActive
});
}
}
function summarizeScope(mode: UserCrmAccessSummary['branchScopeMode'], ids: string[]) {
if (mode === 'all') {
return 'All';
}
if (mode === 'none' || ids.length === 0) {
return 'None';
}
return ids.length <= 2 ? ids.join(' + ') : `${ids.slice(0, 2).join(' + ')} +${ids.length - 2}`;
}
export async function buildUserResponseModels(params: {
scopedOrganizationIds?: string[];
scopedUserIds?: string[];
}) {
const membershipRows = await db
.select({
membershipId: memberships.id,
userId: memberships.userId,
organizationId: memberships.organizationId,
role: memberships.role,
businessRole: memberships.businessRole,
permissions: memberships.permissions,
branchScopeIds: memberships.branchScopeIds,
productTypeScopeIds: memberships.productTypeScopeIds,
createdAt: memberships.createdAt,
updatedAt: memberships.updatedAt,
organizationName: organizations.name
})
.from(memberships)
.innerJoin(organizations, eq(organizations.id, memberships.organizationId))
.where(
and(
...(params.scopedOrganizationIds?.length
? [inArray(memberships.organizationId, params.scopedOrganizationIds)]
: []),
...(params.scopedUserIds?.length ? [inArray(memberships.userId, params.scopedUserIds)] : [])
)
);
if (!membershipRows.length) {
return [];
}
const userIds = [...new Set(membershipRows.map((row) => row.userId))];
const organizationIds = [...new Set(membershipRows.map((row) => row.organizationId))];
const [userRows, assignmentRows, roleProfileRows] = await Promise.all([
db
.select({
id: users.id,
name: users.name,
email: users.email,
systemRole: users.systemRole,
activeOrganizationId: users.activeOrganizationId
})
.from(users)
.where(inArray(users.id, userIds)),
db
.select()
.from(crmUserRoleAssignments)
.where(
and(
inArray(crmUserRoleAssignments.userId, userIds),
inArray(crmUserRoleAssignments.organizationId, organizationIds),
isNull(crmUserRoleAssignments.deletedAt)
)
),
db
.select({
id: crmRoleProfiles.id,
code: crmRoleProfiles.code,
name: crmRoleProfiles.name
})
.from(crmRoleProfiles)
.where(
and(
inArray(crmRoleProfiles.organizationId, organizationIds),
isNull(crmRoleProfiles.deletedAt)
)
)
]);
const userMap = new Map(userRows.map((row) => [row.id, row]));
const roleProfileMap = new Map(roleProfileRows.map((row) => [row.id, row]));
const assignmentsByUserOrg = new Map<string, UserCrmRoleAssignment[]>();
for (const assignment of assignmentRows) {
const roleProfile = roleProfileMap.get(assignment.roleProfileId);
if (!roleProfile) {
continue;
}
const key = `${assignment.userId}:${assignment.organizationId}`;
const current = assignmentsByUserOrg.get(key) ?? [];
current.push({
id: assignment.id,
organizationId: assignment.organizationId,
roleProfileId: assignment.roleProfileId,
roleProfileCode: roleProfile.code,
roleProfileName: roleProfile.name,
branchScopeMode: isRoleAssignmentScopeMode(assignment.branchScopeMode)
? assignment.branchScopeMode
: 'inherit',
branchScopeIds: assignment.branchScopeIds ?? [],
productTypeScopeMode: isRoleAssignmentScopeMode(assignment.productTypeScopeMode)
? assignment.productTypeScopeMode
: 'inherit',
productTypeScopeIds: assignment.productTypeScopeIds ?? [],
isPrimary: assignment.isPrimary,
isActive: assignment.isActive
});
assignmentsByUserOrg.set(key, current);
}
const usersMap = new Map<string, User>();
for (const membershipRow of membershipRows) {
const dbUser = userMap.get(membershipRow.userId);
if (!dbUser) {
continue;
}
const resolvedAccess = await resolveCrmMembershipAccess({
systemRole: dbUser.systemRole === 'super_admin' ? 'super_admin' : 'user',
organizationId: membershipRow.organizationId,
membership: {
id: membershipRow.membershipId,
userId: membershipRow.userId,
organizationId: membershipRow.organizationId,
role: membershipRow.role,
businessRole: membershipRow.businessRole,
permissions: membershipRow.permissions,
branchScopeIds: membershipRow.branchScopeIds,
productTypeScopeIds: membershipRow.productTypeScopeIds,
createdAt: membershipRow.createdAt,
updatedAt: membershipRow.updatedAt
}
});
const userKey = membershipRow.userId;
const existing = usersMap.get(userKey);
const assignments =
assignmentsByUserOrg.get(`${membershipRow.userId}:${membershipRow.organizationId}`) ?? [];
const accessSummary: UserCrmAccessSummary = {
organizationId: membershipRow.organizationId,
organizationName: membershipRow.organizationName,
primaryCrmRole: resolvedAccess.businessRole,
primaryCrmRoleLabel:
assignments.find((assignment) => assignment.isPrimary)?.roleProfileName ??
assignments[0]?.roleProfileName ??
resolvedAccess.roleProfileName,
roleCount: assignments.filter((assignment) => assignment.isActive).length,
permissions: resolvedAccess.permissions,
ownershipScope: resolvedAccess.ownershipScope,
branchScopeMode: resolvedAccess.branchScopeMode,
branchScopeIds: resolvedAccess.branchScopeIds,
productScopeMode: resolvedAccess.productScopeMode,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
approvalAuthority: resolvedAccess.approvalAuthority,
usedLegacyFallback: assignments.length === 0
};
if (existing) {
existing.crmRoleAssignments ??= [];
existing.crmAccessSummaries ??= [];
existing.organizations.push({
id: membershipRow.organizationId,
name: membershipRow.organizationName,
role: membershipRow.role === 'admin' ? 'admin' : 'user'
});
existing.memberships.push({
organizationId: membershipRow.organizationId,
organizationName: membershipRow.organizationName,
membershipRole: membershipRow.role === 'admin' ? 'admin' : 'user',
businessRole: membershipRow.businessRole,
isLegacyBusinessRoleDeprecated: assignments.length > 0
});
existing.crmRoleAssignments.push(...assignments);
existing.crmAccessSummaries.push(accessSummary);
continue;
}
usersMap.set(userKey, {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
systemRole: dbUser.systemRole === 'super_admin' ? 'super_admin' : 'user',
activeMembershipRole:
dbUser.activeOrganizationId === membershipRow.organizationId && membershipRow.role === 'admin'
? 'admin'
: dbUser.activeOrganizationId === membershipRow.organizationId
? 'user'
: null,
activeOrganizationId: dbUser.activeOrganizationId,
organizations: [
{
id: membershipRow.organizationId,
name: membershipRow.organizationName,
role: membershipRow.role === 'admin' ? 'admin' : 'user'
}
],
memberships: [
{
organizationId: membershipRow.organizationId,
organizationName: membershipRow.organizationName,
membershipRole: membershipRow.role === 'admin' ? 'admin' : 'user',
businessRole: membershipRow.businessRole,
isLegacyBusinessRoleDeprecated: assignments.length > 0
}
],
crmRoleAssignments: [...assignments],
crmAccessSummaries: [accessSummary],
primaryCrmRole: null,
crmRoleCount: 0,
branchScopeSummary: 'None',
productScopeSummary: 'None',
hasLegacyBusinessRoleFallback: assignments.length === 0
});
}
return [...usersMap.values()].map((user) => {
const crmAccessSummaries = user.crmAccessSummaries ?? [];
const crmRoleAssignments = user.crmRoleAssignments ?? [];
const primaryAccess =
crmAccessSummaries.find(
(summary) => summary.organizationId === user.activeOrganizationId
) ?? crmAccessSummaries[0];
return {
...user,
organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
memberships: user.memberships.toSorted((a, b) =>
a.organizationName.localeCompare(b.organizationName)
),
crmRoleAssignments: crmRoleAssignments.toSorted((a, b) => {
if (a.isPrimary !== b.isPrimary) {
return a.isPrimary ? -1 : 1;
}
return a.roleProfileName.localeCompare(b.roleProfileName);
}),
crmAccessSummaries: crmAccessSummaries.toSorted((a, b) =>
a.organizationName.localeCompare(b.organizationName)
),
primaryCrmRole: primaryAccess?.primaryCrmRoleLabel ?? primaryAccess?.primaryCrmRole ?? null,
crmRoleCount: crmRoleAssignments.filter((assignment) => assignment.isActive).length,
branchScopeSummary: primaryAccess
? summarizeScope(primaryAccess.branchScopeMode, primaryAccess.branchScopeIds)
: 'None',
productScopeSummary: primaryAccess
? summarizeScope(primaryAccess.productScopeMode, primaryAccess.productTypeScopeIds)
: 'None',
hasLegacyBusinessRoleFallback: crmAccessSummaries.some(
(summary) => summary.usedLegacyFallback
)
} satisfies User;
});
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { crmRoleProfiles, memberships, msOptions } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { requireUserManagementAccess } from '../_lib';
export async function GET(request: NextRequest) {
try {
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const requestedOrganizationIds = request.nextUrl.searchParams
.get('organizationIds')
?.split(',')
.map((value) => value.trim())
.filter(Boolean) ?? [];
const organizationIds = requestedOrganizationIds.length
? requestedOrganizationIds
: manageableOrganizationIds;
if (
!isSuperAdmin &&
organizationIds.some((organizationId) => !manageableOrganizationIds.includes(organizationId))
) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const [roleProfiles, branches, productTypes] = await Promise.all([
db
.select({
organizationId: crmRoleProfiles.organizationId,
id: crmRoleProfiles.id,
code: crmRoleProfiles.code,
name: crmRoleProfiles.name
})
.from(crmRoleProfiles)
.where(
and(
inArray(crmRoleProfiles.organizationId, organizationIds),
eq(crmRoleProfiles.isActive, true),
isNull(crmRoleProfiles.deletedAt)
)
)
.orderBy(asc(crmRoleProfiles.name)),
db
.select({
organizationId: msOptions.organizationId,
id: msOptions.id,
code: msOptions.code,
label: msOptions.label
})
.from(msOptions)
.where(
and(
inArray(msOptions.organizationId, organizationIds),
eq(msOptions.category, 'crm_branch'),
eq(msOptions.isActive, true),
isNull(msOptions.deletedAt)
)
)
.orderBy(asc(msOptions.label)),
db
.select({
organizationId: msOptions.organizationId,
id: msOptions.id,
code: msOptions.code,
label: msOptions.label
})
.from(msOptions)
.where(
and(
inArray(msOptions.organizationId, organizationIds),
eq(msOptions.category, 'crm_product_type'),
eq(msOptions.isActive, true),
isNull(msOptions.deletedAt)
)
)
.orderBy(asc(msOptions.label))
]);
const membershipsByOrganization = await db
.select({
organizationId: memberships.organizationId,
userId: memberships.userId
})
.from(memberships)
.where(inArray(memberships.organizationId, organizationIds));
return NextResponse.json({
success: true,
roleProfiles,
branches,
productTypes,
organizationUserIds: membershipsByOrganization
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json(
{ message: 'Unable to load CRM role assignment reference data' },
{ status: 500 }
);
}
}

View File

@@ -3,100 +3,17 @@ import { asc, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { db } from '@/lib/db';
import { isBusinessRole } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import {
buildMembershipValues,
buildUserResponseModels,
parseUserMutationPayload,
requireUserManagementAccess,
syncCrmRoleAssignmentsForUser,
validateManagedOrganizations
} from './_lib';
type UserRow = {
userId: string;
name: string;
email: string;
systemRole: string;
activeOrganizationId: string | null;
organizationId: string;
organizationName: string;
membershipRole: string;
businessRole: string;
};
function formatUsers(rows: UserRow[]) {
const usersMap = new Map<
string,
{
id: string;
name: string;
email: string;
systemRole: 'super_admin' | 'user';
activeMembershipRole: 'admin' | 'user' | null;
activeOrganizationId: string | null;
organizations: Array<{ id: string; name: string; role: 'admin' | 'user' }>;
memberships: Array<{
organizationId: string;
organizationName: string;
membershipRole: 'admin' | 'user';
businessRole: string;
}>;
}
>();
for (const row of rows) {
const existing = usersMap.get(row.userId);
const organization = {
id: row.organizationId,
name: row.organizationName,
role: row.membershipRole === 'admin' ? 'admin' : 'user'
} as const;
const membership = {
organizationId: row.organizationId,
organizationName: row.organizationName,
membershipRole: organization.role,
businessRole: isBusinessRole(row.businessRole) ? row.businessRole : 'sales_support'
} as const;
if (existing) {
if (!existing.organizations.some((item) => item.id === organization.id)) {
existing.organizations.push(organization);
}
if (!existing.memberships.some((item) => item.organizationId === membership.organizationId)) {
existing.memberships.push(membership);
}
if (row.organizationId === existing.activeOrganizationId) {
existing.activeMembershipRole = organization.role;
}
continue;
}
usersMap.set(row.userId, {
id: row.userId,
name: row.name,
email: row.email,
systemRole: row.systemRole === 'super_admin' ? 'super_admin' : 'user',
activeMembershipRole:
row.organizationId === row.activeOrganizationId ? organization.role : null,
activeOrganizationId: row.activeOrganizationId,
organizations: [organization],
memberships: [membership]
});
}
return [...usersMap.values()].map((user) => ({
...user,
organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
memberships: user.memberships.toSorted((a, b) =>
a.organizationName.localeCompare(b.organizationName)
)
}));
}
function applyRoleFilter(usersList: ReturnType<typeof formatUsers>, roles?: string) {
function applyRoleFilter(usersList: Awaited<ReturnType<typeof buildUserResponseModels>>, roles?: string) {
if (!roles) {
return usersList;
}
@@ -113,7 +30,7 @@ function applyRoleFilter(usersList: ReturnType<typeof formatUsers>, roles?: stri
);
}
function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
function sortUsers(usersList: Awaited<ReturnType<typeof buildUserResponseModels>>, sort?: string) {
const defaultSort = usersList.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
@@ -132,12 +49,28 @@ function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
const left =
primarySort.id === 'role'
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
: primarySort.id === 'primaryCrmRole'
? a.primaryCrmRole ?? ''
: primarySort.id === 'crmRoleCount'
? a.crmRoleCount
: primarySort.id === 'branchScopeSummary'
? a.branchScopeSummary
: primarySort.id === 'productScopeSummary'
? a.productScopeSummary
: primarySort.id === 'organizations'
? a.memberships.map((membership) => membership.organizationName).join(', ')
: (a[primarySort.id as 'name' | 'email'] ?? a.name);
const right =
primarySort.id === 'role'
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
: primarySort.id === 'primaryCrmRole'
? b.primaryCrmRole ?? ''
: primarySort.id === 'crmRoleCount'
? b.crmRoleCount
: primarySort.id === 'branchScopeSummary'
? b.branchScopeSummary
: primarySort.id === 'productScopeSummary'
? b.productScopeSummary
: primarySort.id === 'organizations'
? b.memberships.map((membership) => membership.organizationName).join(', ')
: (b[primarySort.id as 'name' | 'email'] ?? b.name);
@@ -161,36 +94,18 @@ export async function GET(request: NextRequest) {
const search = searchParams.get('search') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const rows = await db
.select({
userId: users.id,
name: users.name,
email: users.email,
systemRole: users.systemRole,
activeOrganizationId: users.activeOrganizationId,
organizationId: memberships.organizationId,
organizationName: organizations.name,
membershipRole: memberships.role,
businessRole: memberships.businessRole
})
.from(users)
.innerJoin(memberships, eq(memberships.userId, users.id))
.innerJoin(organizations, eq(organizations.id, memberships.organizationId))
.orderBy(asc(users.name));
const scopedRows = isSuperAdmin
? rows
: rows.filter((row) => manageableOrganizationIds.includes(row.organizationId));
const usersList = await buildUserResponseModels({
...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds })
});
const searchTerm = search?.trim().toLowerCase();
const searchedRows = searchTerm
? scopedRows.filter(
const searchedUsers = searchTerm
? usersList.filter(
(row) =>
row.name.toLowerCase().includes(searchTerm) ||
row.email.toLowerCase().includes(searchTerm)
)
: scopedRows;
const formattedUsers = formatUsers(searchedRows);
const filteredUsers = applyRoleFilter(formattedUsers, roles);
: usersList;
const filteredUsers = applyRoleFilter(searchedUsers, roles);
const sortedUsers = sortUsers(filteredUsers, sort);
const offset = (page - 1) * limit;
const pagedUsers = sortedUsers.slice(offset, offset + limit);
@@ -215,7 +130,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const payload = parseUserMutationPayload(await request.json());
if (!payload.password || payload.password.length < 8) {
@@ -234,6 +149,7 @@ export async function POST(request: NextRequest) {
await validateManagedOrganizations(
payload.memberships,
payload.crmRoleAssignments,
manageableOrganizationIds,
isSuperAdmin
);
@@ -261,7 +177,19 @@ export async function POST(request: NextRequest) {
activeOrganizationId: payload.memberships[0]?.organizationId ?? null
});
await db.insert(memberships).values(buildMembershipValues(userId, payload.memberships));
const membershipValues = await buildMembershipValues(
userId,
payload.memberships,
payload.crmRoleAssignments
);
await db.insert(memberships).values(membershipValues);
await syncCrmRoleAssignmentsForUser(
userId,
session.user.id,
payload.memberships,
payload.crmRoleAssignments
);
return NextResponse.json(
{

View File

@@ -11,7 +11,7 @@ export default async function CrmUserRoleAssignmentsRoute() {
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
session.user.activePermissions.includes(PERMISSIONS.crmRoleAssignmentRead));
session.user.activePermissions.includes(PERMISSIONS.crmUserRoleAssignmentRead));
const queryClient = getQueryClient();
if (canRead) {

View File

@@ -136,7 +136,7 @@ export const navGroups: NavGroup[] = [
url: "/dashboard/crm/settings/master-options",
icon: "settings",
isActive: false,
access: { requireOrg: true, permission: "crm.role.assignment.read" },
access: { requireOrg: true, permission: "crm.user_role_assignment.read" },
items: [
{
title: "Roles & Permissions",
@@ -148,7 +148,7 @@ export const navGroups: NavGroup[] = [
url: "/dashboard/crm/settings/user-role-assignments",
access: {
requireOrg: true,
permission: "crm.role.assignment.read",
permission: "crm.user_role_assignment.read",
},
},
{

View File

@@ -1,4 +1,10 @@
import type { BusinessRole } from '@/lib/auth/rbac';
import type {
CrmApprovalAuthority,
CrmEffectiveScopeMode,
CrmOwnershipScope,
CrmRoleAssignmentScopeMode,
Permission
} from '@/lib/auth/rbac';
export interface UserOrganization {
id: string;
@@ -10,7 +16,38 @@ export interface UserMembership {
organizationId: string;
organizationName: string;
membershipRole: 'admin' | 'user';
businessRole: BusinessRole;
businessRole: string;
isLegacyBusinessRoleDeprecated?: boolean;
}
export interface UserCrmRoleAssignment {
id: string;
organizationId: string;
roleProfileId: string;
roleProfileCode: string;
roleProfileName: string;
branchScopeMode: CrmRoleAssignmentScopeMode;
branchScopeIds: string[];
productTypeScopeMode: CrmRoleAssignmentScopeMode;
productTypeScopeIds: string[];
isPrimary: boolean;
isActive: boolean;
}
export interface UserCrmAccessSummary {
organizationId: string;
organizationName: string;
primaryCrmRole: string | null;
primaryCrmRoleLabel: string | null;
roleCount: number;
permissions: Permission[];
ownershipScope: CrmOwnershipScope;
branchScopeMode: CrmEffectiveScopeMode;
branchScopeIds: string[];
productScopeMode: CrmEffectiveScopeMode;
productTypeScopeIds: string[];
approvalAuthority: CrmApprovalAuthority;
usedLegacyFallback: boolean;
}
export interface User {
@@ -22,6 +59,13 @@ export interface User {
activeOrganizationId: string | null;
organizations: UserOrganization[];
memberships: UserMembership[];
crmRoleAssignments?: UserCrmRoleAssignment[];
crmAccessSummaries?: UserCrmAccessSummary[];
primaryCrmRole?: string | null;
crmRoleCount?: number;
branchScopeSummary?: string;
productScopeSummary?: string;
hasLegacyBusinessRoleFallback?: boolean;
}
export type UserFilters = {
@@ -50,6 +94,16 @@ export type UserMutationPayload = {
memberships: Array<{
organizationId: string;
membershipRole: 'admin' | 'user';
businessRole: BusinessRole;
}>;
crmRoleAssignments: Array<{
id?: string;
organizationId: string;
roleProfileId: string;
branchScopeMode: CrmRoleAssignmentScopeMode;
branchScopeIds: string[];
productTypeScopeMode: CrmRoleAssignmentScopeMode;
productTypeScopeIds: string[];
isPrimary: boolean;
isActive: boolean;
}>;
};

View File

@@ -25,7 +25,10 @@ import { createUserMutation, updateUserMutation } from '../api/mutations';
import type { User } from '../api/types';
import { toast } from 'sonner';
import { type UserFormValues, userSchema } from '../schemas/user';
import type { BusinessRole } from '@/lib/auth/rbac';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface UserFormSheetProps {
user?: User;
@@ -37,29 +40,50 @@ type OrganizationOption = {
id: string;
name: string;
role: string;
businessRole?: string;
canManageUsers: boolean;
};
type AssignmentReferenceResponse = {
success?: boolean;
roleProfiles?: Array<{ organizationId: string; id: string; code: string; name: string }>;
branches?: Array<{ organizationId: string; id: string; code: string; label: string }>;
productTypes?: Array<{ organizationId: string; id: string; code: string; label: string }>;
};
const membershipRoleOptions = [
{ value: 'admin', label: 'Organization Admin' },
{ value: 'user', label: 'User' }
] as const;
const businessRoleOptions = [
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales_manager', label: 'Sales Manager' },
{ value: 'sales', label: 'Sales' },
{ value: 'sales_support', label: 'Sales Support' },
{ value: 'department_manager', label: 'Department Manager' },
{ value: 'top_manager', label: 'Top Manager' },
{ value: 'crm_admin', label: 'CRM Admin' }
const scopeModeOptions = [
{ value: 'inherit', label: 'Inherit' },
{ value: 'all', label: 'All' },
{ value: 'selected', label: 'Selected' },
{ value: 'none', label: 'None' }
] as const;
function scopeSummary(mode: string, ids: string[]) {
if (mode === 'all') return 'All';
if (mode === 'inherit') return 'Inherit';
if (mode === 'none' || ids.length === 0) return 'None';
return ids.join(', ');
}
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
const isEdit = !!user;
const [organizations, setOrganizations] = useState<OrganizationOption[]>([]);
const [selectedOrganizationIds, setSelectedOrganizationIds] = useState<string[]>(
user?.memberships.map((membership) => membership.organizationId) ?? []
);
const [assignmentReference, setAssignmentReference] = useState<AssignmentReferenceResponse>({
roleProfiles: [],
branches: [],
productTypes: []
});
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [isLoadingAssignments, setIsLoadingAssignments] = useState(false);
const [previewUser, setPreviewUser] = useState<User | null>(null);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
const createMutation = useMutation({
...createUserMutation,
@@ -67,6 +91,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
toast.success('User created successfully');
onOpenChange(false);
form.reset();
setPreviewUser(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create user')
@@ -86,19 +111,35 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
() =>
user?.memberships.map((membership) => ({
organizationId: membership.organizationId,
membershipRole: membership.membershipRole,
businessRole: membership.businessRole
membershipRole: membership.membershipRole
})) ?? [],
[user?.memberships]
);
const initialAssignments = useMemo(
() =>
(user?.crmRoleAssignments ?? []).map((assignment) => ({
id: assignment.id,
organizationId: assignment.organizationId,
roleProfileId: assignment.roleProfileId,
branchScopeMode: assignment.branchScopeMode,
branchScopeIds: assignment.branchScopeIds,
productTypeScopeMode: assignment.productTypeScopeMode,
productTypeScopeIds: assignment.productTypeScopeIds,
isPrimary: assignment.isPrimary,
isActive: assignment.isActive
})),
[user?.crmRoleAssignments]
);
const form = useAppForm({
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
password: '',
systemRole: user?.systemRole ?? 'user',
memberships: initialMemberships
memberships: initialMemberships,
crmRoleAssignments: initialAssignments
} as UserFormValues,
validators: {
onSubmit: userSchema
@@ -122,6 +163,32 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
const { FormTextField } = useFormFields<UserFormValues>();
useEffect(() => {
if (!open) {
setPreviewUser(null);
return;
}
setSelectedOrganizationIds(initialMemberships.map((membership) => membership.organizationId));
setPreviewUser(null);
form.reset({
name: user?.name ?? '',
email: user?.email ?? '',
password: '',
systemRole: user?.systemRole ?? 'user',
memberships: initialMemberships,
crmRoleAssignments: initialAssignments
});
}, [
form,
initialAssignments,
initialMemberships,
open,
user?.email,
user?.name,
user?.systemRole
]);
useEffect(() => {
if (!open) {
return;
@@ -164,187 +231,652 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
};
}, [open]);
useEffect(() => {
if (!open || selectedOrganizationIds.length === 0) {
setAssignmentReference({ roleProfiles: [], branches: [], productTypes: [] });
return;
}
let cancelled = false;
async function loadAssignmentReference() {
setIsLoadingAssignments(true);
try {
const response = await fetch(
`/api/users/crm-role-assignment-reference?organizationIds=${selectedOrganizationIds.join(',')}`
);
const data = (await response.json()) as AssignmentReferenceResponse & { message?: string };
if (!response.ok) {
throw new Error(data.message ?? 'Unable to load CRM role assignment reference data');
}
if (!cancelled) {
setAssignmentReference(data);
}
} catch (error) {
if (!cancelled) {
toast.error(
error instanceof Error
? error.message
: 'Unable to load CRM role assignment reference data'
);
}
} finally {
if (!cancelled) {
setIsLoadingAssignments(false);
}
}
}
void loadAssignmentReference();
return () => {
cancelled = true;
};
}, [open, selectedOrganizationIds]);
const roleProfilesByOrganization = useMemo(() => {
const map = new Map<string, Array<{ id: string; code: string; name: string }>>();
for (const roleProfile of assignmentReference.roleProfiles ?? []) {
const current = map.get(roleProfile.organizationId) ?? [];
current.push({ id: roleProfile.id, code: roleProfile.code, name: roleProfile.name });
map.set(roleProfile.organizationId, current);
}
return map;
}, [assignmentReference.roleProfiles]);
const branchesByOrganization = useMemo(() => {
const map = new Map<string, Array<{ id: string; code: string; label: string }>>();
for (const branch of assignmentReference.branches ?? []) {
const current = map.get(branch.organizationId) ?? [];
current.push({ id: branch.id, code: branch.code, label: branch.label });
map.set(branch.organizationId, current);
}
return map;
}, [assignmentReference.branches]);
const productTypesByOrganization = useMemo(() => {
const map = new Map<string, Array<{ id: string; code: string; label: string }>>();
for (const productType of assignmentReference.productTypes ?? []) {
const current = map.get(productType.organizationId) ?? [];
current.push({ id: productType.id, code: productType.code, label: productType.label });
map.set(productType.organizationId, current);
}
return map;
}, [assignmentReference.productTypes]);
async function handlePreviewEffectiveAccess() {
if (!user) {
return;
}
setIsLoadingPreview(true);
try {
const response = await fetch(`/api/users/${user.id}/effective-access-preview`);
const data = (await response.json()) as { user?: User; message?: string };
if (!response.ok || !data.user) {
throw new Error(data.message ?? 'Unable to preview effective access');
}
setPreviewUser(data.user);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to preview effective access');
} finally {
setIsLoadingPreview(false);
}
}
const effectivePreviewSource = previewUser ?? user ?? null;
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-2xl'>
<SheetContent className='flex flex-col sm:max-w-5xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
<SheetDescription>
{isEdit
? 'Update the user details and workspace memberships below.'
: 'Create a user account and assign workspace-specific roles.'}
? 'Update membership, CRM role assignments, and effective access behavior.'
: 'Create a user with organization memberships and optional CRM role assignments.'}
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<div className='flex-1 overflow-auto pr-1'>
<form.AppForm>
<form.Form id='user-form-sheet' className='space-y-4'>
<FormTextField name='name' label='Full Name' required placeholder='Jane Doe' />
<form.Form id='user-form-sheet' className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FormTextField name='name' label='Full Name' required placeholder='Jane Doe' />
<FormTextField
name='email'
label='Email'
required
type='email'
placeholder='jane@example.com'
/>
</div>
<FormTextField
name='email'
label='Email'
required
type='email'
placeholder='jane@example.com'
/>
<FormTextField
name='password'
label={isEdit ? 'Password (optional)' : 'Password'}
required={!isEdit}
type='password'
placeholder={
isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'
}
/>
{isEdit && user?.systemRole === 'super_admin' && (
<div className='rounded-lg border border-dashed p-3 text-sm'>
<span className='font-medium'>System Role:</span> Super Admin
</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>
)}
<div className='grid gap-4 md:grid-cols-2'>
<FormTextField
name='password'
label={isEdit ? 'Password (optional)' : 'Password'}
required={!isEdit}
type='password'
placeholder={
isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'
}
/>
<div className='rounded-lg border border-dashed p-3 text-sm'>
<span className='font-medium'>System Role:</span>{' '}
{isEdit && user?.systemRole === 'super_admin' ? 'Super Admin' : 'User'}
</div>
</div>
</CardContent>
</Card>
<form.AppField
name='memberships'
children={(field) => (
<field.FieldSet>
<field.Field>
<div className='text-sm font-medium'>Workspace Memberships *</div>
<div className='text-muted-foreground text-xs'>
One user can have different roles in different workspaces.
</div>
<div className='space-y-2 rounded-lg border p-3'>
{isLoadingOrganizations ? (
<div className='text-muted-foreground text-sm'>Loading workspaces...</div>
) : organizations.length ? (
organizations.map((organization) => {
const membership = field.state.value.find(
(value) => value.organizationId === organization.id
);
const checked = !!membership;
<Card>
<CardHeader>
<CardTitle>Workspace Memberships</CardTitle>
<CardDescription>
Membership grants organization access. CRM authorization now comes from role
assignments instead of `membership.businessRole`.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{isLoadingOrganizations ? (
<div className='text-muted-foreground text-sm'>Loading workspaces...</div>
) : organizations.length ? (
organizations.map((organization) => {
const membership = field.state.value.find(
(value) => value.organizationId === organization.id
);
const checked = !!membership;
return (
<div
key={organization.id}
className='grid gap-3 rounded-md border p-3 md:grid-cols-[1fr_180px_180px]'
>
<div className='flex items-start gap-3'>
<Checkbox
aria-label={`Select workspace ${organization.name}`}
checked={checked}
onCheckedChange={(value) => {
if (!value) {
field.handleChange(
field.state.value.filter(
(item) => item.organizationId !== organization.id
)
);
field.handleBlur();
return;
}
field.handleChange([
...field.state.value,
{
organizationId: organization.id,
membershipRole: 'user',
businessRole: 'sales_support'
}
]);
return (
<div
key={organization.id}
className='grid gap-3 rounded-md border p-3 md:grid-cols-[1fr_220px]'
>
<div className='flex items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(value) => {
if (!value) {
const nextMemberships = field.state.value.filter(
(item) => item.organizationId !== organization.id
);
field.handleChange(nextMemberships);
field.handleBlur();
}}
/>
<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>
setSelectedOrganizationIds(
nextMemberships.map((item) => item.organizationId)
);
form.setFieldValue(
'crmRoleAssignments',
form.state.values.crmRoleAssignments.filter(
(item) => item.organizationId !== organization.id
)
);
return;
}
const nextMemberships = [
...field.state.value,
{
organizationId: organization.id,
membershipRole: 'user' as const
}
];
field.handleChange(nextMemberships);
field.handleBlur();
setSelectedOrganizationIds(
nextMemberships.map((item) => item.organizationId)
);
}}
/>
<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>
<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 className='text-muted-foreground text-sm'>
No manageable workspaces available.
</div>
)}
</div>
</field.Field>
<field.FieldError />
</field.FieldSet>
<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>
<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.AppForm>
</div>
@@ -357,9 +889,10 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
type='submit'
form='user-form-sheet'
isLoading={isPending}
disabled={isLoadingOrganizations || organizations.length === 0}
disabled={isLoadingOrganizations}
>
<Icons.check /> {isEdit ? 'Update User' : 'Create User'}
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update User' : 'Create User'}
</Button>
</SheetFooter>
</SheetContent>

View File

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

View File

@@ -1,4 +1,5 @@
import * as z from 'zod';
import { ROLE_ASSIGNMENT_SCOPE_MODES } from '@/lib/auth/rbac';
export const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
@@ -8,11 +9,23 @@ export const userSchema = z.object({
.array(
z.object({
organizationId: z.string().min(1),
membershipRole: z.enum(['admin', 'user']),
businessRole: z.string().min(1)
membershipRole: z.enum(['admin', 'user'])
})
)
.min(1, 'Select at least one organization')
.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>;

View File

@@ -99,6 +99,11 @@ export const PERMISSIONS = {
crmRoleAssignmentUpdate: 'crm.role.assignment.update',
crmRoleAssignmentDelete: 'crm.role.assignment.delete',
crmRoleAssignmentManage: 'crm.role.assignment.manage',
crmUserRoleAssignmentRead: 'crm.user_role_assignment.read',
crmUserRoleAssignmentCreate: 'crm.user_role_assignment.create',
crmUserRoleAssignmentUpdate: 'crm.user_role_assignment.update',
crmUserRoleAssignmentDelete: 'crm.user_role_assignment.delete',
crmUserRoleAssignmentManage: 'crm.user_role_assignment.manage',
crmMasterOptionRead: 'crm.master_option.read',
crmMasterOptionCreate: 'crm.master_option.create',
crmMasterOptionUpdate: 'crm.master_option.update',
@@ -258,6 +263,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -290,6 +296,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -322,6 +329,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -349,6 +357,11 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmRoleAssignmentUpdate,
PERMISSIONS.crmRoleAssignmentDelete,
PERMISSIONS.crmRoleAssignmentManage,
PERMISSIONS.crmUserRoleAssignmentRead,
PERMISSIONS.crmUserRoleAssignmentCreate,
PERMISSIONS.crmUserRoleAssignmentUpdate,
PERMISSIONS.crmUserRoleAssignmentDelete,
PERMISSIONS.crmUserRoleAssignmentManage,
PERMISSIONS.crmMasterOptionRead,
PERMISSIONS.crmMasterOptionCreate,
PERMISSIONS.crmMasterOptionUpdate,
@@ -459,6 +472,11 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmRoleAssignmentUpdate, label: 'Update user role assignments' },
{ key: PERMISSIONS.crmRoleAssignmentDelete, label: 'Delete user role assignments' },
{ key: PERMISSIONS.crmRoleAssignmentManage, label: 'Manage user role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentRead, label: 'Read user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentCreate, label: 'Create user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentUpdate, label: 'Update user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentDelete, label: 'Delete user CRM role assignments' },
{ key: PERMISSIONS.crmUserRoleAssignmentManage, label: 'Manage user CRM role assignments' },
{ key: PERMISSIONS.crmMasterOptionRead, label: 'Read master options' },
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },