task-l.2
This commit is contained in:
@@ -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;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user