diff --git a/apps/app/src/actions/organization/remove-employee.ts b/apps/app/src/actions/organization/remove-employee.ts index 9387ec46b..602648a11 100644 --- a/apps/app/src/actions/organization/remove-employee.ts +++ b/apps/app/src/actions/organization/remove-employee.ts @@ -67,18 +67,18 @@ export const removeEmployeeRoleOrMember = authActionClient }; } - // 3. Check if target has 'employee' role + // 3. Check if target has 'employee' or 'contractor' role const roles = targetMember.role.split(',').filter(Boolean); // Handle empty strings/commas - if (!roles.includes('employee')) { + if (!roles.includes('employee') && !roles.includes('contractor')) { return { success: false, - error: 'Target member does not have the employee role.', + error: 'Target member does not have the employee or contractor role.', }; } // 4. Logic: Remove role or delete member - if (roles.length === 1 && roles[0] === 'employee') { - // Only has employee role - delete member fully + if (roles.length === 1 && (roles[0] === 'employee' || roles[0] === 'contractor')) { + // Only has employee or contractor role - delete member fully // Cannot remove owner (shouldn't happen if only role is employee, but safety check) if (targetMember.role === 'owner') { diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index c1ca22993..f60e30acc 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -88,10 +88,10 @@ export const acceptRequestedPolicyChangesAction = authActionClient }, }); - // Filter to get only employees + // Filter to get only employees and contractors const employeeMembers = employees.filter((member) => { const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; - return roles.includes('employee'); + return roles.includes('employee') || roles.includes('contractor'); }); // Prepare the events array for the API diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index b988987d9..e1521c463 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -63,7 +63,7 @@ export default async function Layout({ return redirect('/auth/unauthorized'); } - if (member.role === 'employee') { + if (member.role === 'employee' || member.role === 'contractor') { return redirect('/no-access'); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index e07fbd922..d7e9dc9c5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -113,6 +113,8 @@ const getPoliciesTasks = async (employeeId: string) => { where: { organizationId: organizationId, status: 'published', + isRequiredToSign: true, + isArchived: false, }, orderBy: { name: 'asc', diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx index b9a0b0108..aa9d5ff5d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx @@ -35,7 +35,7 @@ import { addEmployeeWithoutInvite } from '../actions/addEmployeeWithoutInvite'; import { MultiRoleCombobox } from './MultiRoleCombobox'; // --- Constants for Roles --- -const selectableRoles = ['admin', 'auditor', 'employee'] as const satisfies Readonly< +const selectableRoles = ['admin', 'auditor', 'employee', 'contractor'] as const satisfies Readonly< [Role, ...Role[]] >; type InviteRole = (typeof selectableRoles)[number]; @@ -43,7 +43,7 @@ const DEFAULT_ROLES: InviteRole[] = []; // Type guard to check if a string is a valid InviteRole const isInviteRole = (role: string): role is InviteRole => { - return role === 'admin' || role === 'auditor' || role === 'employee'; + return role === 'admin' || role === 'auditor' || role === 'employee' || role === 'contractor'; }; // --- Schemas --- @@ -159,7 +159,8 @@ export function InviteMembersModal({ // Process each invitation sequentially for (const invite of values.manualInvites) { const hasEmployeeRoleAndNoAdmin = - !invite.roles.includes('admin') && invite.roles.includes('employee'); + !invite.roles.includes('admin') && + (invite.roles.includes('employee') || invite.roles.includes('contractor')); try { if (hasEmployeeRoleAndNoAdmin) { await addEmployeeWithoutInvite({ @@ -320,7 +321,8 @@ export function InviteMembersModal({ // Attempt to invite const hasEmployeeRoleAndNoAdmin = - validRoles.includes('employee') && !validRoles.includes('admin'); + (validRoles.includes('employee') || validRoles.includes('contractor')) && + !validRoles.includes('admin'); try { if (hasEmployeeRoleAndNoAdmin) { await addEmployeeWithoutInvite({ diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 5528a1c27..62882a69b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -168,6 +168,8 @@ export function MemberRow({ member, onRemove, onUpdateRole, canEdit }: MemberRow return 'Auditor'; case 'employee': return 'Employee'; + case 'contractor': + return 'Contractor'; default: return '???'; } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx index 79e9a86bc..40ef17b4e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx @@ -28,6 +28,11 @@ const selectableRoles: { labelKey: 'people.roles.employee', descriptionKey: 'people.roles.employee_description', }, + { + value: 'contractor', + labelKey: 'people.roles.contractor', + descriptionKey: 'people.roles.contractor_description', + }, { value: 'auditor', labelKey: 'people.roles.auditor', @@ -95,6 +100,8 @@ export function MultiRoleCombobox({ return 'Auditor'; case 'employee': return 'Employee'; + case 'contractor': + return 'Contractor'; default: return roleValue; } @@ -112,6 +119,8 @@ export function MultiRoleCombobox({ return 'Auditor'; case 'employee': return 'Employee'; + case 'contractor': + return 'Contractor'; case 'owner': return 'Owner'; default: diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx index 56c320e16..3c56dcbe2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx @@ -42,6 +42,8 @@ export function MultiRoleComboboxContent({ return 'Auditor'; case 'employee': return 'Employee'; + case 'contractor': + return 'Contractor'; default: return roleValue; } @@ -57,6 +59,8 @@ export function MultiRoleComboboxContent({ return 'Read-only access for compliance checks.'; case 'employee': return 'Can sign policies and complete training.'; + case 'contractor': + return 'Can sign policies and complete training.'; default: return ''; } diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx index 95f93acda..09a5cd1c5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx @@ -49,13 +49,16 @@ export async function EmployeesOverview() { employees = fetchedMembers.filter((member) => { const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; - return roles.includes('employee'); + return roles.includes('employee') || roles.includes('contractor'); }); - // Fetch required policies + // Fetch required policies that are published and not archived policies = await db.policy.findMany({ where: { organizationId: organizationId, + isRequiredToSign: true, + status: 'published', + isArchived: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/people/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/layout.tsx index 508fe95f3..149b80caf 100644 --- a/apps/app/src/app/(app)/[orgId]/people/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/layout.tsx @@ -23,7 +23,7 @@ export default async function Layout({ children }: { children: React.ReactNode } const employees = allMembers.filter((member) => { const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; - return roles.includes('employee'); + return roles.includes('employee') || roles.includes('contractor'); }); return ( diff --git a/apps/app/src/utils/permissions.ts b/apps/app/src/utils/permissions.ts index 82762ecbf..86fce15c3 100644 --- a/apps/app/src/utils/permissions.ts +++ b/apps/app/src/utils/permissions.ts @@ -50,4 +50,12 @@ export const employee = ac.newRole({ portal: ['read', 'update'], }); -export const allRoles = { owner, admin, auditor, employee } as const; +/** + * Contractor role with same permissions as employee + * Can manage portal for compliance purposes + */ +export const contractor = ac.newRole({ + portal: ['read', 'update'], +}); + +export const allRoles = { owner, admin, auditor, employee, contractor } as const; diff --git a/apps/portal/src/app/lib/auth.ts b/apps/portal/src/app/lib/auth.ts index 534b6705c..277affdc1 100644 --- a/apps/portal/src/app/lib/auth.ts +++ b/apps/portal/src/app/lib/auth.ts @@ -5,7 +5,7 @@ import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; import { nextCookies } from 'better-auth/next-js'; import { emailOTP, multiSession, organization } from 'better-auth/plugins'; -import { ac, admin, auditor, employee, owner } from './permissions'; +import { ac, admin, auditor, contractor, employee, owner } from './permissions'; export const auth = betterAuth({ database: prismaAdapter(db, { @@ -46,6 +46,7 @@ export const auth = betterAuth({ admin, auditor, employee, + contractor, }, schema: { organization: { diff --git a/apps/portal/src/app/lib/permissions.ts b/apps/portal/src/app/lib/permissions.ts index 2a3d360a5..4110b05cf 100644 --- a/apps/portal/src/app/lib/permissions.ts +++ b/apps/portal/src/app/lib/permissions.ts @@ -43,3 +43,11 @@ export const employee = ac.newRole({ invitation: ['create', 'cancel'], app: ['read', 'update'], }); + +export const contractor = ac.newRole({ + portal: ['read', 'update'], + organization: ['read', 'update'], + member: ['create', 'update'], + invitation: ['create', 'cancel'], + app: ['read', 'update'], +}); diff --git a/packages/db/prisma/migrations/20251111231738_add_contractor/migration.sql b/packages/db/prisma/migrations/20251111231738_add_contractor/migration.sql new file mode 100644 index 000000000..d49f2a275 --- /dev/null +++ b/packages/db/prisma/migrations/20251111231738_add_contractor/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "public"."Role" ADD VALUE 'contractor'; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 298e379bc..30f37b9f0 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -124,6 +124,7 @@ enum Role { admin auditor employee + contractor } enum PolicyStatus {