This commit is contained in:
phaichayon
2026-06-22 10:22:45 +07:00
parent 51d67ef7c2
commit b154a8de33
37 changed files with 5952 additions and 332 deletions

View File

@@ -40,7 +40,12 @@ export async function GET(request: NextRequest) {
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions
permissions: membership.permissions,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
},
{
dateFrom: searchParams.get('dateFrom'),

View File

@@ -15,7 +15,12 @@ export async function GET(request: NextRequest) {
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions
permissions: membership.permissions,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
},
{
dateFrom: searchParams.get('dateFrom'),

View File

@@ -17,13 +17,25 @@ export async function POST(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryAssign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id, {
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload);
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await assignEnquiryToUser(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({
organizationId: organization.id,

View File

@@ -13,12 +13,18 @@ export async function GET(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const items = await listEnquiryProjectParties(id, organization.id, {
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listEnquiryProjectParties(id, organization.id, accessContext);
return NextResponse.json({
success: true,

View File

@@ -26,13 +26,19 @@ export async function PATCH(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryFollowupUpdate
});
const payload = followupRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
})
await listEnquiryFollowups(id, organization.id, accessContext)
).find(
(item) => item.id === followupId
);
@@ -47,12 +53,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
organization.id,
session.user.id,
payload,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
accessContext
);
await auditUpdate({
@@ -95,13 +96,19 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupDelete
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
})
await listEnquiryFollowups(id, organization.id, accessContext)
).find(
(item) => item.id === followupId
);
@@ -115,12 +122,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
followupId,
organization.id,
session.user.id,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
accessContext
);
await auditDelete({

View File

@@ -24,12 +24,18 @@ export async function GET(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupRead
});
const items = await listEnquiryFollowups(id, organization.id, {
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listEnquiryFollowups(id, organization.id, accessContext);
return NextResponse.json({
success: true,
@@ -57,17 +63,23 @@ export async function POST(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryFollowupCreate
});
const payload = followupRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const created = await createEnquiryFollowup(
id,
organization.id,
session.user.id,
payload,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
accessContext
);
await auditCreate({

View File

@@ -17,13 +17,25 @@ export async function POST(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryReassign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id, {
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload);
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await reassignEnquiryToUser(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({
organizationId: organization.id,

View File

@@ -32,7 +32,12 @@ export async function GET(_request: NextRequest, { params }: Params) {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const [enquiry, activity] = await Promise.all([
getEnquiryDetail(id, organization.id, accessContext),
@@ -66,18 +71,19 @@ export async function PATCH(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryUpdate
});
const payload = enquiryRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id, {
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, accessContext);
await auditUpdate({
organizationId: organization.id,
@@ -120,18 +126,19 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryDelete
});
const before = await getEnquiryDetail(id, organization.id, {
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, accessContext);
await auditDelete({
organizationId: organization.id,

View File

@@ -29,13 +29,19 @@ export async function GET(request: NextRequest) {
const branch = searchParams.get('branch') ?? undefined;
const customer = searchParams.get('customer') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const result = await listEnquiries(
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
},
accessContext,
{
page,
limit,
@@ -79,10 +85,21 @@ export async function POST(request: NextRequest) {
permission: PERMISSIONS.crmEnquiryCreate
});
const payload = enquiryRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const created = await createEnquiry(
organization.id,
session.user.id,
membership.businessRole,
accessContext,
payload
);

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { cloneCrmRoleProfile } from '@/features/foundation/role-management/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const cloneRoleSchema = z.object({
code: z.string().min(2),
name: z.string().min(2),
description: z.string().nullable().optional()
});
type RouteProps = {
params: Promise<{ code: string }>;
};
export async function POST(request: NextRequest, props: RouteProps) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleCreate
});
const payload = cloneRoleSchema.parse(await request.json());
const { code } = await props.params;
const role = await cloneCrmRoleProfile(organization.id, session.user.id, code, payload);
return NextResponse.json(
{
success: true,
message: 'CRM role cloned successfully',
role
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to clone CRM role' }, { status: 500 });
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { updateCrmRoleProfile } from '@/features/foundation/role-management/server/service';
import {
APPROVAL_AUTHORITIES,
ALL_PERMISSIONS,
OWNERSHIP_SCOPES,
PERMISSIONS,
SCOPE_MODES,
type Permission
} from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const updateRoleSchema = z.object({
name: z.string().min(2),
description: z.string().nullable().optional(),
permissions: z
.array(z.string())
.refine(
(values) => values.every((value) => ALL_PERMISSIONS.includes(value as Permission)),
'Invalid permission key'
)
.transform((values) => values as Permission[]),
branchScopeMode: z.enum(SCOPE_MODES),
productScopeMode: z.enum(SCOPE_MODES),
ownershipScope: z.enum(OWNERSHIP_SCOPES),
approvalAuthority: z.enum(APPROVAL_AUTHORITIES),
isActive: z.boolean()
});
type RouteProps = {
params: Promise<{ code: string }>;
};
export async function PATCH(request: NextRequest, props: RouteProps) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleUpdate
});
const payload = updateRoleSchema.parse(await request.json());
const { code } = await props.params;
const role = await updateCrmRoleProfile(organization.id, session.user.id, code, payload);
return NextResponse.json({
success: true,
message: 'CRM role updated successfully',
role
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update CRM role' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { listCrmRoleProfiles } from '@/features/foundation/role-management/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET() {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleRead
});
const result = await listCrmRoleProfiles(organization.id, session.user.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'CRM roles loaded successfully',
...result
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid request' }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load CRM roles' }, { status: 500 });
}
}

View File

@@ -45,7 +45,12 @@ export default async function LeadDetailRoute({ params }: PageProps) {
organizationId: session.user.activeOrganizationId,
userId: session.user.id,
membershipRole: session.user.activeMembershipRole ?? 'user',
businessRole: session.user.activeBusinessRole ?? 'sales_support'
businessRole: session.user.activeBusinessRole ?? 'sales_support',
branchScopeIds: session.user.activeBranchScopeIds ?? [],
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
branchScopeMode: 'all',
productScopeMode: 'all'
})
: null;

View File

@@ -16,7 +16,7 @@ export default async function MasterOptionsRoute(props: PageProps) {
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.organizationManage)));
session.user.activePermissions.includes(PERMISSIONS.crmMasterOptionRead)));
searchParamsCache.parse(searchParams);

View File

@@ -0,0 +1,39 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { RoleManagementPage } from '@/features/foundation/role-management/components/role-management-page';
import { crmRoleProfilesQueryOptions } from '@/features/foundation/role-management/api/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
export default async function CrmRolesRoute() {
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
session.user.activePermissions.includes(PERMISSIONS.crmRoleRead));
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(crmRoleProfilesQueryOptions());
}
return (
<PageContainer
pageTitle='Roles & Permissions'
pageDescription='Configure CRM role profiles, permission matrices, ownership visibility, and approval authority.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM role management.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<RoleManagementPage />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -6,10 +6,9 @@ import { z } from 'zod';
import { memberships, organizations, users } from '@/db/schema';
import {
getDefaultBusinessRole,
getDefaultPermissions,
isBusinessRole,
isSystemRole
} from '@/lib/auth/rbac';
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { db } from '@/lib/db';
const signInSchema = z.object({
@@ -28,6 +27,9 @@ type SessionOrganization = {
slug: string;
role: string;
businessRole: string;
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
plan: string;
imageUrl: string | null;
};
@@ -47,6 +49,8 @@ async function buildUserAccessContext(userId: string) {
role: memberships.role,
businessRole: memberships.businessRole,
permissions: memberships.permissions,
branchScopeIds: memberships.branchScopeIds,
productTypeScopeIds: memberships.productTypeScopeIds,
name: organizations.name,
slug: organizations.slug,
plan: organizations.plan,
@@ -67,18 +71,10 @@ async function buildUserAccessContext(userId: string) {
return {
organizationId: organization.id,
role: membership?.role ?? 'admin',
businessRole:
membership?.businessRole && isBusinessRole(membership.businessRole)
? membership.businessRole
: getDefaultBusinessRole('admin'),
permissions:
membership?.permissions ??
getDefaultPermissions(
membership?.role === 'admin' ? 'admin' : 'user',
membership?.businessRole && isBusinessRole(membership.businessRole)
? membership.businessRole
: getDefaultBusinessRole('admin')
),
businessRole: membership?.businessRole ?? getDefaultBusinessRole('admin'),
permissions: membership?.permissions ?? [],
branchScopeIds: membership?.branchScopeIds ?? [],
productTypeScopeIds: membership?.productTypeScopeIds ?? [],
name: organization.name,
slug: organization.slug,
plan: organization.plan,
@@ -97,20 +93,64 @@ async function buildUserAccessContext(userId: string) {
.where(eq(users.id, dbUser.id));
}
const sessionOrganizations: SessionOrganization[] = rows.map((row) => ({
id: row.organizationId,
name: row.name,
slug: row.slug,
role: row.role,
businessRole: row.businessRole,
plan: row.plan,
imageUrl: row.imageUrl
}));
const sessionOrganizations: SessionOrganization[] = [];
for (const row of rows) {
const resolvedAccess = await resolveCrmMembershipAccess({
systemRole,
organizationId: row.organizationId,
membership: {
id: '',
userId: dbUser.id,
organizationId: row.organizationId,
role: row.role,
businessRole: row.businessRole,
permissions: row.permissions,
branchScopeIds: row.branchScopeIds ?? [],
productTypeScopeIds: row.productTypeScopeIds ?? [],
createdAt: new Date(),
updatedAt: new Date()
}
});
sessionOrganizations.push({
id: row.organizationId,
name: row.name,
slug: row.slug,
role: row.role,
businessRole: row.businessRole,
branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
ownershipScope: resolvedAccess.ownershipScope,
plan: row.plan,
imageUrl: row.imageUrl
});
}
const activeMembershipAccess = activeMembership
? await resolveCrmMembershipAccess({
systemRole,
organizationId: activeMembership.organizationId,
membership: {
id: '',
userId: dbUser.id,
organizationId: activeMembership.organizationId,
role: activeMembership.role,
businessRole: activeMembership.businessRole,
permissions: activeMembership.permissions,
branchScopeIds: activeMembership.branchScopeIds ?? [],
productTypeScopeIds: activeMembership.productTypeScopeIds ?? [],
createdAt: new Date(),
updatedAt: new Date()
}
})
: null;
return {
dbUser,
sessionOrganizations,
activeMembership
activeMembership,
activeMembershipAccess
};
}
@@ -177,7 +217,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return token;
}
const { dbUser, sessionOrganizations, activeMembership } = accessContext;
const { dbUser, sessionOrganizations, activeMembership, activeMembershipAccess } = accessContext;
const systemRole = isSystemRole(dbUser.systemRole) ? dbUser.systemRole : 'user';
token.name = dbUser.name;
@@ -189,7 +229,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.activeOrganizationPlan = activeMembership?.plan ?? null;
token.activeMembershipRole = activeMembership?.role ?? null;
token.activeBusinessRole = activeMembership?.businessRole ?? null;
token.activePermissions = activeMembership?.permissions ?? [];
token.activePermissions = activeMembershipAccess?.permissions ?? [];
token.activeBranchScopeIds = activeMembershipAccess?.branchScopeIds ?? [];
token.activeProductTypeScopeIds = activeMembershipAccess?.productTypeScopeIds ?? [];
token.activeOwnershipScope = activeMembershipAccess?.ownershipScope ?? null;
token.organizations = sessionOrganizations;
return token;
@@ -217,6 +260,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session.user.activePermissions = Array.isArray(token.activePermissions)
? token.activePermissions.filter((value): value is string => typeof value === 'string')
: [];
session.user.activeBranchScopeIds = Array.isArray(token.activeBranchScopeIds)
? token.activeBranchScopeIds.filter((value): value is string => typeof value === 'string')
: [];
session.user.activeProductTypeScopeIds = Array.isArray(token.activeProductTypeScopeIds)
? token.activeProductTypeScopeIds.filter((value): value is string => typeof value === 'string')
: [];
session.user.activeOwnershipScope =
typeof token.activeOwnershipScope === 'string' ? token.activeOwnershipScope : null;
session.user.organizations = Array.isArray(token.organizations)
? token.organizations.filter(
(value): value is SessionOrganization =>
@@ -227,6 +278,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
typeof value.slug === 'string' &&
typeof value.role === 'string' &&
typeof value.businessRole === 'string' &&
Array.isArray(value.branchScopeIds) &&
Array.isArray(value.productTypeScopeIds) &&
typeof value.ownershipScope === 'string' &&
typeof value.plan === 'string'
)
: [];

View File

@@ -59,7 +59,7 @@ export const navGroups: NavGroup[] = [
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, role: "admin" },
access: { requireOrg: true, permission: "crm.quotation.read" },
},
{
title: "Users",
@@ -136,12 +136,17 @@ export const navGroups: NavGroup[] = [
url: "/dashboard/crm/settings/master-options",
icon: "settings",
isActive: false,
access: { requireOrg: true, permission: "crm.document_template.read" },
access: { requireOrg: true, permission: "crm.role.read" },
items: [
{
title: "Roles & Permissions",
url: "/dashboard/crm/settings/roles",
access: { requireOrg: true, permission: "crm.role.read" },
},
{
title: "Master Options",
url: "/dashboard/crm/settings/master-options",
access: { requireOrg: true, permission: "organization:manage" },
access: { requireOrg: true, permission: "crm.master_option.read" },
},
{
title: "Document Sequences",

View File

@@ -51,10 +51,41 @@ export const memberships = pgTable('memberships', {
role: text('role').default('user').notNull(),
businessRole: text('business_role').default('sales_support').notNull(),
permissions: text('permissions').array().default([]).notNull(),
branchScopeIds: text('branch_scope_ids').array().default([]).notNull(),
productTypeScopeIds: text('product_type_scope_ids').array().default([]).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const crmRoleProfiles = pgTable(
'crm_role_profiles',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
code: text('code').notNull(),
name: text('name').notNull(),
description: text('description'),
permissions: text('permissions').array().default([]).notNull(),
branchScopeMode: text('branch_scope_mode').default('assigned').notNull(),
productScopeMode: text('product_scope_mode').default('assigned').notNull(),
ownershipScope: text('ownership_scope').default('own').notNull(),
approvalAuthority: text('approval_authority').default('none').notNull(),
isSystem: boolean('is_system').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull()
},
(table) => ({
organizationRoleCodeIdx: uniqueIndex('crm_role_profiles_org_code_idx').on(
table.organizationId,
table.code
)
})
);
export const products = pgTable('products', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
organizationId: text('organization_id').notNull(),

View File

@@ -22,6 +22,7 @@ import {
import { db } from '@/lib/db';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
@@ -57,6 +58,11 @@ type DashboardContext = {
membershipRole: string;
businessRole: string;
permissions: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
};
type NormalizedFilters = {
@@ -73,15 +79,6 @@ type StatusMaps = {
quotationStatusById: Map<string, string>;
};
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
const now = new Date();
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
@@ -128,20 +125,26 @@ function canViewApprovalData(context: DashboardContext) {
}
function hasDashboardFullScope(context: DashboardContext) {
return (
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
);
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}
function canAccessDashboardEnquiry(
row: typeof crmEnquiries.$inferSelect,
context: DashboardContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.productType)) {
return false;
}
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
if (context.ownershipScope === 'own') {
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
}
@@ -152,11 +155,19 @@ function canAccessDashboardQuotation(
row: typeof crmQuotations.$inferSelect,
context: DashboardContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.quotationType)) {
return false;
}
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
if (context.ownershipScope === 'own') {
return row.salesmanId === context.userId;
}

View File

@@ -11,6 +11,7 @@ import {
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
@@ -50,13 +51,6 @@ const ENQUIRY_OPTION_CATEGORIES = {
} as const;
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const SALES_CREATED_ENQUIRY_ROLES = new Set([
'sales',
'sales_support',
@@ -73,6 +67,11 @@ export interface EnquiryAccessContext {
userId: string;
membershipRole: string;
businessRole: string;
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
}
function mapOption(
@@ -141,20 +140,26 @@ function mapEnquiryRecord(
}
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
return (
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
);
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}
function canAccessEnquiryRow(
row: typeof crmEnquiries.$inferSelect,
context: EnquiryAccessContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.productType)) {
return false;
}
if (hasEnquiryFullAccess(context)) {
return true;
}
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
if (context.ownershipScope === 'own') {
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
}
@@ -162,17 +167,23 @@ function canAccessEnquiryRow(
}
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
if (hasEnquiryFullAccess(context)) {
return [];
const filters: SQL[] = [];
if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
filters.push(inArray(crmEnquiries.branchId, context.branchScopeIds));
}
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return [
if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) {
filters.push(inArray(crmEnquiries.productType, context.productTypeScopeIds));
}
if (!hasEnquiryFullAccess(context) && context.ownershipScope === 'own') {
filters.push(
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
];
);
}
return [];
return filters;
}
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
@@ -437,7 +448,11 @@ async function assertFollowupBelongsToEnquiry(
return followup;
}
async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) {
async function validateEnquiryPayload(
organizationId: string,
payload: EnquiryMutationPayload,
accessContext?: EnquiryAccessContext
) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
if (payload.contactId) {
@@ -465,6 +480,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
await validateBranchAccess(payload.branchId);
}
if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
throw new AuthError('Forbidden branch scope', 403);
}
if (accessContext && !hasProductScopeAccess(accessContext, payload.productType)) {
throw new AuthError('Forbidden product scope', 403);
}
for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue(
@@ -996,13 +1019,13 @@ export async function getEnquiryActivity(
export async function createEnquiry(
organizationId: string,
userId: string,
businessRole: string,
accessContext: EnquiryAccessContext,
payload: EnquiryMutationPayload
) {
await validateEnquiryPayload(organizationId, payload);
await validateEnquiryPayload(organizationId, payload, accessContext);
const pipelineStage = await resolvePipelineStageForCreate(
organizationId,
businessRole,
accessContext.businessRole,
payload.status
);
@@ -1064,7 +1087,7 @@ export async function updateEnquiry(
payload: EnquiryMutationPayload,
accessContext?: EnquiryAccessContext
) {
await validateEnquiryPayload(organizationId, payload);
await validateEnquiryPayload(organizationId, payload, accessContext);
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
@@ -1167,9 +1190,10 @@ async function updateEnquiryAssignment(
organizationId: string,
userId: string,
payload: EnquiryAssignmentMutationPayload,
mode: 'assign' | 'reassign'
mode: 'assign' | 'reassign',
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
if (mode === 'assign' && enquiry.assignedToUserId) {
throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400);
@@ -1202,18 +1226,20 @@ export async function assignEnquiryToUser(
id: string,
organizationId: string,
userId: string,
payload: EnquiryAssignmentMutationPayload
payload: EnquiryAssignmentMutationPayload,
accessContext?: EnquiryAccessContext
) {
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign');
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign', accessContext);
}
export async function reassignEnquiryToUser(
id: string,
organizationId: string,
userId: string,
payload: EnquiryAssignmentMutationPayload
payload: EnquiryAssignmentMutationPayload,
accessContext?: EnquiryAccessContext
) {
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign', accessContext);
}
export async function listEnquiryFollowups(

View File

@@ -0,0 +1,30 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { cloneCrmRoleProfile, updateCrmRoleProfile } from './service';
import { crmRoleQueryKeys } from './queries';
export const updateCrmRoleProfileMutation = mutationOptions({
mutationFn: ({
code,
payload
}: {
code: string;
payload: Parameters<typeof updateCrmRoleProfile>[1];
}) => updateCrmRoleProfile(code, payload),
onSettled: async () => {
await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all });
}
});
export const cloneCrmRoleProfileMutation = mutationOptions({
mutationFn: ({
sourceCode,
payload
}: {
sourceCode: string;
payload: Parameters<typeof cloneCrmRoleProfile>[1];
}) => cloneCrmRoleProfile(sourceCode, payload),
onSettled: async () => {
await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all });
}
});

View File

@@ -0,0 +1,13 @@
import { queryOptions } from '@tanstack/react-query';
import { getCrmRoleProfiles } from './service';
export const crmRoleQueryKeys = {
all: ['crm-role-profiles'] as const
};
export function crmRoleProfilesQueryOptions() {
return queryOptions({
queryKey: crmRoleQueryKeys.all,
queryFn: getCrmRoleProfiles
});
}

View File

@@ -0,0 +1,28 @@
import { apiClient } from '@/lib/api-client';
import type {
CloneCrmRoleProfilePayload,
CrmRoleProfileRecord,
CrmRoleProfilesResponse,
UpdateCrmRoleProfilePayload
} from './types';
export async function getCrmRoleProfiles() {
return apiClient<CrmRoleProfilesResponse>('/crm/settings/roles');
}
export async function updateCrmRoleProfile(code: string, payload: UpdateCrmRoleProfilePayload) {
return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>(`/crm/settings/roles/${code}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
}
export async function cloneCrmRoleProfile(sourceCode: string, payload: CloneCrmRoleProfilePayload) {
return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>(
`/crm/settings/roles/${sourceCode}/clone`,
{
method: 'POST',
body: JSON.stringify(payload)
}
);
}

View File

@@ -0,0 +1,57 @@
import type {
CrmApprovalAuthority,
CrmOwnershipScope,
CrmScopeMode,
Permission
} from '@/lib/auth/rbac';
export interface CrmRoleProfileRecord {
id: string;
organizationId: string;
code: string;
name: string;
description: string | null;
permissions: Permission[];
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
ownershipScope: CrmOwnershipScope;
approvalAuthority: CrmApprovalAuthority;
isSystem: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PermissionGroupDto {
key: string;
label: string;
permissions: Array<{
key: Permission;
label: string;
}>;
}
export interface CrmRoleProfilesResponse {
success: boolean;
time: string;
message: string;
roles: CrmRoleProfileRecord[];
permissionGroups: PermissionGroupDto[];
}
export interface UpdateCrmRoleProfilePayload {
name: string;
description?: string | null;
permissions: Permission[];
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
ownershipScope: CrmOwnershipScope;
approvalAuthority: CrmApprovalAuthority;
isActive: boolean;
}
export interface CloneCrmRoleProfilePayload {
code: string;
name: string;
description?: string | null;
}

View File

@@ -0,0 +1,391 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
APPROVAL_AUTHORITIES,
OWNERSHIP_SCOPES,
SCOPE_MODES,
type Permission
} from '@/lib/auth/rbac';
import { cloneCrmRoleProfileMutation, updateCrmRoleProfileMutation } from '../api/mutations';
import { crmRoleProfilesQueryOptions } from '../api/queries';
type RoleEditorState = {
name: string;
description: string;
permissions: Permission[];
branchScopeMode: (typeof SCOPE_MODES)[number];
productScopeMode: (typeof SCOPE_MODES)[number];
ownershipScope: (typeof OWNERSHIP_SCOPES)[number];
approvalAuthority: (typeof APPROVAL_AUTHORITIES)[number];
isActive: boolean;
};
export function RoleManagementPage() {
const { data } = useSuspenseQuery(crmRoleProfilesQueryOptions());
const [selectedCode, setSelectedCode] = useState<string | null>(data.roles[0]?.code ?? null);
const [cloneCode, setCloneCode] = useState('');
const [cloneName, setCloneName] = useState('');
const [state, setState] = useState<RoleEditorState | null>(null);
const selectedRole = useMemo(
() => data.roles.find((role) => role.code === selectedCode) ?? null,
[data.roles, selectedCode]
);
useEffect(() => {
if (!selectedRole) {
setState(null);
return;
}
setState({
name: selectedRole.name,
description: selectedRole.description ?? '',
permissions: selectedRole.permissions,
branchScopeMode: selectedRole.branchScopeMode,
productScopeMode: selectedRole.productScopeMode,
ownershipScope: selectedRole.ownershipScope,
approvalAuthority: selectedRole.approvalAuthority,
isActive: selectedRole.isActive
});
}, [selectedRole]);
const updateMutation = useMutation({
...updateCrmRoleProfileMutation,
onSuccess: () => toast.success('CRM role updated successfully'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to update CRM role')
});
const cloneMutation = useMutation({
...cloneCrmRoleProfileMutation,
onSuccess: (result) => {
toast.success('CRM role cloned successfully');
setSelectedCode(result.role.code);
setCloneCode('');
setCloneName('');
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to clone CRM role')
});
if (!selectedRole || !state) {
return (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
No CRM roles available.
</div>
);
}
const currentRole = selectedRole;
const editorState = state;
function togglePermission(permission: Permission, checked: boolean) {
setState((current) =>
current
? {
...current,
permissions: checked
? [...new Set([...current.permissions, permission])]
: current.permissions.filter((item) => item !== permission)
}
: current
);
}
async function handleSave() {
await updateMutation.mutateAsync({
code: currentRole.code,
payload: {
...editorState,
description: editorState.description || null
}
});
}
async function handleClone() {
if (!cloneCode.trim() || !cloneName.trim()) {
toast.error('Clone code and clone name are required');
return;
}
await cloneMutation.mutateAsync({
sourceCode: currentRole.code,
payload: {
code: cloneCode,
name: cloneName,
description: editorState.description || null
}
});
}
return (
<div className='grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]'>
<Card>
<CardHeader>
<CardTitle>Roles</CardTitle>
<CardDescription>System and cloned CRM role profiles for this organization.</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{data.roles.map((role) => (
<button
key={role.code}
type='button'
onClick={() => setSelectedCode(role.code)}
className={`w-full rounded-lg border p-3 text-left transition ${
role.code === selectedCode ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
}`}
>
<div className='flex items-center justify-between gap-2'>
<div className='font-medium'>{role.name}</div>
<div className='flex gap-2'>
{role.isSystem ? <Badge variant='secondary'>System</Badge> : null}
{!role.isActive ? <Badge variant='outline'>Inactive</Badge> : null}
</div>
</div>
<div className='text-muted-foreground mt-1 text-xs'>{role.code}</div>
<div className='text-muted-foreground mt-2 line-clamp-2 text-sm'>
{role.description || 'No description'}
</div>
</button>
))}
</CardContent>
</Card>
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>{currentRole.name}</CardTitle>
<CardDescription>
Configure permissions, ownership visibility, branch scope mode, and approval authority.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='role-name'>Role Name</Label>
<Input
id='role-name'
value={editorState.name}
onChange={(event) => setState({ ...state, name: event.target.value })}
/>
</div>
<div className='space-y-2'>
<Label>Role Code</Label>
<Input value={currentRole.code} readOnly />
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='role-description'>Description</Label>
<Textarea
id='role-description'
value={editorState.description}
onChange={(event) => setState({ ...state, description: event.target.value })}
/>
</div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<div className='space-y-2'>
<Label>Ownership Scope</Label>
<Select
value={editorState.ownershipScope}
onValueChange={(value) =>
setState({ ...state, ownershipScope: value as RoleEditorState['ownershipScope'] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OWNERSHIP_SCOPES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Branch Scope</Label>
<Select
value={editorState.branchScopeMode}
onValueChange={(value) =>
setState({ ...state, branchScopeMode: value as RoleEditorState['branchScopeMode'] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_MODES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Product Scope</Label>
<Select
value={editorState.productScopeMode}
onValueChange={(value) =>
setState({
...state,
productScopeMode: value as RoleEditorState['productScopeMode']
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_MODES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Approval Authority</Label>
<Select
value={editorState.approvalAuthority}
onValueChange={(value) =>
setState({
...state,
approvalAuthority: value as RoleEditorState['approvalAuthority']
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{APPROVAL_AUTHORITIES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex items-center gap-3'>
<Checkbox
checked={editorState.isActive}
onCheckedChange={(checked) => setState({ ...state, isActive: checked === true })}
/>
<div>
<div className='font-medium'>Role active</div>
<div className='text-muted-foreground text-sm'>
Inactive roles remain historical but should not be used for new assignments.
</div>
</div>
</div>
<div className='space-y-4'>
<div>
<div className='font-medium'>Permission Matrix</div>
<div className='text-muted-foreground text-sm'>
Effective permissions are role permissions plus membership-level overrides.
</div>
</div>
<div className='grid gap-4 xl:grid-cols-2'>
{data.permissionGroups.map((group) => (
<Card key={group.key}>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>{group.label}</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{group.permissions.map((permission) => {
const checked = editorState.permissions.includes(permission.key);
return (
<label
key={permission.key}
className='flex cursor-pointer items-start gap-3 rounded-md border p-3'
>
<Checkbox
checked={checked}
onCheckedChange={(value) =>
togglePermission(permission.key, value === true)
}
/>
<div className='space-y-1'>
<div className='font-medium'>{permission.label}</div>
<div className='text-muted-foreground text-xs'>{permission.key}</div>
</div>
</label>
);
})}
</CardContent>
</Card>
))}
</div>
</div>
<div className='flex flex-wrap gap-3'>
<Button onClick={() => void handleSave()} isLoading={updateMutation.isPending}>
Save Role
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Clone Role</CardTitle>
<CardDescription>
Create a custom role profile by copying the current role and then tailoring its permissions.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-[1fr_1fr_auto]'>
<div className='space-y-2'>
<Label htmlFor='clone-code'>New Role Code</Label>
<Input
id='clone-code'
value={cloneCode}
onChange={(event) => setCloneCode(event.target.value)}
placeholder='sales_crane_manager'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='clone-name'>New Role Name</Label>
<Input
id='clone-name'
value={cloneName}
onChange={(event) => setCloneName(event.target.value)}
placeholder='Sales Crane Manager'
/>
</div>
<div className='flex items-end'>
<Button onClick={() => void handleClone()} isLoading={cloneMutation.isPending}>
Clone
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { and, asc, eq, isNull } from 'drizzle-orm';
import { crmRoleProfiles } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
CRM_PERMISSION_GROUPS,
getDefaultRoleProfiles,
isApprovalAuthority,
isOwnershipScope,
isScopeMode,
type Permission
} from '@/lib/auth/rbac';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import type {
CloneCrmRoleProfilePayload,
CrmRoleProfileRecord,
UpdateCrmRoleProfilePayload
} from '../api/types';
function mapRole(row: typeof crmRoleProfiles.$inferSelect): CrmRoleProfileRecord {
return {
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name,
description: row.description,
permissions: row.permissions as Permission[],
branchScopeMode: isScopeMode(row.branchScopeMode) ? row.branchScopeMode : 'assigned',
productScopeMode: isScopeMode(row.productScopeMode) ? row.productScopeMode : 'assigned',
ownershipScope: isOwnershipScope(row.ownershipScope) ? row.ownershipScope : 'own',
approvalAuthority: isApprovalAuthority(row.approvalAuthority)
? row.approvalAuthority
: 'none',
isSystem: row.isSystem,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
function sanitizeCode(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '');
}
export async function ensureCrmRoleProfiles(
organizationId: string,
userId: string
) {
const existingRows = await db
.select({ code: crmRoleProfiles.code })
.from(crmRoleProfiles)
.where(
and(
eq(crmRoleProfiles.organizationId, organizationId),
isNull(crmRoleProfiles.deletedAt)
)
);
const existingCodes = new Set(existingRows.map((row) => row.code));
const missingProfiles = getDefaultRoleProfiles().filter(
(profile) => !existingCodes.has(profile.code)
);
if (!missingProfiles.length) {
return;
}
await db.insert(crmRoleProfiles).values(
missingProfiles.map((profile) => ({
id: crypto.randomUUID(),
organizationId,
code: profile.code,
name: profile.name,
description: profile.description,
permissions: profile.permissions,
branchScopeMode: profile.branchScopeMode,
productScopeMode: profile.productScopeMode,
ownershipScope: profile.ownershipScope,
approvalAuthority: profile.approvalAuthority,
isSystem: profile.isSystem,
isActive: true,
createdBy: userId,
updatedBy: userId
}))
);
}
export async function listCrmRoleProfiles(
organizationId: string,
userId: string
) {
await ensureCrmRoleProfiles(organizationId, userId);
const rows = await db
.select()
.from(crmRoleProfiles)
.where(
and(
eq(crmRoleProfiles.organizationId, organizationId),
isNull(crmRoleProfiles.deletedAt)
)
)
.orderBy(asc(crmRoleProfiles.isSystem), asc(crmRoleProfiles.name));
return {
roles: rows.map(mapRole),
permissionGroups: CRM_PERMISSION_GROUPS
};
}
export async function getCrmRoleProfileByCode(
organizationId: string,
userId: string,
code: string
) {
await ensureCrmRoleProfiles(organizationId, userId);
const row = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, code),
isNull(crmRoleProfiles.deletedAt)
)
});
if (!row) {
throw new AuthError('CRM role not found', 404);
}
return mapRole(row);
}
export async function updateCrmRoleProfile(
organizationId: string,
userId: string,
code: string,
payload: UpdateCrmRoleProfilePayload
) {
const current = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, code),
isNull(crmRoleProfiles.deletedAt)
)
});
if (!current) {
throw new AuthError('CRM role not found', 404);
}
const [updated] = await db
.update(crmRoleProfiles)
.set({
name: payload.name.trim(),
description: payload.description?.trim() || null,
permissions: payload.permissions,
branchScopeMode: payload.branchScopeMode,
productScopeMode: payload.productScopeMode,
ownershipScope: payload.ownershipScope,
approvalAuthority: payload.approvalAuthority,
isActive: payload.isActive,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmRoleProfiles.id, current.id))
.returning();
await auditAction({
organizationId,
userId,
entityType: 'crm_role',
entityId: updated.id,
action: 'update',
beforeData: mapRole(current),
afterData: mapRole(updated)
});
return mapRole(updated);
}
export async function cloneCrmRoleProfile(
organizationId: string,
userId: string,
sourceCode: string,
payload: CloneCrmRoleProfilePayload
) {
const source = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, sourceCode),
isNull(crmRoleProfiles.deletedAt)
)
});
if (!source) {
throw new AuthError('Source CRM role not found', 404);
}
const targetCode = sanitizeCode(payload.code);
if (!targetCode) {
throw new AuthError('Role code is required', 400);
}
const duplicate = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, targetCode),
isNull(crmRoleProfiles.deletedAt)
)
});
if (duplicate) {
throw new AuthError('Role code already exists', 409);
}
const [created] = await db
.insert(crmRoleProfiles)
.values({
id: crypto.randomUUID(),
organizationId,
code: targetCode,
name: payload.name.trim(),
description: payload.description?.trim() || source.description,
permissions: source.permissions,
branchScopeMode: source.branchScopeMode,
productScopeMode: source.productScopeMode,
ownershipScope: source.ownershipScope,
approvalAuthority: source.approvalAuthority,
isSystem: false,
isActive: true,
createdBy: userId,
updatedBy: userId
})
.returning();
await auditAction({
organizationId,
userId,
entityType: 'crm_role',
entityId: created.id,
action: 'create',
afterData: {
sourceCode,
role: mapRole(created)
}
});
return mapRole(created);
}

View File

@@ -52,7 +52,8 @@ const businessRoleOptions = [
{ value: 'sales', label: 'Sales' },
{ value: 'sales_support', label: 'Sales Support' },
{ value: 'department_manager', label: 'Department Manager' },
{ value: 'top_manager', label: 'Top Manager' }
{ value: 'top_manager', label: 'Top Manager' },
{ value: 'crm_admin', label: 'CRM Admin' }
] as const;
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {

View File

@@ -1,6 +1,4 @@
import * as z from 'zod';
import { BUSINESS_ROLES } 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'),
@@ -11,7 +9,7 @@ export const userSchema = z.object({
z.object({
organizationId: z.string().min(1),
membershipRole: z.enum(['admin', 'user']),
businessRole: z.enum(BUSINESS_ROLES)
businessRole: z.string().min(1)
})
)
.min(1, 'Select at least one organization')

151
src/lib/auth/crm-access.ts Normal file
View File

@@ -0,0 +1,151 @@
import { and, eq, isNull } from 'drizzle-orm';
import { crmRoleProfiles, memberships } from '@/db/schema';
import { db } from '@/lib/db';
import {
ALL_PERMISSIONS,
type CrmApprovalAuthority,
type CrmOwnershipScope,
type CrmScopeMode,
getDefaultBusinessRole,
getRoleProfileDefinition,
isApprovalAuthority,
isMembershipRole,
isOwnershipScope,
isScopeMode,
resolveEffectivePermissions,
type MembershipRole,
type Permission,
type SystemRole
} from './rbac';
export interface ResolvedCrmAccess {
membershipRole: MembershipRole;
businessRole: string;
permissions: Permission[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: CrmOwnershipScope;
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
approvalAuthority: CrmApprovalAuthority;
roleProfileName: string | null;
}
type BranchScopedAccess = Pick<
ResolvedCrmAccess,
'branchScopeMode' | 'branchScopeIds'
> | {
branchScopeMode: string;
branchScopeIds: string[];
};
type ProductScopedAccess = Pick<
ResolvedCrmAccess,
'productScopeMode' | 'productTypeScopeIds'
> | {
productScopeMode: string;
productTypeScopeIds: string[];
};
function normalizeStringArray(value: unknown) {
return Array.isArray(value)
? [...new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0))]
: [];
}
export async function resolveCrmMembershipAccess(input: {
systemRole: SystemRole;
organizationId: string;
membership: typeof memberships.$inferSelect;
}): Promise<ResolvedCrmAccess> {
if (input.systemRole === 'super_admin') {
return {
membershipRole: 'admin',
businessRole: input.membership.businessRole,
permissions: ALL_PERMISSIONS,
branchScopeIds: [],
productTypeScopeIds: [],
ownershipScope: 'organization',
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'final',
roleProfileName: 'System Admin'
};
}
const membershipRole = isMembershipRole(input.membership.role)
? input.membership.role
: 'user';
const businessRole = input.membership.businessRole || getDefaultBusinessRole(membershipRole);
const [roleProfile] = await db
.select()
.from(crmRoleProfiles)
.where(
and(
eq(crmRoleProfiles.organizationId, input.organizationId),
eq(crmRoleProfiles.code, businessRole),
eq(crmRoleProfiles.isActive, true),
isNull(crmRoleProfiles.deletedAt)
)
)
.limit(1);
const defaultDefinition = getRoleProfileDefinition(businessRole);
const ownershipScope: CrmOwnershipScope = isOwnershipScope(roleProfile?.ownershipScope ?? '')
? (roleProfile.ownershipScope as CrmOwnershipScope)
: (defaultDefinition?.ownershipScope ?? 'own');
const branchScopeMode: CrmScopeMode = isScopeMode(roleProfile?.branchScopeMode ?? '')
? (roleProfile.branchScopeMode as CrmScopeMode)
: (defaultDefinition?.branchScopeMode ?? 'assigned');
const productScopeMode: CrmScopeMode = isScopeMode(roleProfile?.productScopeMode ?? '')
? (roleProfile.productScopeMode as CrmScopeMode)
: (defaultDefinition?.productScopeMode ?? 'assigned');
const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority(
roleProfile?.approvalAuthority ?? ''
)
? (roleProfile.approvalAuthority as CrmApprovalAuthority)
: (defaultDefinition?.approvalAuthority ?? 'none');
return {
membershipRole,
businessRole,
permissions: resolveEffectivePermissions({
systemRole: input.systemRole,
membershipRole,
businessRole,
rolePermissions: roleProfile?.permissions ?? defaultDefinition?.permissions ?? [],
directPermissions: input.membership.permissions
}),
branchScopeIds: normalizeStringArray(input.membership.branchScopeIds),
productTypeScopeIds: normalizeStringArray(input.membership.productTypeScopeIds),
ownershipScope,
branchScopeMode,
productScopeMode,
approvalAuthority,
roleProfileName: roleProfile?.name ?? defaultDefinition?.name ?? null
};
}
export function hasBranchScopeAccess(access: BranchScopedAccess, branchId?: string | null) {
if (!branchId) {
return true;
}
if (access.branchScopeMode === 'all' || access.branchScopeIds.length === 0) {
return true;
}
return access.branchScopeIds.includes(branchId);
}
export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId?: string | null) {
if (!productTypeId) {
return true;
}
if (access.productScopeMode === 'all' || access.productTypeScopeIds.length === 0) {
return true;
}
return access.productTypeScopeIds.includes(productTypeId);
}

View File

@@ -6,12 +6,19 @@ export const BUSINESS_ROLES = [
'sales_support',
'sales_manager',
'department_manager',
'top_manager'
'top_manager',
'crm_admin'
] as const;
export const OWNERSHIP_SCOPES = ['own', 'team', 'organization', 'monitor'] as const;
export const SCOPE_MODES = ['all', 'assigned'] as const;
export const APPROVAL_AUTHORITIES = ['none', 'manager', 'department', 'final'] as const;
export type SystemRole = (typeof SYSTEM_ROLES)[number];
export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number];
export type BusinessRole = (typeof BUSINESS_ROLES)[number];
export type BusinessRole = string;
export type CrmOwnershipScope = (typeof OWNERSHIP_SCOPES)[number];
export type CrmScopeMode = (typeof SCOPE_MODES)[number];
export type CrmApprovalAuthority = (typeof APPROVAL_AUTHORITIES)[number];
export const PERMISSIONS = {
productsRead: 'products:read',
@@ -52,6 +59,7 @@ export const PERMISSIONS = {
crmQuotationFollowupManage: 'crm.quotation.followup.manage',
crmQuotationAttachmentManage: 'crm.quotation.attachment.manage',
crmQuotationRevisionCreate: 'crm.quotation.revision.create',
crmQuotationPricingRead: 'crm.quotation.pricing.read',
crmApprovalRead: 'crm.approval.read',
crmApprovalSubmit: 'crm.approval.submit',
crmApprovalApprove: 'crm.approval.approve',
@@ -77,106 +85,262 @@ export const PERMISSIONS = {
crmQuotationDocumentPreview: 'crm.quotation.document.preview',
crmQuotationPdfPreview: 'crm.quotation.pdf.preview',
crmQuotationPdfDownload: 'crm.quotation.pdf.download',
crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved'
crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved',
crmRoleRead: 'crm.role.read',
crmRoleCreate: 'crm.role.create',
crmRoleUpdate: 'crm.role.update',
crmRoleDelete: 'crm.role.delete',
crmRoleAssign: 'crm.role.assign',
crmMasterOptionRead: 'crm.master_option.read',
crmMasterOptionCreate: 'crm.master_option.create',
crmMasterOptionUpdate: 'crm.master_option.update',
crmMasterOptionDelete: 'crm.master_option.delete'
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
const MANAGER_PERMISSIONS: Permission[] = [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
export interface CrmRoleProfileDefinition {
code: string;
name: string;
description: string;
permissions: Permission[];
ownershipScope: CrmOwnershipScope;
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
approvalAuthority: CrmApprovalAuthority;
isSystem: boolean;
}
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
if (role === 'admin') {
return [
PERMISSIONS.productsRead,
PERMISSIONS.productsWrite,
PERMISSIONS.organizationManage,
PERMISSIONS.usersManage,
export interface PermissionGroup {
key: string;
label: string;
permissions: Array<{ key: Permission; label: string }>;
}
const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'code'>> = {
marketing: {
name: 'Marketing',
description: 'Lead creation, assignment, monitoring, and enquiry visibility without commercial authority.',
permissions: [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmCustomerDelete,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmContactDelete,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmDashboardRead
],
ownershipScope: 'monitor',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'none',
isSystem: true
},
sales: {
name: 'Sales',
description: 'Own enquiries, quotations, and follow-ups within assigned branch and product scope.',
permissions: [
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryDelete,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmEnquiryFollowupDelete,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationDelete,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'own',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'none',
isSystem: true
},
sales_support: {
name: 'Sales Support',
description: 'Quotation drafting and operational support without approval authority.',
permissions: [
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'own',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'none',
isSystem: true
},
sales_manager: {
name: 'Sales Manager',
description: 'Team visibility, sales assignment, and quotation approval authority.',
permissions: [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'team',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'manager',
isSystem: true
},
department_manager: {
name: 'Department Manager',
description: 'Department analytics and higher-level approval authority.',
permissions: [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'organization',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'department',
isSystem: true
},
top_manager: {
name: 'Top Manager',
description: 'Final approval authority with full CRM analytics visibility.',
permissions: [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'organization',
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'final',
isSystem: true
},
crm_admin: {
name: 'CRM Admin',
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
permissions: [
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmRoleRead,
PERMISSIONS.crmRoleCreate,
PERMISSIONS.crmRoleUpdate,
PERMISSIONS.crmRoleDelete,
PERMISSIONS.crmRoleAssign,
PERMISSIONS.crmMasterOptionRead,
PERMISSIONS.crmMasterOptionCreate,
PERMISSIONS.crmMasterOptionUpdate,
PERMISSIONS.crmMasterOptionDelete,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmApprovalWorkflowCreate,
PERMISSIONS.crmApprovalWorkflowUpdate,
PERMISSIONS.crmApprovalWorkflowDelete,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate,
@@ -192,116 +356,163 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload,
PERMISSIONS.crmQuotationPdfGenerateApproved
],
ownershipScope: 'organization',
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'none',
isSystem: true
}
};
export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{
key: 'lead',
label: 'Leads',
permissions: [
{ key: PERMISSIONS.crmLeadRead, label: 'Read leads' },
{ key: PERMISSIONS.crmLeadCreate, label: 'Create leads' },
{ key: PERMISSIONS.crmLeadUpdate, label: 'Update leads' },
{ key: PERMISSIONS.crmLeadAssign, label: 'Assign leads' },
{ key: PERMISSIONS.crmLeadDelete, label: 'Delete leads' }
]
},
{
key: 'enquiry',
label: 'Enquiries',
permissions: [
{ key: PERMISSIONS.crmEnquiryRead, label: 'Read enquiries' },
{ key: PERMISSIONS.crmEnquiryCreate, label: 'Create enquiries' },
{ key: PERMISSIONS.crmEnquiryUpdate, label: 'Update enquiries' },
{ key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' },
{ key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' },
{ key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' },
{ key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupDelete, label: 'Delete enquiry follow-ups' }
]
},
{
key: 'quotation',
label: 'Quotations',
permissions: [
{ key: PERMISSIONS.crmQuotationRead, label: 'Read quotations' },
{ key: PERMISSIONS.crmQuotationCreate, label: 'Create quotations' },
{ key: PERMISSIONS.crmQuotationUpdate, label: 'Update quotations' },
{ key: PERMISSIONS.crmQuotationDelete, label: 'Delete quotations' },
{ key: PERMISSIONS.crmQuotationPricingRead, label: 'Read quotation pricing' },
{ key: PERMISSIONS.crmQuotationItemManage, label: 'Manage quotation items' },
{ key: PERMISSIONS.crmQuotationCustomerManage, label: 'Manage quotation customers' },
{ key: PERMISSIONS.crmQuotationTopicManage, label: 'Manage quotation topics' },
{ key: PERMISSIONS.crmQuotationFollowupManage, label: 'Manage quotation follow-ups' },
{ key: PERMISSIONS.crmQuotationAttachmentManage, label: 'Manage quotation attachments' },
{ key: PERMISSIONS.crmQuotationRevisionCreate, label: 'Create quotation revisions' }
]
},
{
key: 'approval',
label: 'Approvals',
permissions: [
{ key: PERMISSIONS.crmApprovalRead, label: 'Read approvals' },
{ key: PERMISSIONS.crmApprovalSubmit, label: 'Submit approvals' },
{ key: PERMISSIONS.crmApprovalApprove, label: 'Approve quotations' },
{ key: PERMISSIONS.crmApprovalReject, label: 'Reject quotations' },
{ key: PERMISSIONS.crmApprovalReturn, label: 'Return quotations' }
]
},
{
key: 'dashboard',
label: 'Dashboard',
permissions: [
{ key: PERMISSIONS.crmDashboardRead, label: 'Read dashboard' },
{ key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' }
]
},
{
key: 'settings',
label: 'CRM Settings',
permissions: [
{ key: PERMISSIONS.crmRoleRead, label: 'Read roles' },
{ key: PERMISSIONS.crmRoleCreate, label: 'Create roles' },
{ key: PERMISSIONS.crmRoleUpdate, label: 'Update roles' },
{ key: PERMISSIONS.crmRoleDelete, label: 'Delete roles' },
{ key: PERMISSIONS.crmRoleAssign, label: 'Assign role scopes' },
{ key: PERMISSIONS.crmMasterOptionRead, label: 'Read master options' },
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
{ key: PERMISSIONS.crmMasterOptionDelete, label: 'Delete master options' },
{ key: PERMISSIONS.crmApprovalWorkflowRead, label: 'Read approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowDelete, label: 'Delete approval workflows' },
{ key: PERMISSIONS.crmDocumentTemplateRead, label: 'Read document templates' },
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
{ key: PERMISSIONS.crmDocumentTemplateDelete, label: 'Delete document templates' },
{ key: PERMISSIONS.crmDocumentSequenceRead, label: 'Read document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceReset, label: 'Reset document sequences' }
]
},
{
key: 'documents',
label: 'Documents',
permissions: [
{ key: PERMISSIONS.crmDocumentArtifactRead, label: 'Read approved artifacts' },
{ key: PERMISSIONS.crmDocumentArtifactDownload, label: 'Download approved artifacts' },
{ key: PERMISSIONS.crmDocumentArtifactVoid, label: 'Void approved artifacts' },
{ key: PERMISSIONS.crmQuotationDocumentPreview, label: 'Preview quotation document' },
{ key: PERMISSIONS.crmQuotationPdfPreview, label: 'Preview quotation PDF' },
{ key: PERMISSIONS.crmQuotationPdfDownload, label: 'Download quotation PDF' },
{ key: PERMISSIONS.crmQuotationPdfGenerateApproved, label: 'Generate approved quotation PDF' }
]
}
];
export const ALL_PERMISSIONS = Object.values(PERMISSIONS) as Permission[];
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
if (role === 'admin') {
return [
PERMISSIONS.productsRead,
PERMISSIONS.productsWrite,
PERMISSIONS.organizationManage,
PERMISSIONS.usersManage
];
}
return [
PERMISSIONS.productsRead,
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
return [PERMISSIONS.productsRead];
}
export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null {
const definition = DEFAULT_ROLE_DEFINITIONS[roleCode];
if (!definition) {
return null;
}
return {
code: roleCode,
...definition
};
}
export function getDefaultRoleProfiles(): CrmRoleProfileDefinition[] {
return BUSINESS_ROLES.map((roleCode) => {
const definition = getRoleProfileDefinition(roleCode);
if (!definition) {
throw new Error(`Missing default role definition for ${roleCode}`);
}
return definition;
});
}
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
switch (role) {
case 'marketing':
return [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmDashboardRead
];
case 'sales_manager':
return MANAGER_PERMISSIONS;
case 'sales':
return [
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
case 'sales_support':
return [
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
case 'department_manager':
case 'top_manager':
return MANAGER_PERMISSIONS;
default:
return [];
}
return getRoleProfileDefinition(role)?.permissions ?? [];
}
export function getDefaultBusinessRole(role: MembershipRole): BusinessRole {
@@ -317,6 +528,27 @@ export function getDefaultPermissions(
];
}
export function resolveEffectivePermissions(input: {
systemRole: SystemRole;
membershipRole: MembershipRole;
businessRole: string;
rolePermissions?: string[] | null;
directPermissions?: string[] | null;
}) {
if (input.systemRole === 'super_admin') {
return ALL_PERMISSIONS;
}
return [
...new Set([
...getMembershipBasePermissions(input.membershipRole),
...getBusinessRolePermissions(input.businessRole),
...(input.rolePermissions?.filter((value): value is Permission => typeof value === 'string') ?? []),
...(input.directPermissions?.filter((value): value is Permission => typeof value === 'string') ?? [])
])
];
}
export function isSystemRole(value: string): value is SystemRole {
return SYSTEM_ROLES.includes(value as SystemRole);
}
@@ -326,5 +558,17 @@ export function isMembershipRole(value: string): value is MembershipRole {
}
export function isBusinessRole(value: string): value is BusinessRole {
return BUSINESS_ROLES.includes(value as BusinessRole);
return value.trim().length > 0;
}
export function isOwnershipScope(value: string): value is CrmOwnershipScope {
return OWNERSHIP_SCOPES.includes(value as CrmOwnershipScope);
}
export function isScopeMode(value: string): value is CrmScopeMode {
return SCOPE_MODES.includes(value as CrmScopeMode);
}
export function isApprovalAuthority(value: string): value is CrmApprovalAuthority {
return APPROVAL_AUTHORITIES.includes(value as CrmApprovalAuthority);
}

View File

@@ -1,7 +1,9 @@
import { and, eq } from 'drizzle-orm';
import { auth } from '@/auth';
import { memberships, organizations } from '@/db/schema';
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { db } from '@/lib/db';
import type { Permission } from './rbac';
export class AuthError extends Error {
status: number;
@@ -51,15 +53,22 @@ export async function requireOrganizationAccess(options?: {
throw new AuthError('Organization membership required', 403);
}
return {
session,
membership: {
userId: session.user.id,
organizationId: activeOrganization.id,
role: activeOrganization.role,
businessRole: activeOrganization.businessRole,
permissions: session.user.activePermissions
},
return {
session,
membership: {
userId: session.user.id,
organizationId: activeOrganization.id,
role: activeOrganization.role,
businessRole: activeOrganization.businessRole,
permissions: session.user.activePermissions,
branchScopeIds: session.user.activeBranchScopeIds,
productTypeScopeIds: session.user.activeProductTypeScopeIds,
ownershipScope: session.user.activeOwnershipScope,
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'final',
roleProfileName: 'System Admin'
},
organization: {
id: activeOrganization.id,
name: activeOrganization.name,
@@ -85,6 +94,12 @@ export async function requireOrganizationAccess(options?: {
throw new AuthError('Forbidden', 403);
}
const resolvedAccess = await resolveCrmMembershipAccess({
systemRole: session.user.systemRole,
organizationId: membership.organizationId,
membership
});
const allowedRoles = options?.roles ?? (options?.role ? [options.role] : []);
const allowedPermissions = options?.permissions ?? (options?.permission ? [options.permission] : []);
@@ -94,7 +109,9 @@ export async function requireOrganizationAccess(options?: {
if (
allowedPermissions.length > 0 &&
!allowedPermissions.every((permission) => membership.permissions.includes(permission))
!allowedPermissions.every((permission) =>
resolvedAccess.permissions.includes(permission as Permission)
)
) {
throw new AuthError('Forbidden', 403);
}
@@ -109,7 +126,17 @@ export async function requireOrganizationAccess(options?: {
return {
session,
membership,
membership: {
...membership,
permissions: resolvedAccess.permissions,
branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
ownershipScope: resolvedAccess.ownershipScope,
branchScopeMode: resolvedAccess.branchScopeMode,
productScopeMode: resolvedAccess.productScopeMode,
approvalAuthority: resolvedAccess.approvalAuthority,
roleProfileName: resolvedAccess.roleProfileName
},
organization
};
}

View File

@@ -11,6 +11,9 @@ declare module 'next-auth' {
activeMembershipRole: string | null;
activeBusinessRole: string | null;
activePermissions: string[];
activeBranchScopeIds: string[];
activeProductTypeScopeIds: string[];
activeOwnershipScope: string | null;
organizations: Array<{
id: string;
name: string;
@@ -40,6 +43,9 @@ declare module 'next-auth/jwt' {
activeMembershipRole?: string | null;
activeBusinessRole?: string | null;
activePermissions?: string[];
activeBranchScopeIds?: string[];
activeProductTypeScopeIds?: string[];
activeOwnershipScope?: string | null;
organizations?: Array<{
id: string;
name: string;