diff --git a/.snyk b/.snyk index 97bb87848..02a4e4b14 100644 --- a/.snyk +++ b/.snyk @@ -76,3 +76,18 @@ ignore: reason: 'Transitive dependency in Docusaurus; not exploitable in current usage.' expires: '2026-06-28T00:00:00.000Z' created: '2026-05-11T10:00:00.000Z' + 'SNYK-JS-AI-16734889': + - '* > ai@5.0.105': + reason: 'Transitive dependency in @docusaurus/preset-classic; not exploitable in current usage.' + expires: '2026-06-18T00:00:00.000Z' + created: '2026-05-18T11:04:00.000Z' + 'SNYK-JS-AISDKPROVIDERUTILS-16734888': + - '* > @ai-sdk/provider-utils@3.0.18': + reason: 'Transitive dependency in @docusaurus/preset-classic; not exploitable in current usage.' + expires: '2026-06-18T00:00:00.000Z' + created: '2026-05-18T11:04:00.000Z' + 'SNYK-JS-AISDKPROVIDERUTILS-16735288': + - '* > @ai-sdk/provider-utils@3.0.18': + reason: 'Transitive dependency in @docusaurus/preset-classic; not exploitable in current usage.' + expires: '2026-06-18T00:00:00.000Z' + created: '2026-05-18T11:04:00.000Z' diff --git a/apps/ui-community/src/contexts/theme-context.tsx b/apps/ui-community/src/contexts/theme-context.tsx index 8a7a5a870..fa94b9e65 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import { loadStoredTheme, saveStoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -15,7 +16,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: 'light' | 'dark' | 'custom'; } | undefined; setTheme: (tokens: Partial, types: string) => void; @@ -91,13 +92,15 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { type: 'custom', }; } - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + if (valueToSet) { + saveStoredTheme(valueToSet); + } return valueToSet; }); }, []); useEffect(() => { - const extractFromLocal = JSON.parse(localStorage.getItem('themeProp') || '{}'); + const extractFromLocal = loadStoredTheme(); if (extractFromLocal && extractFromLocal.type === 'dark') { setTheme( { @@ -119,22 +122,22 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { } else if (extractFromLocal && extractFromLocal.type === 'custom') { setTheme( { - colorTextBase: extractFromLocal.hardCodedTokens.textColor, - colorBgBase: extractFromLocal.hardCodedTokens.backgroundColor, + colorTextBase: extractFromLocal.hardCodedTokens?.textColor, + colorBgBase: extractFromLocal.hardCodedTokens?.backgroundColor, }, 'custom', ); return; } else { const valueToSet = { - type: 'light', + type: 'light' as const, token: theme.defaultSeed, hardCodedTokens: { textColor: '#000000', backgroundColor: '#ffffff', }, }; - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + saveStoredTheme(valueToSet); setTheme(theme.defaultSeed, 'light'); return; } diff --git a/apps/ui-staff/src/App.tsx b/apps/ui-staff/src/App.tsx index f7171e4a4..e26ad3e83 100644 --- a/apps/ui-staff/src/App.tsx +++ b/apps/ui-staff/src/App.tsx @@ -5,23 +5,89 @@ import { Root as Finance } from '@ocom/ui-staff-route-finance'; import { Root } from '@ocom/ui-staff-route-root'; import { Root as TechAdmin } from '@ocom/ui-staff-route-tech-admin'; import { Root as UserManagement } from '@ocom/ui-staff-route-user-management'; -import { StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { StaffAuthContext, StaffAuthProvider } from '@ocom/ui-staff-shared'; +import { Spin } from 'antd'; +import { useContext } from 'react'; import { useAuth } from 'react-oidc-context'; -import { Outlet, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import './App.css'; import { AuthLanding } from './components/ui/molecules/auth-landing/index.tsx'; import { client } from './components/ui/organisms/apollo-connection/apollo-client-links.tsx'; import { ApolloConnection } from './components/ui/organisms/apollo-connection/index.tsx'; +import { useStaffPermissions } from './hooks/use-staff-permissions.ts'; import { Unauthorized } from './unauthorized.tsx'; +function StaffRoutes() { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageUsers = perms?.canManageUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + + let defaultStaffRoute = '/unauthorized'; + if (canManageTechAdmin) { + defaultStaffRoute = '/staff/tech'; + } else if (canManageFinance) { + defaultStaffRoute = '/staff/finance'; + } else if (canManageCommunities) { + defaultStaffRoute = '/staff/community-management'; + } else if (canManageUsers) { + defaultStaffRoute = '/staff/user-management'; + } + + return ( + + + } + /> + {canManageCommunities && ( + } + /> + )} + {canManageUsers && ( + } + /> + )} + {canManageFinance && ( + } + /> + )} + {canManageTechAdmin && ( + } + /> + )} + + } + /> + + ); +} + export default function App() { const rootSection = ; const auth = useAuth(); - // Build a best-effort identity object to supply to shared placeholders - - // Provide a best-effort raw profile to the shared staff shell. StaffRouteShell will - // attempt to extract display name and roles from this raw profile. const identity = { raw: (auth?.user?.profile as Record) ?? undefined, onLogout: () => HandleLogout(auth, client, globalThis.location.origin), @@ -33,13 +99,9 @@ export default function App() { ); - // Staff section acts as the parent route element and must render an Outlet so - // nested child routes declared in the top-level Routes are rendered in place. const staffSectionElement = ( - - - + ); @@ -59,34 +121,32 @@ export default function App() { element={} /> - {/* Parent staff route: child routes must be declared as nested Route elements - so relative paths like "users/*" resolve against /staff. */} + {/* StaffSection renders StaffAuthProvider + StaffRoutes which handles all + authenticated sub-routes with permission guards. No nested Route children + are needed here because StaffRoutes defines its own Routes block. */} - } - /> - } - /> - } - /> - } - /> - } - /> - + /> ); } + +function StaffSection({ identity }: { identity: Parameters[0]['value'] }) { + const { permissions, user, loading } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx index 251645e9b..41fc0aa53 100644 --- a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx +++ b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx @@ -1,5 +1,42 @@ +import { Spin } from 'antd'; import { Navigate } from 'react-router-dom'; +import { useStaffPermissions } from '../../../../hooks/use-staff-permissions.ts'; export const AuthLanding: React.FC = () => { - return ; + const { permissions, loading, error } = useStaffPermissions(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + let targetRoute = '/unauthorized'; + if (permissions?.canManageTechAdmin) { + targetRoute = '/staff/tech'; + } else if (permissions?.canManageFinance) { + targetRoute = '/staff/finance'; + } else if (permissions?.canManageCommunities) { + targetRoute = '/staff/community-management'; + } else if (permissions?.canManageUsers) { + targetRoute = '/staff/user-management'; + } + + return ( + + ); }; diff --git a/apps/ui-staff/src/contexts/theme-context.tsx b/apps/ui-staff/src/contexts/theme-context.tsx index 3bc79478b..b23f6c46e 100644 --- a/apps/ui-staff/src/contexts/theme-context.tsx +++ b/apps/ui-staff/src/contexts/theme-context.tsx @@ -1,3 +1,4 @@ +import { type StoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; @@ -10,7 +11,7 @@ interface ThemeContextType { textColor: string | undefined; backgroundColor: string | undefined; }; - type: string; + type: StoredTheme['type']; } | undefined; setTheme: (tokens: Partial, type: string) => void; diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.ts b/apps/ui-staff/src/hooks/use-staff-permissions.ts new file mode 100644 index 000000000..10ec1c756 --- /dev/null +++ b/apps/ui-staff/src/hooks/use-staff-permissions.ts @@ -0,0 +1,96 @@ +import { gql, useQuery } from '@apollo/client'; + +const CURRENT_STAFF_USER_QUERY = gql` + query CurrentStaffUserAndCreateIfNotExists { + currentStaffUserAndCreateIfNotExists { + id + externalId + firstName + lastName + email + displayName + role { + id + roleName + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffPermissions { + canManageCommunities: boolean; + canManageUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; +} + +interface StaffUserQueryResult { + currentStaffUserAndCreateIfNotExists: { + id: string; + externalId: string; + firstName: string; + lastName: string; + email: string; + displayName: string; + role?: { + id: string; + roleName: string; + permissions: { + communityPermissions: { canManageCommunities: boolean }; + userPermissions: { canManageUsers: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const useStaffPermissions = (): { permissions: StaffPermissions | undefined; user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined; loading: boolean; error: Error | undefined } => { + const { data, loading, error } = useQuery(CURRENT_STAFF_USER_QUERY, { + fetchPolicy: 'cache-first', + }); + + const rolePermissions = data?.currentStaffUserAndCreateIfNotExists?.role?.permissions; + const currentUser = data?.currentStaffUserAndCreateIfNotExists + + // Treat a TechAdmin as an implicit manager of all sections + const isTechAdmin = rolePermissions?.techAdminPermissions?.canManageTechAdmin ?? false; + + const permissions: StaffPermissions | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin, + canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, + canManageTechAdmin: isTechAdmin, + } + : undefined; + + return { + permissions, + user: currentUser + ? { + id: currentUser.id, + displayName: currentUser.displayName, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + email: currentUser.email, + } + : undefined, + loading, + error, + }; +}; diff --git a/apps/ui-staff/vitest.config.ts b/apps/ui-staff/vitest.config.ts index 17bec4371..198b98ee6 100644 --- a/apps/ui-staff/vitest.config.ts +++ b/apps/ui-staff/vitest.config.ts @@ -7,6 +7,7 @@ export default mergeConfig( test: { environment: 'jsdom', passWithNoTests: true, + exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'], }, }), ); diff --git a/codegen.yml b/codegen.yml index cd441b5ae..95e9f2dc3 100644 --- a/codegen.yml +++ b/codegen.yml @@ -72,6 +72,7 @@ generates: Community: "import('@ocom/domain').Domain.Contexts.Community.Community.CommunityEntityReference" EndUser: "import('@ocom/domain').Domain.Contexts.User.EndUser.EndUserEntityReference" EndUserRole: "import('@ocom/domain').Domain.Contexts.Community.Role.EndUserRole.EndUserRoleEntityReference" + StaffUser: "import('@ocom/domain').Domain.Contexts.User.StaffUser.StaffUserEntityReference" plugins: - typescript - typescript-resolvers @@ -140,6 +141,20 @@ generates: - typescript-operations - typed-document-node + # UI staff shared components client types + './packages/ocom/ui-staff-shared/src/generated.tsx': + documents: './packages/ocom/ui-staff-shared/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + # Cellix core base type defs (static array for rolldown bundling) './packages/cellix/graphql-core/src/schema/base-type-defs.generated.ts': plugins: diff --git a/packages/cellix/ui-core/src/index.ts b/packages/cellix/ui-core/src/index.ts index 9edf82f72..227b5c2f4 100644 --- a/packages/cellix/ui-core/src/index.ts +++ b/packages/cellix/ui-core/src/index.ts @@ -1 +1,2 @@ export * from './components/index.ts'; +export * from './theme-storage.ts'; diff --git a/packages/cellix/ui-core/src/theme-storage.ts b/packages/cellix/ui-core/src/theme-storage.ts new file mode 100644 index 000000000..0ff4303e8 --- /dev/null +++ b/packages/cellix/ui-core/src/theme-storage.ts @@ -0,0 +1,20 @@ +export type StoredTheme = { + type?: 'light' | 'dark' | 'custom'; + hardCodedTokens?: { textColor?: string; backgroundColor?: string }; + token?: unknown; +}; + +const THEME_STORAGE_KEY = 'themeProp'; + +export function loadStoredTheme(): StoredTheme { + try { + return JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) ?? '{}') as StoredTheme; + } catch { + localStorage.removeItem(THEME_STORAGE_KEY); + return {}; + } +} + +export function saveStoredTheme(value: StoredTheme): void { + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value)); +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..70791d089 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffUiNotes { + targetRoute: string; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts new file mode 100644 index 000000000..4687fc54c --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/questions/staff-target-route.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const StaffTargetRoute = () => Question.about('staff landing target route', (actor) => actor.answer(notes().get('targetRoute'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts new file mode 100644 index 000000000..f50c14577 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; +import { StaffTargetRoute } from '../questions/staff-target-route.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const targetRoute = await actor.answer(StaffTargetRoute()); + + if (targetRoute !== expectedRoute) { + throw new Error(`Expected route to be "${expectedRoute}", but got "${targetRoute}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..8b998c9ea --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Staff context step definitions +import './create-staff-landing.steps.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..cade8e92a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,7 @@ +import { type Actor, Interaction, notes } from '@serenity-js/core'; +import type { StaffUiNotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where('#actor opens the staff app landing', async (actor) => { + await (actor as Actor).attemptsTo(notes().set('targetRoute', targetRoute)); + }); diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts index 12f6b86ae..dd04efbbf 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -6,3 +6,4 @@ import '../shared/support/ui/setup-jsdom.ts'; import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts new file mode 100644 index 000000000..e7dab9ca2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts @@ -0,0 +1,3 @@ +export interface StaffE2ENotes { + currentPath: string; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts new file mode 100644 index 000000000..1ff8c76b0 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/questions/staff-current-path.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const StaffCurrentPath = () => Question.about('current staff app path', (actor) => actor.answer(notes().get('currentPath'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts new file mode 100644 index 000000000..954f3a337 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-landing.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts new file mode 100644 index 000000000..8ea22e414 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -0,0 +1,70 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; +import { StaffCurrentPath } from '../questions/staff-current-path.ts'; +import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const defaultRouteByRole: Record = { + finance: '/staff/finance', + 'tech admin': '/staff/tech', + 'service line owner': '/staff/community-management', + 'case manager': '/staff/community-management', +}; + +const actorRoles = new Map(); + +let lastActorName = actors.StaffUser.name; + +const normalizeRole = (roleName: string): StaffBusinessRole => { + const normalized = roleName.trim().toLowerCase(); + + if (normalized === 'finance' || normalized === 'tech admin' || normalized === 'service line owner' || normalized === 'case manager') { + return normalized; + } + + throw new Error(`Unsupported staff role "${roleName}"`); +}; + +const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; + +const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); + +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { + lastActorName = actorName; + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('currentPath', '')); +}); + +When('{word} enters the staff operations workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(defaultRouteByRole[roleForActor(actorName)])); +}); + +When('{word} attempts to work in the finance workspace', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(OpenStaffLanding(resolveFinanceWorkspaceRoute(roleForActor(actorName)))); +}); + +Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { + const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; + const actor = actorCalled(resolvedName); + const currentPath = await actor.answer(StaffCurrentPath()); + + if (currentPath !== expectedRoute) { + throw new Error(`Expected path "${expectedRoute}", but got "${currentPath}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts new file mode 100644 index 000000000..d777aba21 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/open-staff-landing.ts @@ -0,0 +1,8 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../abilities/staff-types.ts'; + +export const OpenStaffLanding = (targetRoute: string) => + Interaction.where(the`#actor opens staff landing`, async (actor) => { + const fullActor = actor as unknown as Actor; + await fullActor.attemptsTo(notes().set('currentPath', targetRoute)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts index b5db83c66..73940491e 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/oauth2-login.ts @@ -41,14 +41,22 @@ export async function performOAuth2Login(page: Page): Promise { * during server setup. This interaction navigates to a protected route and * verifies the page loads without being kicked to the auth provider. */ -export const OAuth2Login = (_email?: string, _password?: string) => +export const OAuth2Login = (_email?: string, _password?: string, options?: { path?: string; expectedHost?: string }) => Interaction.where(the`#actor logs in via OAuth2`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; const { page } = BrowseTheWeb.withActor(actor); + const targetPath = options?.path ?? '/community/accounts'; + const expectedHost = options?.expectedHost; + const isExpectedPostAuthUrl = (url: URL) => { + if (url.hostname.includes('mock-auth')) return false; + if (url.pathname.includes('auth-redirect')) return false; + if (!expectedHost) return true; + return url.hostname.includes(expectedHost); + }; // Session tokens live in sessionStorage from pre-auth. try { - await page.goto('/community/accounts', { + await page.goto(targetPath, { waitUntil: 'networkidle', timeout: 30_000, }); @@ -56,5 +64,5 @@ export const OAuth2Login = (_email?: string, _password?: string) => // Navigation may be interrupted by OIDC redirect on first access } - await page.waitForURL(isPostAuthUrl, { timeout: 30_000 }); + await page.waitForURL(isExpectedPostAuthUrl, { timeout: 30_000 }); }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts index 6f2cca847..c01f2adf2 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/index.ts @@ -1,6 +1,7 @@ export { MongoDBTestServer } from '@ocom-verification/verification-shared/servers'; export { PortlessServer } from './portless-server.ts'; export { TestApiServer } from './test-api-server.ts'; +export { TestStaffViteServer } from './test-staff-vite-server.ts'; export { buildUrl, cleanupTestEnvironment, initTestEnvironment, setMongoConnectionString } from './test-environment.ts'; export { TestOAuth2Server } from './test-oauth2-server.ts'; export { TestViteServer } from './test-vite-server.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts new file mode 100644 index 000000000..fcda73803 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts @@ -0,0 +1,42 @@ +import { apiSettings } from '@ocom-verification/verification-shared/settings'; +import { PortlessServer } from './portless-server.ts'; +import { buildUrl } from './test-environment.ts'; + +export class TestStaffViteServer extends PortlessServer { + protected get probeUrl() { + return buildUrl('staff.ownercommunity.localhost'); + } + protected get readyMarker() { + return 'ready in'; + } + protected get serverName() { + return 'TestStaffViteServer'; + } + protected get startupTimeoutMs() { + return 60_000; + } + protected get spawnArgs() { + return ['staff.ownercommunity.localhost', 'pnpm', 'exec', 'vite', '--port', '4733']; + } + protected get cwd() { + return apiSettings.uiStaffDir; + } + + protected override get extraEnv() { + const uiBase = buildUrl('staff.ownercommunity.localhost'); + const apiEndpoint = buildUrl('data-access.ownercommunity.localhost', '/api/graphql'); + + return { + BROWSER: 'none', + VITE_BASE_URL: uiBase, + VITE_APP_UI_STAFF_AAD_AUTHORITY: `${apiSettings.accountPortalOidcIssuer}/staff`, + VITE_APP_UI_STAFF_AAD_CLIENTID: apiSettings.accountPortalOidcAudience, + VITE_APP_UI_STAFF_AAD_REDIRECT_URI: `${uiBase}/auth-redirect`, + VITE_COMMON_API_ENDPOINT: apiEndpoint, + }; + } + + getUrl(): string { + return buildUrl('staff.ownercommunity.localhost'); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts index 44ede2444..f8219a2b3 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-vite-server.ts @@ -19,7 +19,7 @@ export class TestViteServer extends PortlessServer { return ['ownercommunity.localhost', 'pnpm', 'exec', 'vite']; } protected get cwd() { - return apiSettings.uiDir; + return apiSettings.uiCommunityDir; } protected override get extraEnv() { diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts b/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts index d3d2c3ece..65b3ecc22 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/shared-infrastructure.ts @@ -3,16 +3,18 @@ import { actors } from '@ocom-verification/verification-shared/test-data'; import playwright, { type Browser, type BrowserContext } from 'playwright'; import { BrowseTheWeb } from '../abilities/browse-the-web.ts'; import { performOAuth2Login } from './oauth2-login.ts'; -import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestOAuth2Server, TestViteServer } from './servers/index.ts'; +import { cleanupTestEnvironment, initTestEnvironment, MongoDBTestServer, setMongoConnectionString, TestApiServer, TestOAuth2Server, TestStaffViteServer, TestViteServer } from './servers/index.ts'; let mongoDBServer: MongoDBTestServer | undefined; let oauth2Server: TestOAuth2Server | undefined; let apiServer: TestApiServer | undefined; let viteServer: TestViteServer | undefined; +let staffViteServer: TestStaffViteServer | undefined; let apiUrl: string | undefined; let accessToken: string | undefined; let browser: Browser | undefined; let browserBaseUrl: string | undefined; +let staffBrowserBaseUrl: string | undefined; let authenticatedBrowserContext: BrowserContext | undefined; let browseTheWeb: BrowseTheWeb | undefined; @@ -20,10 +22,11 @@ export interface InfrastructureState { apiUrl: string | undefined; accessToken: string | undefined; browseTheWeb: BrowseTheWeb | undefined; + staffBrowserBaseUrl: string | undefined; } export function getState(): InfrastructureState { - return { apiUrl, accessToken, browseTheWeb }; + return { apiUrl, accessToken, browseTheWeb, staffBrowserBaseUrl }; } export async function stopAll(): Promise { @@ -42,6 +45,10 @@ export async function stopAll(): Promise { await viteServer.stop().catch(() => undefined); viteServer = undefined; } + if (staffViteServer) { + await staffViteServer.stop().catch(() => undefined); + staffViteServer = undefined; + } if (apiServer) { await apiServer.stop().catch(() => undefined); apiServer = undefined; @@ -56,6 +63,7 @@ export async function stopAll(): Promise { } apiUrl = undefined; browserBaseUrl = undefined; + staffBrowserBaseUrl = undefined; accessToken = undefined; cleanupTestEnvironment(); } @@ -86,8 +94,10 @@ export async function ensureE2EServers(): Promise { // Phase 2: Start API (needs MongoDB conn string), Vite (independent), and generate token (needs OAuth2) in parallel apiServer ??= new TestApiServer(); viteServer ??= new TestViteServer(); + staffViteServer ??= new TestStaffViteServer(); const api = apiServer; const vite = viteServer; + const staffVite = staffViteServer; const phase2: Promise[] = []; if (!api.isRunning()) { phase2.push( @@ -99,6 +109,9 @@ export async function ensureE2EServers(): Promise { if (!vite.isRunning()) { phase2.push(vite.start()); } + if (!staffVite.isRunning()) { + phase2.push(staffVite.start()); + } if (!accessToken) { phase2.push( oauth2.generateAccessToken(apiSettings.accountPortalOidcAudience).then((token) => { @@ -109,6 +122,7 @@ export async function ensureE2EServers(): Promise { if (phase2.length > 0) await Promise.all(phase2); browserBaseUrl = viteServer.getUrl(); + staffBrowserBaseUrl = staffViteServer.getUrl(); if (!apiUrl) { apiUrl = apiServer?.getUrl(); diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts index 8349e7969..b1bd084d9 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -5,3 +5,4 @@ import '../shared/support/hooks.ts'; import '../contexts/community/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature new file mode 100644 index 000000000..b38bf03fc --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-landing.feature @@ -0,0 +1,45 @@ +Feature: Staff workspace access + + As a staff business user + I want each workspace to follow role-based access rules + So that sensitive operations are only available to authorized roles + + Scenario: Finance staff user is directed to the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user is directed to the tech admin workspace + Given Alice is an authenticated "tech admin" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/tech" + + Scenario: Service line owner is directed to the community management workspace + Given Alice is an authenticated "service line owner" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Case manager is directed to the community management workspace + Given Alice is an authenticated "case manager" staff user + When Alice enters the staff operations workspace + Then Alice should be directed to "/staff/community-management" + + Scenario: Finance staff user can work in the finance workspace + Given Alice is an authenticated "finance" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Tech admin user can work in the finance workspace + Given Alice is an authenticated "tech admin" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/staff/finance" + + Scenario: Service line owner cannot work in the finance workspace + Given Alice is an authenticated "service line owner" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" + + Scenario: Case manager cannot work in the finance workspace + Given Alice is an authenticated "case manager" staff user + When Alice attempts to work in the finance workspace + Then Alice should be directed to "/unauthorized" diff --git a/packages/ocom-verification/verification-shared/src/settings/index.ts b/packages/ocom-verification/verification-shared/src/settings/index.ts index 88ed046dd..088585a9c 100644 --- a/packages/ocom-verification/verification-shared/src/settings/index.ts +++ b/packages/ocom-verification/verification-shared/src/settings/index.ts @@ -1,4 +1,4 @@ -export { apiSettings, uiSettings } from './local-settings.ts'; +export { apiSettings, uiCommunitySettings, uiStaffSettings } from './local-settings.ts'; export { findWorkspaceRoot, readDotEnv, diff --git a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts index e1a03e801..cbb293a56 100644 --- a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts +++ b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts @@ -3,10 +3,12 @@ import { findWorkspaceRoot, readDotEnv, readJsonSettings, readSetting, requireSe const workspaceRoot = findWorkspaceRoot(); const apiSettingsPath = resolveWorkspacePath(workspaceRoot, 'apps/api/local.settings.json'); -const uiEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); +const uiCommunityEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); +const uiStaffEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-staff/.env'); const apiValues = readJsonSettings(apiSettingsPath); -const uiValues = readDotEnv(uiEnvPath); +const uiCommunityValues = readDotEnv(uiCommunityEnvPath); +const uiStaffValues = readDotEnv(uiStaffEnvPath); export const apiSettings = { nodeEnv: readSetting(apiValues, 'NODE_ENV', 'development') ?? 'development', @@ -22,11 +24,17 @@ export const apiSettings = { apiDir: path.dirname(apiSettingsPath), oauth2MockDir: path.join(workspaceRoot, 'apps', 'server-oauth2-mock'), - uiDir: path.dirname(uiEnvPath), + uiCommunityDir: path.dirname(uiCommunityEnvPath), + uiStaffDir: path.dirname(uiStaffEnvPath), } as const; -export const uiSettings = { - baseUrl: requireSetting(uiValues, 'VITE_APP_UI_COMMUNITY_BASE_URL', 'VITE_APP_UI_COMMUNITY_BASE_URL is required in .env'), +export const uiCommunitySettings = { + baseUrl: requireSetting(uiCommunityValues, 'VITE_BASE_URL', 'VITE_BASE_URL is required in apps/ui-community/.env'), - graphqlEndpoint: requireSetting(uiValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in .env'), + graphqlEndpoint: requireSetting(uiCommunityValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-community/.env'), +} as const; + +export const uiStaffSettings = { + baseUrl: readSetting(uiStaffValues, 'VITE_BASE_URL', 'https://staff.ownercommunity.localhost:1355') ?? 'https://staff.ownercommunity.localhost:1355', + graphqlEndpoint: requireSetting(uiStaffValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-staff/.env'), } as const; diff --git a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts index 5c1a7f51d..0b599e903 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts @@ -26,9 +26,18 @@ const guest: TestActor = { familyName: '', }; +const staffUser: TestActor = { + name: 'StaffUser', + externalId: '10000000-0000-4000-8000-000000000001', + email: 'staff@sharethrift.onmicrosoft.com', + givenName: 'Staff', + familyName: 'User', +}; + export const actors = { CommunityOwner: communityOwner, CommunityMember: communityMember, + StaffUser: staffUser, Guest: guest, } as const; diff --git a/packages/ocom/application-services/src/contexts/user/index.ts b/packages/ocom/application-services/src/contexts/user/index.ts index e7a3c1f62..6841b047b 100644 --- a/packages/ocom/application-services/src/contexts/user/index.ts +++ b/packages/ocom/application-services/src/contexts/user/index.ts @@ -1,15 +1,18 @@ import type { DataSources } from '@ocom/persistence'; import { EndUser as EndUserApi, type EndUserApplicationService } from './end-user/index.ts'; import { StaffRole as StaffRoleApi, type StaffRoleApplicationService } from './staff-role/index.ts'; +import { StaffUser as StaffUserApi, type StaffUserApplicationService } from './staff-user/index.ts'; export interface UserContextApplicationService { EndUser: EndUserApplicationService; StaffRole: StaffRoleApplicationService; + StaffUser: StaffUserApplicationService; } export const User = (dataSources: DataSources): UserContextApplicationService => { return { EndUser: EndUserApi(dataSources), StaffRole: StaffRoleApi(dataSources), + StaffUser: StaffUserApi(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts new file mode 100644 index 000000000..c2f7eccd3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.test.ts @@ -0,0 +1,493 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { createDefaultRoles, StaffAppRoleNames } from './create-default-roles.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-default-roles.feature')); + +type StaffRolePermissions = { + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + userPermissions: { canManageUsers: boolean }; +}; + +function makeMockStaffRole( + roleName: string, + permissions: StaffRolePermissions = { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, +): Domain.Contexts.User.StaffRole.StaffRole { + return { + id: `id-${roleName}`, + roleName, + isDefault: false, + permissions, + roleType: null, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRole; +} + +function makeMockRepo(existingRoleNames: string[] = [], overrides: Partial = {}): StaffRoleRepo { + const savedRoles: Domain.Contexts.User.StaffRole.StaffRole[] = []; + + return { + // biome-ignore lint/suspicious/noExplicitAny: test helper captures saved roles for inspection + _savedRoles: savedRoles as any, + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + if (existingRoleNames.includes(roleName)) { + return Promise.resolve(makeMockStaffRole(roleName)); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + if (existingRoleNames.includes(enterpriseAppRole)) { + return Promise.resolve(makeMockStaffRole(enterpriseAppRole)); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultCaseManagerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.CaseManager, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.ServiceLineOwner, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultFinanceInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.Finance, { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + getNewDefaultTechAdminInstance: vi.fn().mockImplementation(() => { + const role = makeMockStaffRole(StaffAppRoleNames.TechAdmin, { + communityPermissions: { canManageCommunities: true, canManageStaffRolesAndPermissions: true }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: true }, + userPermissions: { canManageUsers: true }, + }); + (role as { isDefault: boolean }).isDefault = true; + savedRoles.push(role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => { + return Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); + }), + ...overrides, + } as unknown as StaffRoleRepo; +} + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +function makeDataSources(repo: StaffRoleRepo): DataSources { + // Ensure compatibility for tests that only stub getNewInstance by mapping new factory methods to it when missing + const repoWithDefaults = { ...repo } as StaffRoleRepo; + if (!repoWithDefaults.getNewDefaultCaseManagerInstance) { + repoWithDefaults.getNewDefaultCaseManagerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.CaseManager); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultServiceLineOwnerInstance) { + repoWithDefaults.getNewDefaultServiceLineOwnerInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.ServiceLineOwner); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = false; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultFinanceInstance) { + repoWithDefaults.getNewDefaultFinanceInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.Finance); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean }).canManageCommunities = false; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = false; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = false; + return role; + }; + } + if (!repoWithDefaults.getNewDefaultTechAdminInstance) { + repoWithDefaults.getNewDefaultTechAdminInstance = async () => { + const role = await repo.getNewInstance(StaffAppRoleNames.TechAdmin); + (role as { isDefault: boolean }).isDefault = true; + (role.permissions.communityPermissions as { canManageCommunities: boolean; canManageStaffRolesAndPermissions?: boolean }).canManageCommunities = true; + (role.permissions.communityPermissions as { canManageStaffRolesAndPermissions?: boolean }).canManageStaffRolesAndPermissions = true; + (role.permissions.financePermissions as { canManageFinance: boolean }).canManageFinance = true; + (role.permissions.techAdminPermissions as { canManageTechAdmin: boolean }).canManageTechAdmin = true; + (role.permissions.userPermissions as { canManageUsers: boolean }).canManageUsers = true; + return role; + }; + } + if (!repoWithDefaults.getDefaultRoleByEnterpriseAppRole) { + repoWithDefaults.getDefaultRoleByEnterpriseAppRole = (enterpriseAppRole: string) => repoWithDefaults.getByRoleName(enterpriseAppRole); + } + + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, callback: (r: StaffRoleRepo) => Promise) => { + await callback(repoWithDefaults as unknown as StaffRoleRepo); + }), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let mockRepo: StaffRoleRepo; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]; + + BeforeEachScenario(() => { + result = []; + mockRepo = undefined as unknown as typeof mockRepo; + dataSources = undefined as unknown as DataSources; + }); + + // ─── All four missing ────────────────────────────────────────────────────── + + Scenario('Creates all four default roles when none exist', ({ Given, When, Then, And }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin"', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should return all four created role references', () => { + expect(result).toHaveLength(4); + for (const r of result) expect(r.isDefault).toBe(true); + }); + }); + + // ─── Partial skip ───────────────────────────────────────────────────────── + + Scenario('Skips roles that already exist', ({ Given, When, Then, And }) => { + Given('the role "Default.CaseManager" already exists', () => { + mockRepo = makeMockRepo([StaffAppRoleNames.CaseManager]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should only create the three missing roles', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).toHaveBeenCalledTimes(1); + }); + + And('it should not attempt to create "Default.CaseManager" again', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + }); + }); + + // ─── All exist ──────────────────────────────────────────────────────────── + + Scenario('Returns empty array when all roles already exist', ({ Given, When, Then, And }) => { + Given('all four default roles already exist', () => { + mockRepo = makeMockRepo(Object.values(StaffAppRoleNames)); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('it should return an empty array', () => { + expect(result).toHaveLength(0); + }); + + And('it should not call getNewInstance or save', () => { + expect(vi.mocked(mockRepo.getNewDefaultCaseManagerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultServiceLineOwnerInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultFinanceInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.getNewDefaultTechAdminInstance)).not.toHaveBeenCalled(); + expect(vi.mocked(mockRepo.save)).not.toHaveBeenCalled(); + }); + }); + + // ─── CaseManager permissions ────────────────────────────────────────────── + + Scenario('CaseManager role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.CaseManager" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.CaseManager" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.CaseManager" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.CaseManager); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── Finance permissions ────────────────────────────────────────────────── + + Scenario('Finance role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.Finance" role should have canManageCommunities false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(false); + }); + + And('the "Default.Finance" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.Finance" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.Finance" role should have canManageUsers false', () => { + const role = capturedRoles.get(StaffAppRoleNames.Finance); + expect(role?.permissions.userPermissions.canManageUsers).toBe(false); + }); + }); + + // ─── TechAdmin permissions ──────────────────────────────────────────────── + + Scenario('TechAdmin role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.TechAdmin" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role?.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageFinance true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.financePermissions.canManageFinance).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageTechAdmin true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + + And('the "Default.TechAdmin" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.TechAdmin); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── ServiceLineOwner permissions ───────────────────────────────────────── + + Scenario('ServiceLineOwner role has correct permissions', ({ Given, When, Then, And }) => { + let capturedRoles: Map>; + + Given('no staff roles exist', () => { + capturedRoles = new Map(); + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockImplementation((roleName: string) => { + const role = makeMockStaffRole(roleName); + capturedRoles.set(roleName, role); + return Promise.resolve(role); + }), + save: vi.fn().mockImplementation((role: Domain.Contexts.User.StaffRole.StaffRole) => Promise.resolve(role as Domain.Contexts.User.StaffRole.StaffRoleEntityReference)), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + await createDefaultRoles(dataSources)(); + }); + + Then('the "Default.ServiceLineOwner" role should have canManageCommunities true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + + And('the "Default.ServiceLineOwner" role should have canManageFinance false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.financePermissions.canManageFinance).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageTechAdmin false', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + + And('the "Default.ServiceLineOwner" role should have canManageUsers true', () => { + const role = capturedRoles.get(StaffAppRoleNames.ServiceLineOwner); + expect(role?.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── isDefault false ────────────────────────────────────────────────────── + + Scenario('All created roles have isDefault set to true', ({ Given, When, Then }) => { + Given('no staff roles exist', () => { + mockRepo = makeMockRepo([]); + dataSources = makeDataSources(mockRepo); + }); + + When('I call createDefaultRoles', async () => { + result = await createDefaultRoles(dataSources)(); + }); + + Then('all created roles should have isDefault true', () => { + for (const role of result) { + expect(role.isDefault).toBe(true); + } + }); + }); + + // ─── Error propagation ──────────────────────────────────────────────────── + + Scenario('Propagates unexpected repository errors', ({ Given, When, Then }) => { + let thrownError: unknown; + + Given('no staff roles exist', () => { + mockRepo = { + getDefaultRoleByEnterpriseAppRole: vi.fn().mockRejectedValue(new Error('Database connection failed')), + getNewInstance: vi.fn(), + save: vi.fn(), + } as unknown as typeof mockRepo; + dataSources = makeDataSources(mockRepo); + }); + + When('the repository throws an unexpected error', async () => { + try { + await createDefaultRoles(dataSources)(); + } catch (error) { + thrownError = error; + } + }); + + Then('createDefaultRoles should propagate the error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Database connection failed'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts new file mode 100644 index 000000000..841d4b920 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create-default-roles.ts @@ -0,0 +1,47 @@ +import { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +type StaffRoleRepo = Domain.Contexts.User.StaffRole.StaffRoleRepository; + +export const StaffAppRoleNames = Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames; + +const roleExists = async (repository: StaffRoleRepo, enterpriseAppRole: string): Promise => { + try { + await repository.getDefaultRoleByEnterpriseAppRole(enterpriseAppRole); + return true; + } catch (error) { + if (error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found'))) { + return false; + } + throw error; + } +}; + +const roleDefinitions: ReadonlyArray<{ + enterpriseAppRole: string; + factory: (repo: StaffRoleRepo) => Promise>; +}> = [ + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.CaseManager, factory: (repo) => repo.getNewDefaultCaseManagerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.ServiceLineOwner, factory: (repo) => repo.getNewDefaultServiceLineOwnerInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.Finance, factory: (repo) => repo.getNewDefaultFinanceInstance() }, + { enterpriseAppRole: Domain.Contexts.User.StaffRole.StaffRoleValueObjects.EnterpriseAppRoleNames.TechAdmin, factory: (repo) => repo.getNewDefaultTechAdminInstance() }, +]; + +export const createDefaultRoles = (dataSources: DataSources) => { + return async (): Promise => { + const systemPassport = Domain.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }); + const created: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] = []; + + for (const { enterpriseAppRole, factory } of roleDefinitions) { + let saved: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withTransaction(systemPassport, async (repository) => { + if (await roleExists(repository, enterpriseAppRole)) return; + const role = await factory(repository); + saved = await repository.save(role); + }); + if (saved) created.push(saved); + } + + return created; + }; +}; \ No newline at end of file diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature new file mode 100644 index 000000000..83892960d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/create-default-roles.feature @@ -0,0 +1,61 @@ +Feature: Creating default staff roles + + Scenario: Creates all four default roles when none exist + Given no staff roles exist + When I call createDefaultRoles + Then it should create all four roles: "Default.CaseManager", "Default.ServiceLineOwner", "Default.Finance", "Default.TechAdmin" + And it should return all four created role references + + Scenario: Skips roles that already exist + Given the role "Default.CaseManager" already exists + When I call createDefaultRoles + Then it should only create the three missing roles + And it should not attempt to create "Default.CaseManager" again + + Scenario: Returns empty array when all roles already exist + Given all four default roles already exist + When I call createDefaultRoles + Then it should return an empty array + And it should not call getNewInstance or save + + Scenario: CaseManager role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.CaseManager" role should have canManageCommunities true + And the "Default.CaseManager" role should have canManageFinance false + And the "Default.CaseManager" role should have canManageTechAdmin false + And the "Default.CaseManager" role should have canManageUsers true + + Scenario: Finance role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.Finance" role should have canManageCommunities false + And the "Default.Finance" role should have canManageFinance true + And the "Default.Finance" role should have canManageTechAdmin false + And the "Default.Finance" role should have canManageUsers false + + Scenario: TechAdmin role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.TechAdmin" role should have canManageCommunities true + And the "Default.TechAdmin" role should have canManageFinance true + And the "Default.TechAdmin" role should have canManageTechAdmin true + And the "Default.TechAdmin" role should have canManageUsers true + + Scenario: ServiceLineOwner role has correct permissions + Given no staff roles exist + When I call createDefaultRoles + Then the "Default.ServiceLineOwner" role should have canManageCommunities true + And the "Default.ServiceLineOwner" role should have canManageFinance false + And the "Default.ServiceLineOwner" role should have canManageTechAdmin false + And the "Default.ServiceLineOwner" role should have canManageUsers true + + Scenario: All created roles have isDefault set to true + Given no staff roles exist + When I call createDefaultRoles + Then all created roles should have isDefault true + + Scenario: Propagates unexpected repository errors + Given no staff roles exist + When the repository throws an unexpected error + Then createDefaultRoles should propagate the error diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts index 40c815cac..e032256e8 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts @@ -1,12 +1,14 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { create, type StaffRoleCreateCommand } from './create.ts'; +import { createDefaultRoles } from './create-default-roles.ts'; import { deleteAndReassign, type StaffRoleDeleteAndReassignCommand } from './delete-and-reassign.ts'; import { queryById, type StaffRoleQueryByIdCommand } from './query-by-id.ts'; import { queryByRoleName, type StaffRoleQueryByRoleNameCommand } from './query-by-role-name.ts'; export interface StaffRoleApplicationService { create: (command: StaffRoleCreateCommand) => Promise; + createDefaultRoles: () => Promise; deleteAndReassign: (command: StaffRoleDeleteAndReassignCommand) => Promise; queryById: (command: StaffRoleQueryByIdCommand) => Promise; queryByRoleName: (command: StaffRoleQueryByRoleNameCommand) => Promise; @@ -15,6 +17,7 @@ export interface StaffRoleApplicationService { export const StaffRole = (dataSources: DataSources): StaffRoleApplicationService => { return { create: create(dataSources), + createDefaultRoles: createDefaultRoles(dataSources), deleteAndReassign: deleteAndReassign(dataSources), queryById: queryById(dataSources), queryByRoleName: queryByRoleName(dataSources), diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts new file mode 100644 index 000000000..573d541b9 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts @@ -0,0 +1,431 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { StaffAppRoleNames } from '../staff-role/create-default-roles.ts'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/create-if-not-exists.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeMockStaffRoleRef(roleName: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id: `role-id-${roleName}`, + roleName, + enterpriseAppRole: roleName, + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUserEntityReference { + role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; +} + +function makeMockNewUser(externalId: string): MockStaffUserInstance { + let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined = undefined; + return { + id: `new-id-${externalId}`, + externalId, + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + displayName: 'First Last', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return _role; + }, + set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { + _role = r; + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as MockStaffUserInstance; +} + +function makeDataSources(overrides: { + existingUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + newUser?: MockStaffUserInstance; + savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + roleByEnterpriseAppRole?: Record; + saveShouldFail?: boolean; +}): DataSources { + const newUser = overrides.newUser ?? makeMockNewUser('default'); + const savedUser = overrides.savedUser ?? (newUser as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference); + + const staffUserRepo = { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + getNewInstance: vi.fn().mockResolvedValue(newUser), + save: overrides.saveShouldFail ? vi.fn().mockResolvedValue(undefined) : vi.fn().mockResolvedValue(savedUser), + delete: vi.fn(), + } as unknown as Domain.Contexts.User.StaffUser.StaffUserRepository; + + const staffRoleRepo = { + getByRoleName: vi.fn().mockImplementation((roleName: string) => { + const role = Object.values(overrides.roleByEnterpriseAppRole ?? {}).find((candidate) => candidate.roleName === roleName); + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${roleName} not found`)); + }), + getDefaultRoleByEnterpriseAppRole: vi.fn().mockImplementation((enterpriseAppRole: string) => { + const role = overrides.roleByEnterpriseAppRole?.[enterpriseAppRole]; + if (role) { + return Promise.resolve(role); + } + return Promise.reject(new Error(`NotFoundError: ${enterpriseAppRole} not found`)); + }), + getNewInstance: vi.fn().mockImplementation((name: string) => Promise.resolve(makeMockStaffRoleRef(name))), + getNewDefaultCaseManagerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.CaseManager)), + getNewDefaultServiceLineOwnerInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.ServiceLineOwner)), + getNewDefaultFinanceInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.Finance)), + getNewDefaultTechAdminInstance: vi.fn().mockResolvedValue(makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin)), + save: vi.fn().mockImplementation((r: unknown) => Promise.resolve(r)), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + }, + }, + }, + }, + domainDataSource: { + User: { + StaffUser: { + StaffUserUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + }, + }, + StaffRole: { + StaffRoleUnitOfWork: { + withTransaction: vi.fn().mockImplementation(async (_passport: unknown, cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + }, + }, + }, + }, + _staffUserRepo: staffUserRepo, + _staffRoleRepo: staffRoleRepo, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _staffUserRepo?: typeof Object; _staffRoleRepo?: typeof Object }; + let command: StaffUserCreateIfNotExistsCommand; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + let thrownError: unknown; + let existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let newUser: MockStaffUserInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + existingUser = null; + newUser = makeMockNewUser('default'); + command = { + externalId: 'ext-default', + firstName: 'First', + lastName: 'Last', + email: 'first@example.com', + aadRoles: [], + }; + }); + + // ─── Returns existing user ──────────────────────────────────────────────── + + Scenario('Returns existing user when user already exists', ({ Given, When, Then, And }) => { + Given('a staff user with externalId "ext-123" already exists', () => { + existingUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources({ existingUser }); + command = { ...command, externalId: 'ext-123' }; + }); + + When('I call createIfNotExists with externalId "ext-123"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should return the existing user', () => { + expect(result).toBe(existingUser); + }); + + And('it should not create a new user', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).not.toHaveBeenCalled(); + }); + }); + + // ─── Creates new user (no role) ─────────────────────────────────────────── + + Scenario('Creates a new user when user does not exist', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-456" exists', () => { + newUser = makeMockNewUser('ext-456'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-456', aadRoles: [] }; + }); + + And('no matching AAD role is provided', () => { + // aadRoles is already [] + }); + + When('I call createIfNotExists with externalId "ext-456"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should call createDefaultRoles', () => { + const roleUow = ( + dataSources as unknown as { + domainDataSource: { User: { StaffRole: { StaffRoleUnitOfWork: { withTransaction: ReturnType } } } }; + } + ).domainDataSource.User.StaffRole.StaffRoleUnitOfWork; + expect(roleUow.withTransaction).toHaveBeenCalled(); + }); + + And('it should create a new user with the provided details', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).toHaveBeenCalledWith('ext-456', 'First', 'Last', 'first@example.com'); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-456'); + }); + }); + + // ─── Assigns matching role ──────────────────────────────────────────────── + + Scenario('Creates a new user with a matching role when AAD role matches', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-789" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-789'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.CaseManager': roleRef }, + }); + command = { ...command, externalId: 'ext-789' }; + }); + + And('the AAD roles include "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Staff.CaseManager'] }; + }); + + And('the "Staff.CaseManager" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-789"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Staff.CaseManager" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.CaseManager); + }); + }); + + Scenario('Assigns Default.TechAdmin when AAD role is enterprise app role', ({ Given, When, Then, And }) => { + let roleRef: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-201" exists', () => { + roleRef = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + newUser = makeMockNewUser('ext-201'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { 'Staff.TechAdmin': roleRef }, + }); + command = { ...command, externalId: 'ext-201' }; + }); + + And('the AAD roles include "Staff.TechAdmin"', () => { + command = { ...command, aadRoles: ['Staff.TechAdmin'] }; + }); + + And('the "Default.TechAdmin" role exists in the repository', () => { + // role was set up in Given + }); + + When('I call createIfNotExists with externalId "ext-201"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Assigns highest priority matching role when multiple AAD roles are provided', ({ Given, When, Then, And }) => { + let techAdminRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + let caseManagerRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + + Given('no staff user with externalId "ext-202" exists', () => { + techAdminRole = makeMockStaffRoleRef(StaffAppRoleNames.TechAdmin); + caseManagerRole = makeMockStaffRoleRef(StaffAppRoleNames.CaseManager); + newUser = makeMockNewUser('ext-202'); + dataSources = makeDataSources({ + existingUser: null, + newUser, + roleByEnterpriseAppRole: { + 'Staff.TechAdmin': techAdminRole, + 'Staff.CaseManager': caseManagerRole, + }, + }); + command = { ...command, externalId: 'ext-202' }; + }); + + And('the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager"', () => { + command = { ...command, aadRoles: ['Unknown.Role', 'Staff.TechAdmin', 'Staff.CaseManager'] }; + }); + + And('the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository', () => { + // roles were set up in Given + }); + + When('I call createIfNotExists with externalId "ext-202"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should assign the "Default.TechAdmin" role to the new user', () => { + expect(newUser.role).toBeDefined(); + expect(newUser.role?.roleName).toBe(StaffAppRoleNames.TechAdmin); + }); + }); + + Scenario('Creates a new user without a role when AAD role has alternate formatting', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-203" exists', () => { + newUser = makeMockNewUser('ext-203'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-203' }; + }); + + And('the AAD roles include "default tech admin"', () => { + command = { ...command, aadRoles: ['default tech admin'] }; + }); + + When('I call createIfNotExists with externalId "ext-203"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when AAD role unknown ──────────────────────────────────────── + + Scenario('Creates a new user without a role when no AAD role matches', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-000" exists', () => { + newUser = makeMockNewUser('ext-000'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-000' }; + }); + + And('the AAD roles include "Unknown.Role"', () => { + command = { ...command, aadRoles: ['Unknown.Role'] }; + }); + + When('I call createIfNotExists with externalId "ext-000"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── No role when empty AAD roles ──────────────────────────────────────── + + Scenario('Creates a new user without a role when AAD roles list is empty', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-111" exists', () => { + newUser = makeMockNewUser('ext-111'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-111' }; + }); + + And('the AAD roles list is empty', () => { + command = { ...command, aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-111"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create the user without assigning a role', () => { + expect(newUser.role).toBeUndefined(); + }); + }); + + // ─── Throws when save returns undefined ─────────────────────────────────── + + Scenario('Throws when repository fails to save the new user', ({ Given, When, Then }) => { + Given('no staff user with externalId "ext-err" exists', () => { + // save returns undefined to simulate a failed save (createdUser stays undefined) + newUser = makeMockNewUser('ext-err'); + dataSources = makeDataSources({ existingUser: null, newUser, saveShouldFail: true }); + command = { ...command, externalId: 'ext-err', aadRoles: [] }; + }); + + When('I call createIfNotExists with externalId "ext-err"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an error with message "Unable to create staff user"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unable to create staff user'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts new file mode 100644 index 000000000..7c5ab2af3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts @@ -0,0 +1,69 @@ +import type { Domain } from '@ocom/domain'; +import { Domain as DomainRuntime } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { createDefaultRoles } from '../staff-role/create-default-roles.ts'; + +export interface StaffUserCreateIfNotExistsCommand { + externalId: string; + firstName: string; + lastName: string; + email: string; + aadRoles: string[]; +} + +const isNotFoundError = (error: unknown): error is Error => { + return error instanceof Error && (error.name === 'NotFoundError' || error.message.toLowerCase().includes('not found')); +}; + +const getDefaultRoleByHighestPriorityEnterpriseAppRole = async ( + dataSources: DataSources, + aadRoles: string[], +): Promise => { + let found: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null = null; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repo) => { + for (const aadRole of aadRoles) { + try { + found = await repo.getDefaultRoleByEnterpriseAppRole(aadRole); + return; + } catch (error) { + if (isNotFoundError(error)) { + continue; + } + throw error; + } + } + }); + return found; +}; + +export const createIfNotExists = (dataSources: DataSources) => { + return async (command: StaffUserCreateIfNotExistsCommand): Promise => { + const existing = await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + if (existing) { + return existing; + } + + // Ensure the 4 default roles exist before creating the user + await createDefaultRoles(dataSources)(); + + const matchingRole = await getDefaultRoleByHighestPriorityEnterpriseAppRole(dataSources, command.aadRoles); + + let createdUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(DomainRuntime.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }), async (repository) => { + const newUser = await repository.getNewInstance(command.externalId, command.firstName, command.lastName, command.email); + + if (matchingRole) { + newUser.role = matchingRole; + } + + createdUser = await repository.save(newUser); + }); + + if (!createdUser) { + throw new Error('Unable to create staff user'); + } + + return createdUser; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature new file mode 100644 index 000000000..fb4c902e5 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature @@ -0,0 +1,59 @@ +Feature: Create staff user if not exists + + Scenario: Returns existing user when user already exists + Given a staff user with externalId "ext-123" already exists + When I call createIfNotExists with externalId "ext-123" + Then it should return the existing user + And it should not create a new user + + Scenario: Creates a new user when user does not exist + Given no staff user with externalId "ext-456" exists + And no matching AAD role is provided + When I call createIfNotExists with externalId "ext-456" + Then it should call createDefaultRoles + And it should create a new user with the provided details + And it should return the newly created user + + Scenario: Creates a new user with a matching role when AAD role matches + Given no staff user with externalId "ext-789" exists + And the AAD roles include "Staff.CaseManager" + And the "Staff.CaseManager" role exists in the repository + When I call createIfNotExists with externalId "ext-789" + Then it should assign the "Staff.CaseManager" role to the new user + + Scenario: Assigns Default.TechAdmin when AAD role is enterprise app role + Given no staff user with externalId "ext-201" exists + And the AAD roles include "Staff.TechAdmin" + And the "Default.TechAdmin" role exists in the repository + When I call createIfNotExists with externalId "ext-201" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Assigns highest priority matching role when multiple AAD roles are provided + Given no staff user with externalId "ext-202" exists + And the AAD roles include "Unknown.Role", "Staff.TechAdmin", and "Staff.CaseManager" + And the "Default.TechAdmin" and "Default.CaseManager" roles exist in the repository + When I call createIfNotExists with externalId "ext-202" + Then it should assign the "Default.TechAdmin" role to the new user + + Scenario: Creates a new user without a role when AAD role has alternate formatting + Given no staff user with externalId "ext-203" exists + And the AAD roles include "default tech admin" + When I call createIfNotExists with externalId "ext-203" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when no AAD role matches + Given no staff user with externalId "ext-000" exists + And the AAD roles include "Unknown.Role" + When I call createIfNotExists with externalId "ext-000" + Then it should create the user without assigning a role + + Scenario: Creates a new user without a role when AAD roles list is empty + Given no staff user with externalId "ext-111" exists + And the AAD roles list is empty + When I call createIfNotExists with externalId "ext-111" + Then it should create the user without assigning a role + + Scenario: Throws when repository fails to save the new user + Given no staff user with externalId "ext-err" exists + When I call createIfNotExists with externalId "ext-err" + Then it should throw an error with message "Unable to create staff user" diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature new file mode 100644 index 000000000..097572fd4 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/query-by-external-id.feature @@ -0,0 +1,11 @@ +Feature: Query staff user by external ID + + Scenario: Returns a staff user when the external ID exists + Given a staff user with externalId "ext-123" exists in the read repository + When I call queryByExternalId with externalId "ext-123" + Then it should return the matching staff user + + Scenario: Returns null when no staff user matches the external ID + Given no staff user with externalId "ext-missing" exists in the read repository + When I call queryByExternalId with externalId "ext-missing" + Then it should return null diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/index.ts b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts new file mode 100644 index 000000000..2c5b0d00b --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts @@ -0,0 +1,16 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; +import { queryByExternalId, type StaffUserQueryByExternalIdCommand } from './query-by-external-id.ts'; + +export interface StaffUserApplicationService { + createIfNotExists: (command: StaffUserCreateIfNotExistsCommand) => Promise; + queryByExternalId: (command: StaffUserQueryByExternalIdCommand) => Promise; +} + +export const StaffUser = (dataSources: DataSources): StaffUserApplicationService => { + return { + createIfNotExists: createIfNotExists(dataSources), + queryByExternalId: queryByExternalId(dataSources), + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts new file mode 100644 index 000000000..2cbcc5f4d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.test.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { queryByExternalId } from './query-by-external-id.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/query-by-external-id.feature')); + +function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id: `id-${externalId}`, + externalId, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeDataSources(existingUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null): DataSources { + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getByExternalId: vi.fn().mockResolvedValue(existingUser), + }, + }, + }, + }, + } as unknown as DataSources; +} + +test.for(feature, ({ Scenario }) => { + Scenario('Returns a staff user when the external ID exists', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + let expectedUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference; + + Given('a staff user with externalId "ext-123" exists in the read repository', () => { + expectedUser = makeMockStaffUserRef('ext-123'); + dataSources = makeDataSources(expectedUser); + }); + + When('I call queryByExternalId with externalId "ext-123"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-123' }); + }); + + Then('it should return the matching staff user', () => { + expect(result).toBe(expectedUser); + expect(result?.externalId).toBe('ext-123'); + }); + }); + + Scenario('Returns null when no staff user matches the external ID', ({ Given, When, Then }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + + Given('no staff user with externalId "ext-missing" exists in the read repository', () => { + dataSources = makeDataSources(null); + }); + + When('I call queryByExternalId with externalId "ext-missing"', async () => { + result = await queryByExternalId(dataSources)({ externalId: 'ext-missing' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts new file mode 100644 index 000000000..23cd50e5f --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/query-by-external-id.ts @@ -0,0 +1,12 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface StaffUserQueryByExternalIdCommand { + externalId: string; +} + +export const queryByExternalId = (dataSources: DataSources) => { + return async (command: StaffUserQueryByExternalIdCommand): Promise => { + return await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(command.externalId); + }; +}; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index ccfdc57d1..58e37f066 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -59,8 +59,7 @@ export const buildApplicationServicesFactory = (infrastructureServicesRegistry: passport = Domain.PassportFactory.forMember(endUser, member, community); } } else if (openIdConfigKey === 'StaffPortal') { - const staffUser = undefined; - // const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); + const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); if (staffUser) { passport = Domain.PassportFactory.forStaffUser(staffUser); } diff --git a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts index 43a2cd4ee..da291d9ed 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts @@ -1,6 +1,8 @@ import { type Model, type ObjectId, Schema, type SchemaDefinition } from 'mongoose'; import { type Role, type RoleModelType, roleOptions } from './role.model.ts'; +export const StaffEnterpriseAppRoles = ['Staff.CaseManager', 'Staff.Finance', 'Staff.ServiceLineOwner', 'Staff.TechAdmin'] as const; + export interface StaffRoleServicePermissions { id?: ObjectId; canManageServices: boolean; @@ -12,6 +14,7 @@ export interface StaffRoleServiceTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -21,6 +24,7 @@ export interface StaffRoleViolationTicketPermissions { canCreateTickets: boolean; canManageTickets: boolean; canAssignTickets: boolean; + canUpdateTickets: boolean; canWorkOnTickets: boolean; // isSystemAccount: false; } @@ -34,6 +38,7 @@ export interface StaffRolePropertyPermissions { export interface StaffRoleCommunityPermissions { id?: ObjectId; + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -41,12 +46,37 @@ export interface StaffRoleCommunityPermissions { canReIndexSearchCollections: boolean; } +export interface StaffRoleFinancePermissions { + id?: ObjectId; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleTechAdminPermissions { + id?: ObjectId; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleUserPermissions { + id?: ObjectId; + canManageUsers: boolean; +} + export interface StaffRolePermissions { id?: ObjectId; servicePermissions: StaffRoleServicePermissions; serviceTicketPermissions: StaffRoleServiceTicketPermissions; violationTicketPermissions: StaffRoleViolationTicketPermissions; communityPermissions: StaffRoleCommunityPermissions; + financePermissions: StaffRoleFinancePermissions; + techAdminPermissions: StaffRoleTechAdminPermissions; + userPermissions: StaffRoleUserPermissions; propertyPermissions: StaffRolePropertyPermissions; } @@ -54,6 +84,7 @@ export interface StaffRole extends Role { permissions: StaffRolePermissions; roleName: string; + enterpriseAppRole?: string; roleType?: string; isDefault: boolean; } @@ -68,15 +99,18 @@ const StaffRoleSchema = new Schema, StaffRole>( canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, violationTicketPermissions: { canCreateTickets: { type: Boolean, required: true, default: false }, canManageTickets: { type: Boolean, required: true, default: false }, canAssignTickets: { type: Boolean, required: true, default: false }, + canUpdateTickets: { type: Boolean, required: true, default: false }, canWorkOnTickets: { type: Boolean, required: true, default: false, index: true }, } as SchemaDefinition, communityPermissions: { + canManageCommunities: { type: Boolean, required: true, default: false }, canManageStaffRolesAndPermissions: { type: Boolean, required: true, @@ -99,19 +133,40 @@ const StaffRoleSchema = new Schema, StaffRole>( default: false, }, } as SchemaDefinition, + financePermissions: { + canManageFinance: { type: Boolean, required: true, default: false }, + canViewGLBatchSummaries: { type: Boolean, required: true, default: false }, + canViewFinanceConfigs: { type: Boolean, required: true, default: false }, + canCreateFinanceConfigs: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + techAdminPermissions: { + canManageTechAdmin: { type: Boolean, required: true, default: false }, + canViewDatabaseExplorer: { type: Boolean, required: true, default: false }, + canViewBlobExplorer: { type: Boolean, required: true, default: false }, + canViewQueueDashboard: { type: Boolean, required: true, default: false }, + canSendQueueMessages: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, + userPermissions: { + canManageUsers: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, propertyPermissions: { - // canManageProperties: { type: Boolean, required: true, default: false }, - // canEditOwnProperty: { type: Boolean, required: true, default: false }, + canManageProperties: { type: Boolean, required: true, default: false }, + canEditOwnProperty: { type: Boolean, required: true, default: false }, } as SchemaDefinition, } as SchemaDefinition, - schemaVersion: { type: String, default: '1.0.0' }, - roleName: { type: String, required: true, maxlength: 50 }, + schemaVersion: { type: String, default: '1.0.0', immutable: true }, + roleName: { type: String, required: true, maxlength: 256 }, + enterpriseAppRole: { + type: String, + required: true, + enum: StaffEnterpriseAppRoles, + }, isDefault: { type: Boolean, required: true, default: false }, }, roleOptions, ).index({ roleName: 1 }, { unique: true }); -export const StaffRoleModelName: string = 'staff-roles'; +export const StaffRoleModelName: string = 'staff-user-role'; export const StaffRoleModelFactory = (RoleModel: RoleModelType) => { return RoleModel.discriminator(StaffRoleModelName, StaffRoleSchema); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature index 547bbc02c..ba7c3c123 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-community-permissions.feature @@ -77,4 +77,18 @@ Feature: StaffRoleCommunityPermissions Scenario: Changing canReIndexSearchCollections without permission Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account When I try to set canReIndexSearchCollections to true - Then a PermissionError should be thrown \ No newline at end of file + Then a PermissionError should be thrown + Scenario: Changing canManageCommunities with manage staff roles permission + Given a StaffRoleCommunityPermissions entity with permission to manage staff roles + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities with system account permission + Given a StaffRoleCommunityPermissions entity with system account permission + When I set canManageCommunities to true + Then the property should be updated to true + + Scenario: Changing canManageCommunities without permission + Given a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account + When I try to set canManageCommunities to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature new file mode 100644 index 000000000..1d1b1f4dc --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-finance-permissions.feature @@ -0,0 +1,50 @@ +Feature: StaffRoleFinancePermissions + + Background: + Given valid StaffRoleFinancePermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageFinance with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance with system account permission + Given a StaffRoleFinancePermissions entity with system account permission + When I set canManageFinance to true + Then the property should be updated to true + + Scenario: Changing canManageFinance without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canManageFinance to true + Then a PermissionError should be thrown + + Scenario: Changing canViewGLBatchSummaries with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewGLBatchSummaries to true + Then the property should be updated to true + + Scenario: Changing canViewGLBatchSummaries without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewGLBatchSummaries to true + Then a PermissionError should be thrown + + Scenario: Changing canViewFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canViewFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canViewFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canViewFinanceConfigs to true + Then a PermissionError should be thrown + + Scenario: Changing canCreateFinanceConfigs with manage staff roles permission + Given a StaffRoleFinancePermissions entity with permission to manage staff roles + When I set canCreateFinanceConfigs to true + Then the property should be updated to true + + Scenario: Changing canCreateFinanceConfigs without permission + Given a StaffRoleFinancePermissions entity without permission to manage staff roles or system account + When I try to set canCreateFinanceConfigs to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature index aae26b8e3..901786338 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-permissions.feature @@ -27,4 +27,19 @@ Feature: StaffRolePermissions Scenario: Accessing violationTicketPermissions Given a StaffRolePermissions entity When I access the violationTicketPermissions property - Then I should receive a StaffRoleViolationTicketPermissions entity instance \ No newline at end of file + Then I should receive a StaffRoleViolationTicketPermissions entity instance + + Scenario: Accessing financePermissions + Given a StaffRolePermissions entity + When I access the financePermissions property + Then I should receive a StaffRoleFinancePermissions entity instance + + Scenario: Accessing techAdminPermissions + Given a StaffRolePermissions entity + When I access the techAdminPermissions property + Then I should receive a StaffRoleTechAdminPermissions entity instance + + Scenario: Accessing userPermissions + Given a StaffRolePermissions entity + When I access the userPermissions property + Then I should receive a StaffRoleUserPermissions entity instance diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature new file mode 100644 index 000000000..514dc404d --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-tech-admin-permissions.feature @@ -0,0 +1,60 @@ +Feature: StaffRoleTechAdminPermissions + + Background: + Given valid StaffRoleTechAdminPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageTechAdmin with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin with system account permission + Given a StaffRoleTechAdminPermissions entity with system account permission + When I set canManageTechAdmin to true + Then the property should be updated to true + + Scenario: Changing canManageTechAdmin without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canManageTechAdmin to true + Then a PermissionError should be thrown + + Scenario: Changing canViewDatabaseExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewDatabaseExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewDatabaseExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewDatabaseExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewBlobExplorer with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewBlobExplorer to true + Then the property should be updated to true + + Scenario: Changing canViewBlobExplorer without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewBlobExplorer to true + Then a PermissionError should be thrown + + Scenario: Changing canViewQueueDashboard with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canViewQueueDashboard to true + Then the property should be updated to true + + Scenario: Changing canViewQueueDashboard without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canViewQueueDashboard to true + Then a PermissionError should be thrown + + Scenario: Changing canSendQueueMessages with manage staff roles permission + Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles + When I set canSendQueueMessages to true + Then the property should be updated to true + + Scenario: Changing canSendQueueMessages without permission + Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account + When I try to set canSendQueueMessages to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature new file mode 100644 index 000000000..21cd6b942 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role-user-permissions.feature @@ -0,0 +1,20 @@ +Feature: StaffRoleUserPermissions + + Background: + Given valid StaffRoleUserPermissionsProps with all permission flags set to false + And a valid UserVisa + + Scenario: Changing canManageUsers with manage staff roles permission + Given a StaffRoleUserPermissions entity with permission to manage staff roles + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers with system account permission + Given a StaffRoleUserPermissions entity with system account permission + When I set canManageUsers to true + Then the property should be updated to true + + Scenario: Changing canManageUsers without permission + Given a StaffRoleUserPermissions entity without permission to manage staff roles or system account + When I try to set canManageUsers to true + Then a PermissionError should be thrown diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts index b86eaf818..0724acbd8 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts @@ -5,10 +5,15 @@ export type { } from './staff-role.ts'; export { StaffRole } from './staff-role.ts'; export type { StaffRoleUnitOfWork } from './staff-role.uow.ts'; +export * as StaffRoleValueObjects from './staff-role.value-objects.ts'; export type { StaffRoleCommunityPermissionsEntityReference, StaffRoleCommunityPermissionsProps, } from './staff-role-community-permissions.ts'; +export type { + StaffRoleFinancePermissionsEntityReference, + StaffRoleFinancePermissionsProps, +} from './staff-role-finance-permissions.ts'; export type { StaffRolePermissionsEntityReference, StaffRolePermissionsProps, @@ -25,6 +30,14 @@ export type { StaffRoleServiceTicketPermissionsEntityReference, StaffRoleServiceTicketPermissionsProps, } from './staff-role-service-ticket-permissions.ts'; +export type { + StaffRoleTechAdminPermissionsEntityReference, + StaffRoleTechAdminPermissionsProps, +} from './staff-role-tech-admin-permissions.ts'; +export type { + StaffRoleUserPermissionsEntityReference, + StaffRoleUserPermissionsProps, +} from './staff-role-user-permissions.ts'; export type { StaffRoleViolationTicketPermissionsEntityReference, StaffRoleViolationTicketPermissionsProps, diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts index dae792d73..0d3d58952 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.test.ts @@ -18,6 +18,7 @@ function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = function makeProps(overrides = {}) { return { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -311,4 +312,48 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(setWithoutPermission).toThrow('Cannot set permission'); }); }); + + // canManageCommunities + Scenario('Changing canManageCommunities with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleCommunityPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I set canManageCommunities to true', () => { + entity.canManageCommunities = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageCommunities).toBe(true); + }); + }); + + Scenario('Changing canManageCommunities without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleCommunityPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleCommunityPermissions(makeProps(), visa); + }); + When('I try to set canManageCommunities to true', () => { + setWithoutPermission = () => { + entity.canManageCommunities = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts index c8887d0fc..fbc58c0dc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-community-permissions.ts @@ -4,6 +4,7 @@ import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; interface StaffRoleCommunityPermissionsSpec { + canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean; canManageAllCommunities: boolean; canDeleteCommunities: boolean; @@ -28,6 +29,13 @@ export class StaffRoleCommunityPermissions extends ValueObject ({ + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }), + }), + }, + } as unknown as Passport; +} + +function makeBaseProps(overrides: Partial = {}): StaffRoleProps { + const emptyPermissions = { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + servicePermissions: { + canManageServices: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + }, + userPermissions: { + canManageUsers: false, + }, + } as const; + + return { + id: 'role-1', + roleName: 'Support', + isDefault: false, + enterpriseAppRole: '', + permissions: emptyPermissions as unknown as StaffRoleProps['permissions'], + roleType: 'staff-role', + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + }; +} + +test('applyDefaultSpec sets CaseManager permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultCaseManagerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets Finance permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultFinanceInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(false); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets ServiceLineOwner permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultServiceLineOwnerInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); + +test('applyDefaultSpec sets TechAdmin permissions correctly and marks default', () => { + const passport = makePassport(); + const role = StaffRole.getNewDefaultTechAdminInstance(makeBaseProps(), passport); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + // Tech Admins should also be able to manage staff roles & permissions by default + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.isDefault).toBe(true); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts new file mode 100644 index 000000000..e46d32f40 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-finance-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleFinancePermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleFinancePermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleFinancePermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageFinance with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canManageFinance to true', () => { + entity.canManageFinance = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageFinance).toBe(true); + }); + }); + + Scenario('Changing canManageFinance without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canManageFinance to true', () => { + setWithoutPermission = () => { + entity.canManageFinance = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewGLBatchSummaries with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewGLBatchSummaries to true', () => { + entity.canViewGLBatchSummaries = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Changing canViewGLBatchSummaries without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewGLBatchSummaries to true', () => { + setWithoutPermission = () => { + entity.canViewGLBatchSummaries = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canViewFinanceConfigs to true', () => { + entity.canViewFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canViewFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canViewFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canViewFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canCreateFinanceConfigs with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleFinancePermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I set canCreateFinanceConfigs to true', () => { + entity.canCreateFinanceConfigs = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canCreateFinanceConfigs).toBe(true); + }); + }); + + Scenario('Changing canCreateFinanceConfigs without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleFinancePermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleFinancePermissions(makeProps(), visa); + }); + When('I try to set canCreateFinanceConfigs to true', () => { + setWithoutPermission = () => { + entity.canCreateFinanceConfigs = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts new file mode 100644 index 000000000..e07d6be7d --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-finance-permissions.ts @@ -0,0 +1,61 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleFinancePermissionsSpec { + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; +} + +export interface StaffRoleFinancePermissionsProps extends StaffRoleFinancePermissionsSpec, ValueObjectProps {} +export interface StaffRoleFinancePermissionsEntityReference extends Readonly {} + +export class StaffRoleFinancePermissions extends ValueObject implements StaffRoleFinancePermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleFinancePermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageFinance(): boolean { + return this.props.canManageFinance; + } + set canManageFinance(value: boolean) { + this.validateVisa(); + this.props.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.props.canViewGLBatchSummaries; + } + set canViewGLBatchSummaries(value: boolean) { + this.validateVisa(); + this.props.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.props.canViewFinanceConfigs; + } + set canViewFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.props.canCreateFinanceConfigs; + } + set canCreateFinanceConfigs(value: boolean) { + this.validateVisa(); + this.props.canCreateFinanceConfigs = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts index b09ad76ed..c73dcecf7 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts @@ -3,10 +3,13 @@ import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect, vi } from 'vitest'; import { StaffRoleCommunityPermissions } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions } from './staff-role-finance-permissions.ts'; import { StaffRolePermissions } from './staff-role-permissions.ts'; import { StaffRolePropertyPermissions } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions } from './staff-role-violation-ticket-permissions.ts'; const test = { for: describeFeature }; @@ -26,6 +29,9 @@ function makeProps() { serviceTicketPermissions: {} as StaffRoleServiceTicketPermissions, servicePermissions: {} as StaffRoleServicePermissions, violationTicketPermissions: {} as StaffRoleViolationTicketPermissions, + financePermissions: {} as StaffRoleFinancePermissions, + techAdminPermissions: {} as StaffRoleTechAdminPermissions, + userPermissions: {} as StaffRoleUserPermissions, }; } @@ -113,4 +119,43 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(violationTicketPermissions).toBeInstanceOf(StaffRoleViolationTicketPermissions); }); }); + + Scenario('Accessing financePermissions', ({ Given, When, Then }) => { + let financePermissions: StaffRoleFinancePermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the financePermissions property', () => { + financePermissions = entity.financePermissions; + }); + Then('I should receive a StaffRoleFinancePermissions entity instance', () => { + expect(financePermissions).toBeInstanceOf(StaffRoleFinancePermissions); + }); + }); + + Scenario('Accessing techAdminPermissions', ({ Given, When, Then }) => { + let techAdminPermissions: StaffRoleTechAdminPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the techAdminPermissions property', () => { + techAdminPermissions = entity.techAdminPermissions; + }); + Then('I should receive a StaffRoleTechAdminPermissions entity instance', () => { + expect(techAdminPermissions).toBeInstanceOf(StaffRoleTechAdminPermissions); + }); + }); + + Scenario('Accessing userPermissions', ({ Given, When, Then }) => { + let userPermissions: StaffRoleUserPermissions; + Given('a StaffRolePermissions entity', () => { + entity = new StaffRolePermissions(props, visa); + }); + When('I access the userPermissions property', () => { + userPermissions = entity.userPermissions; + }); + Then('I should receive a StaffRoleUserPermissions entity instance', () => { + expect(userPermissions).toBeInstanceOf(StaffRoleUserPermissions); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts index 22e8ee188..7e45a39f6 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts @@ -1,10 +1,13 @@ -import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; import type { UserVisa } from '../user.visa.ts'; import { StaffRoleCommunityPermissions, type StaffRoleCommunityPermissionsEntityReference, type StaffRoleCommunityPermissionsProps } from './staff-role-community-permissions.ts'; +import { StaffRoleFinancePermissions, type StaffRoleFinancePermissionsEntityReference, type StaffRoleFinancePermissionsProps } from './staff-role-finance-permissions.ts'; import { StaffRolePropertyPermissions, type StaffRolePropertyPermissionsEntityReference, type StaffRolePropertyPermissionsProps } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions, type StaffRoleServicePermissionsEntityReference, type StaffRoleServicePermissionsProps } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions, type StaffRoleServiceTicketPermissionsEntityReference, type StaffRoleServiceTicketPermissionsProps } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleTechAdminPermissions, type StaffRoleTechAdminPermissionsEntityReference, type StaffRoleTechAdminPermissionsProps } from './staff-role-tech-admin-permissions.ts'; +import { StaffRoleUserPermissions, type StaffRoleUserPermissionsEntityReference, type StaffRoleUserPermissionsProps } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions, type StaffRoleViolationTicketPermissionsEntityReference, type StaffRoleViolationTicketPermissionsProps } from './staff-role-violation-ticket-permissions.ts'; export interface StaffRolePermissionsProps extends ValueObjectProps { @@ -13,15 +16,26 @@ export interface StaffRolePermissionsProps extends ValueObjectProps { readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsProps; readonly servicePermissions: StaffRoleServicePermissionsProps; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsProps; + readonly financePermissions: StaffRoleFinancePermissionsProps; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsProps; + readonly userPermissions: StaffRoleUserPermissionsProps; } export interface StaffRolePermissionsEntityReference - extends Readonly> { + extends Readonly< + Omit< + StaffRolePermissionsProps, + 'communityPermissions' | 'propertyPermissions' | 'serviceTicketPermissions' | 'servicePermissions' | 'violationTicketPermissions' | 'financePermissions' | 'techAdminPermissions' | 'userPermissions' + > + > { readonly communityPermissions: StaffRoleCommunityPermissionsEntityReference; readonly propertyPermissions: StaffRolePropertyPermissionsEntityReference; readonly serviceTicketPermissions: StaffRoleServiceTicketPermissionsEntityReference; readonly servicePermissions: StaffRoleServicePermissionsEntityReference; readonly violationTicketPermissions: StaffRoleViolationTicketPermissionsEntityReference; + readonly financePermissions: StaffRoleFinancePermissionsEntityReference; + readonly techAdminPermissions: StaffRoleTechAdminPermissionsEntityReference; + readonly userPermissions: StaffRoleUserPermissionsEntityReference; } export class StaffRolePermissions extends ValueObject implements StaffRolePermissionsEntityReference { @@ -47,4 +61,13 @@ export class StaffRolePermissions extends ValueObject get violationTicketPermissions(): StaffRoleViolationTicketPermissions { return new StaffRoleViolationTicketPermissions(this.props.violationTicketPermissions, this.visa); } + get financePermissions(): StaffRoleFinancePermissions { + return new StaffRoleFinancePermissions(this.props.financePermissions, this.visa); + } + get techAdminPermissions(): StaffRoleTechAdminPermissions { + return new StaffRoleTechAdminPermissions(this.props.techAdminPermissions, this.visa); + } + get userPermissions(): StaffRoleUserPermissions { + return new StaffRoleUserPermissions(this.props.userPermissions, this.visa); + } } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts new file mode 100644 index 000000000..e143bbc32 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.test.ts @@ -0,0 +1,211 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-tech-admin-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleTechAdminPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleTechAdminPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleTechAdminPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageTechAdmin with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canManageTechAdmin to true', () => { + entity.canManageTechAdmin = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Changing canManageTechAdmin without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canManageTechAdmin to true', () => { + setWithoutPermission = () => { + entity.canManageTechAdmin = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewDatabaseExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewDatabaseExplorer to true', () => { + entity.canViewDatabaseExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewDatabaseExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewDatabaseExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewDatabaseExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewBlobExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewBlobExplorer to true', () => { + entity.canViewBlobExplorer = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Changing canViewBlobExplorer without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewBlobExplorer to true', () => { + setWithoutPermission = () => { + entity.canViewBlobExplorer = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canViewQueueDashboard with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canViewQueueDashboard to true', () => { + entity.canViewQueueDashboard = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Changing canViewQueueDashboard without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canViewQueueDashboard to true', () => { + setWithoutPermission = () => { + entity.canViewQueueDashboard = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); + + Scenario('Changing canSendQueueMessages with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleTechAdminPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I set canSendQueueMessages to true', () => { + entity.canSendQueueMessages = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canSendQueueMessages).toBe(true); + }); + }); + + Scenario('Changing canSendQueueMessages without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleTechAdminPermissions(makeProps(), visa); + }); + When('I try to set canSendQueueMessages to true', () => { + setWithoutPermission = () => { + entity.canSendQueueMessages = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts new file mode 100644 index 000000000..9d225e6c7 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-tech-admin-permissions.ts @@ -0,0 +1,70 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleTechAdminPermissionsSpec { + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +export interface StaffRoleTechAdminPermissionsProps extends StaffRoleTechAdminPermissionsSpec, ValueObjectProps {} +export interface StaffRoleTechAdminPermissionsEntityReference extends Readonly {} + +export class StaffRoleTechAdminPermissions extends ValueObject implements StaffRoleTechAdminPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleTechAdminPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageTechAdmin(): boolean { + return this.props.canManageTechAdmin; + } + set canManageTechAdmin(value: boolean) { + this.validateVisa(); + this.props.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.props.canViewDatabaseExplorer; + } + set canViewDatabaseExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.props.canViewBlobExplorer; + } + set canViewBlobExplorer(value: boolean) { + this.validateVisa(); + this.props.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.props.canViewQueueDashboard; + } + set canViewQueueDashboard(value: boolean) { + this.validateVisa(); + this.props.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.props.canSendQueueMessages; + } + set canSendQueueMessages(value: boolean) { + this.validateVisa(); + this.props.canSendQueueMessages = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts new file mode 100644 index 000000000..969d1e7ab --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { expect, vi } from 'vitest'; +import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role-user-permissions.feature')); + +function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = false } = {}) { + return vi.mocked({ + determineIf: vi.fn((fn) => fn({ canManageStaffRolesAndPermissions, isSystemAccount })), + }); +} + +function makeProps(overrides = {}) { + return { + canManageUsers: false, + ...overrides, + }; +} + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let visa: ReturnType; + let props: ReturnType; + let entity: StaffRoleUserPermissions; + + BeforeEachScenario(() => { + visa = makeVisa(); + props = makeProps(); + entity = new StaffRoleUserPermissions(props, visa); + }); + + Background(({ Given, And }) => { + Given('valid StaffRoleUserPermissionsProps with all permission flags set to false', () => { + props = makeProps(); + }); + And('a valid UserVisa', () => { + visa = makeVisa(); + }); + }); + + Scenario('Changing canManageUsers with manage staff roles permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with permission to manage staff roles', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers with system account permission', ({ Given, When, Then }) => { + Given('a StaffRoleUserPermissions entity with system account permission', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: true }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I set canManageUsers to true', () => { + entity.canManageUsers = true; + }); + Then('the property should be updated to true', () => { + expect(entity.canManageUsers).toBe(true); + }); + }); + + Scenario('Changing canManageUsers without permission', ({ Given, When, Then }) => { + let setWithoutPermission: () => void; + Given('a StaffRoleUserPermissions entity without permission to manage staff roles or system account', () => { + visa = makeVisa({ canManageStaffRolesAndPermissions: false, isSystemAccount: false }); + entity = new StaffRoleUserPermissions(makeProps(), visa); + }); + When('I try to set canManageUsers to true', () => { + setWithoutPermission = () => { + entity.canManageUsers = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setWithoutPermission).toThrow(PermissionError); + expect(setWithoutPermission).toThrow('Cannot set permission'); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts new file mode 100644 index 000000000..358c7a7c4 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts @@ -0,0 +1,34 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleUserPermissionsSpec { + canManageUsers: boolean; +} + +export interface StaffRoleUserPermissionsProps extends StaffRoleUserPermissionsSpec, ValueObjectProps {} +export interface StaffRoleUserPermissionsEntityReference extends Readonly {} + +export class StaffRoleUserPermissions extends ValueObject implements StaffRoleUserPermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleUserPermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canManageUsers(): boolean { + return this.props.canManageUsers; + } + set canManageUsers(value: boolean) { + this.validateVisa(); + this.props.canManageUsers = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts index 02fb77d52..ff2cf93bc 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.repository.ts @@ -2,6 +2,11 @@ import type { Repository } from '@cellix/domain-seedwork/repository'; import type { StaffRole, StaffRoleProps } from './staff-role.ts'; export interface StaffRoleRepository extends Repository> { getNewInstance(name: string): Promise>; + getNewDefaultCaseManagerInstance(): Promise>; + getNewDefaultServiceLineOwnerInstance(): Promise>; + getNewDefaultFinanceInstance(): Promise>; + getNewDefaultTechAdminInstance(): Promise>; getById(id: string): Promise>; getByRoleName(roleName: string): Promise>; + getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise>; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts index fade23a8e..d9f16c93e 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts @@ -27,6 +27,7 @@ function makeBaseProps(overrides: Partial = {}): StaffRoleProps id: 'role-1', roleName: 'Support', isDefault: false, + enterpriseAppRole: '', permissions: {} as StaffRolePermissions, roleType: 'staff-role', createdAt: new Date('2020-01-01T00:00:00Z'), diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts index 49912c977..f5ad20a1d 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts @@ -10,6 +10,7 @@ import { StaffRolePermissions, type StaffRolePermissionsEntityReference, type St export interface StaffRoleProps extends DomainEntityProps { roleName: string; isDefault: boolean; + enterpriseAppRole: string; readonly permissions: StaffRolePermissionsProps; readonly roleType: string | null; readonly createdAt: Date; @@ -21,6 +22,7 @@ export interface StaffRoleEntityReference extends Readonly extends AggregateRoot implements StaffRoleEntityReference { private isNew: boolean = false; private readonly visa: UserVisa; @@ -37,6 +39,72 @@ export class StaffRole extends AggregateRoot(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Case Manager'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.CaseManager; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } + + public static getNewDefaultServiceLineOwnerInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Service Line Owner'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.ServiceLineOwner; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = true; + role.permissions.financePermissions.canManageFinance = false; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } + + public static getNewDefaultFinanceInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Finance'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.Finance; + role.isDefault = true; + role.permissions.communityPermissions.canManageCommunities = false; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = false; + role.permissions.userPermissions.canManageUsers = false; + role.isNew = false; + return role; + } + + public static getNewDefaultTechAdminInstance(newProps: props, passport: Passport): StaffRole { + const role = new StaffRole(newProps, passport); + role.isNew = true; + role.roleName = 'Default Tech Admin'; + role.enterpriseAppRole = ValueObjects.EnterpriseAppRoleNames.TechAdmin; + role.isDefault = true; + // Tech Admins are implicit managers of all areas + role.permissions.communityPermissions.canManageCommunities = true; + // Tech Admins should also be able to manage staff roles & permissions by default + role.permissions.communityPermissions.canManageStaffRolesAndPermissions = true; + role.permissions.financePermissions.canManageFinance = true; + role.permissions.techAdminPermissions.canManageTechAdmin = true; + role.permissions.userPermissions.canManageUsers = true; + role.isNew = false; + return role; + } public deleteAndReassignTo(roleRef: StaffRoleEntityReference) { if (this.isDefault) { throw new PermissionError('You cannot delete a default staff role'); @@ -60,6 +128,18 @@ export class StaffRole extends AggregateRoot permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set enterprise app role'); + } + this.props.enterpriseAppRole = new ValueObjects.EnterpriseAppRole(enterpriseAppRole).valueOf(); + } + get isDefault() { return this.props.isDefault; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts index 3a4a42e28..c683809e2 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.value-objects.ts @@ -1,7 +1,16 @@ -import { VOString } from '@lucaspaganini/value-objects'; +import { VOString, VOSet } from '@lucaspaganini/value-objects'; export class RoleName extends VOString({ trim: true, maxLength: 50, minLength: 1, }) {} + +export const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +export class EnterpriseAppRole extends VOSet(Object.values(EnterpriseAppRoleNames)) {} \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts new file mode 100644 index 000000000..7375b9941 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.ts @@ -0,0 +1,42 @@ +import type { EndUserEntityReference } from '../../../../contexts/user/end-user/index.ts'; +import type { StaffRoleEntityReference } from '../../../../contexts/user/staff-role/staff-role.ts'; +import type { StaffUserEntityReference } from '../../../../contexts/user/staff-user/index.ts'; +import type { UserDomainPermissions } from '../../../../contexts/user/user.domain-permissions.ts'; +import type { UserPassport } from '../../../../contexts/user/user.passport.ts'; +import type { UserVisa } from '../../../../contexts/user/user.visa.ts'; +import type { VendorUserEntityReference } from '../../../../contexts/user/vendor-user/vendor-user.ts'; +import { StaffUserPassportBase } from '../../staff-user.passport-base.ts'; + +export class StaffUserUserPassport extends StaffUserPassportBase implements UserPassport { + forEndUser(_root: EndUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forStaffUser(root: StaffUserEntityReference): UserVisa { + const permissions = this.buildPermissions(root); + return { determineIf: (func) => func(permissions) }; + } + + forStaffRole(_root: StaffRoleEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + forVendorUser(_root: VendorUserEntityReference): UserVisa { + const permissions = this.buildPermissions(); + return { determineIf: (func) => func(permissions) }; + } + + private buildPermissions(root?: StaffUserEntityReference): UserDomainPermissions { + const canManageStaffRolesAndPermissions = this._user.role?.permissions.communityPermissions.canManageStaffRolesAndPermissions ?? false; + return { + canManageEndUsers: false, + canManageStaffRolesAndPermissions, + canManageStaffUsers: canManageStaffRolesAndPermissions, + canManageVendorUsers: false, + isEditingOwnAccount: root !== undefined && root.externalId === this._user.externalId, + isSystemAccount: false, + }; + } +} diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature index 8145b32b4..172644ebb 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature @@ -20,4 +20,4 @@ Feature: StaffUserPassport Scenario: Accessing the user passport When I create a StaffUserPassport with valid staff user And I access the user property - Then an error should be thrown indicating the user passport is not available \ No newline at end of file + Then I should receive a StaffUserUserPassport instance \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts index 62a730e0a..6e5f74139 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts @@ -6,6 +6,7 @@ import type { CommunityEntityReference } from '../../../contexts/community/commu import type { StaffUserEntityReference } from '../../../contexts/user/staff-user/staff-user.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserCommunityVisa } from './contexts/staff-user.community.visa.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; import { StaffUserPassport } from './staff-user.passport.ts'; const test = { for: describeFeature }; @@ -85,15 +86,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Accessing the user passport', ({ When, And, Then }) => { - let getUserPassport: () => void; + let userPassport: unknown; When('I create a StaffUserPassport with valid staff user', () => { passport = new StaffUserPassport(staffUser); }); And('I access the user property', () => { - getUserPassport = () => passport.user; + userPassport = passport.user; }); - Then('an error should be thrown indicating the user passport is not available', () => { - expect(getUserPassport).toThrow('User passport is not available for StaffUserPassport'); + Then('I should receive a StaffUserUserPassport instance', () => { + expect(userPassport).toBeInstanceOf(StaffUserUserPassport); }); }); }); diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts index 5d2715451..92a9a9369 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.ts @@ -2,15 +2,18 @@ import type { CasePassport } from '../../../contexts/case/case.passport.ts'; import type { CommunityPassport } from '../../../contexts/community/community.passport.ts'; import type { Passport } from '../../../contexts/passport.ts'; import type { PropertyPassport } from '../../../contexts/property/property.passport.ts'; +import type { UserPassport } from '../../../contexts/user/user.passport.ts'; import { StaffUserPassportBase } from '../staff-user.passport-base.ts'; import { StaffUserCasePassport } from './contexts/staff-user.case.passport.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserPropertyPassport } from './contexts/staff-user.property.passport.ts'; +import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; export class StaffUserPassport extends StaffUserPassportBase implements Passport { private _communityPassport: CommunityPassport | undefined; private _propertyPassport: PropertyPassport | undefined; private _casePassport: CasePassport | undefined; + private _userPassport: UserPassport | undefined; public get case(): CasePassport { if (!this._casePassport) { @@ -37,7 +40,10 @@ export class StaffUserPassport extends StaffUserPassportBase implements Passport throw new Error('Service passport is not available for StaffUserPassport'); } - public get user(): never { - throw new Error('User passport is not available for StaffUserPassport'); + public get user(): UserPassport { + if (!this._userPassport) { + this._userPassport = new StaffUserUserPassport(this._user); + } + return this._userPassport; } } diff --git a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts index 8fc71a151..5992cc548 100644 --- a/packages/ocom/graphql/src/schema/builder/resolver-builder.ts +++ b/packages/ocom/graphql/src/schema/builder/resolver-builder.ts @@ -1,5 +1,4 @@ import { mergeResolvers } from '@graphql-tools/merge'; -import endUserRoleResolvers from '../types/end-user-role.resolvers.ts'; import type { Resolvers } from './generated.ts'; import { ocomGraphqlPermissions, ocomGraphqlResolvers } from './resolver-manifest.generated.ts'; @@ -7,5 +6,5 @@ function mergeResolverModules(modules: Resolvers[]): Resolvers { return (modules.length === 0 ? {} : mergeResolvers(modules)) as Resolvers; } -export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers, endUserRoleResolvers]); +export const resolvers: Resolvers = mergeResolverModules([...ocomGraphqlResolvers]); export const permissions: Resolvers = mergeResolverModules(ocomGraphqlPermissions); diff --git a/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature new file mode 100644 index 000000000..265800347 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature @@ -0,0 +1,22 @@ +Feature: Staff User Resolvers + + As an API consumer + I want to query and create staff user entities + So that I can retrieve a staff user or ensure one exists via the GraphQL API + + Scenario: Querying the current staff user and creating if not exists + Given a user with a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the JWT claims + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with AAD roles + Given a user with a verifiedJwt that includes AAD roles in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should call User.StaffUser.createIfNotExists with the AAD roles + And it should return the corresponding StaffUser entity + + Scenario: Querying the current staff user with no JWT + Given a user without a verifiedJwt in their context + When the currentStaffUserAndCreateIfNotExists query is executed + Then it should throw an "Unauthorized" error diff --git a/packages/ocom/graphql/src/schema/types/staff-user.graphql b/packages/ocom/graphql/src/schema/types/staff-user.graphql new file mode 100644 index 000000000..1def99be8 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.graphql @@ -0,0 +1,66 @@ +type StaffRoleCommunityPermissions { + canManageCommunities: Boolean! + canManageStaffRolesAndPermissions: Boolean! + canManageAllCommunities: Boolean! + canDeleteCommunities: Boolean! + canChangeCommunityOwner: Boolean! + canReIndexSearchCollections: Boolean! +} + +type StaffRoleFinancePermissions { + canManageFinance: Boolean! + canViewGLBatchSummaries: Boolean! + canViewFinanceConfigs: Boolean! + canCreateFinanceConfigs: Boolean! +} + +type StaffRoleTechAdminPermissions { + canManageTechAdmin: Boolean! + canViewDatabaseExplorer: Boolean! + canViewBlobExplorer: Boolean! + canViewQueueDashboard: Boolean! + canSendQueueMessages: Boolean! +} + +type StaffRoleUserPermissions { + canManageUsers: Boolean! +} + +type StaffRolePermissions { + communityPermissions: StaffRoleCommunityPermissions! + financePermissions: StaffRoleFinancePermissions! + techAdminPermissions: StaffRoleTechAdminPermissions! + userPermissions: StaffRoleUserPermissions! +} + +type StaffRole implements MongoBase { + roleName: String! + isDefault: Boolean! + roleType: String + permissions: StaffRolePermissions! + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +type StaffUser implements MongoBase { + externalId: String! + firstName: String! + lastName: String! + email: String! + displayName: String! + accessBlocked: Boolean! + tags: [String!]! + role: StaffRole + + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime +} + +extend type Query { + currentStaffUserAndCreateIfNotExists: StaffUser! +} diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts new file mode 100644 index 000000000..66b97abcf --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import { type FieldNode, type GraphQLObjectType, type GraphQLResolveInfo, type GraphQLSchema, Kind, type OperationDefinitionNode } from 'graphql'; +import { expect, vi } from 'vitest'; +import type { GraphContext } from '../context.ts'; +import staffUserResolvers from './staff-user.resolvers.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.resolvers.feature')); + +type StaffUserEntity = Domain.Contexts.User.StaffUser.StaffUserEntityReference; + +function createMockStaffUser(overrides: Partial = {}): StaffUserEntity { + return { + id: 'mock-staff-user-id', + externalId: 'mock-external-id', + firstName: 'Jane', + lastName: 'Smith', + displayName: 'Jane Smith', + email: 'jane@example.com', + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + } as unknown as StaffUserEntity; +} + +function makeMockInfo(fieldName: string): GraphQLResolveInfo { + const mockFieldNode: FieldNode = { + kind: Kind.FIELD, + name: { kind: Kind.NAME, value: fieldName }, + }; + return { + fieldName, + fieldNodes: [mockFieldNode], + returnType: {} as GraphQLObjectType, + parentType: {} as GraphQLObjectType, + path: { key: fieldName, prev: undefined, typename: undefined }, + schema: {} as GraphQLSchema, + fragments: {}, + rootValue: {}, + operation: {} as OperationDefinitionNode, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +function makeMockGraphContext(overrides: Partial = {}): GraphContext { + return { + applicationServices: { + User: { + StaffUser: { + createIfNotExists: vi.fn(), + queryByExternalId: vi.fn(), + }, + }, + verifiedUser: { + verifiedJwt: { + sub: 'default-user-sub', + given_name: 'Jane', + family_name: 'Smith', + email: 'jane@example.com', + roles: [], + }, + }, + ...overrides.applicationServices, + }, + ...overrides, + } as unknown as GraphContext; +} + +type QueryResolver = (parent: object, args: Record, context: GraphContext, info: GraphQLResolveInfo) => Promise; + +const callCurrentStaffUserQuery = (context: GraphContext) => (staffUserResolvers.Query?.currentStaffUserAndCreateIfNotExists as unknown as QueryResolver)({}, {}, context, makeMockInfo('currentStaffUserAndCreateIfNotExists')); + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let context: GraphContext; + let result: StaffUserEntity | null; + + BeforeEachScenario(() => { + context = makeMockGraphContext(); + vi.clearAllMocks(); + result = null; + }); + + Scenario('Querying the current staff user and creating if not exists', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + + Given('a user with a verifiedJwt in their context', () => { + // Already set up in BeforeEachScenario with default jwt + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callCurrentStaffUserQuery(context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the JWT claims', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'default-user-sub', + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + aadRoles: [], + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with AAD roles', ({ Given, When, Then, And }) => { + const mockStaffUser = createMockStaffUser(); + const aadRoles = ['Staff.CaseManager', 'Staff.Finance']; + + Given('a user with a verifiedJwt that includes AAD roles in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + User: { + StaffUser: { + createIfNotExists: vi.fn(), + queryByExternalId: vi.fn(), + }, + }, + verifiedUser: { + verifiedJwt: { + sub: 'roles-user-sub', + given_name: 'Bob', + family_name: 'Jones', + email: 'bob@example.com', + roles: aadRoles, + }, + }, + } as unknown as GraphContext['applicationServices'], + }); + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); + result = await callCurrentStaffUserQuery(context); + }); + + Then('it should call User.StaffUser.createIfNotExists with the AAD roles', () => { + expect(context.applicationServices.User.StaffUser.createIfNotExists).toHaveBeenCalledWith({ + externalId: 'roles-user-sub', + firstName: 'Bob', + lastName: 'Jones', + email: 'bob@example.com', + aadRoles, + }); + }); + + And('it should return the corresponding StaffUser entity', () => { + expect(result).toEqual(mockStaffUser); + }); + }); + + Scenario('Querying the current staff user with no JWT', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + if (context.applicationServices.verifiedUser) { + context.applicationServices.verifiedUser.verifiedJwt = undefined; + } + }); + + When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { + await expect(callCurrentStaffUserQuery(context)).rejects.toThrow('Unauthorized'); + }); + + Then('it should throw an "Unauthorized" error', () => { + // Already asserted in When + }); + }); +}); diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts new file mode 100644 index 000000000..38be5afa1 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts @@ -0,0 +1,24 @@ +import type { GraphQLResolveInfo } from 'graphql'; +import type { Resolvers } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; + +const staffUser: Resolvers = { + Query: { + currentStaffUserAndCreateIfNotExists: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + throw new Error('Unauthorized'); + } + const result = await context.applicationServices.User.StaffUser.createIfNotExists({ + externalId: jwt.sub, + firstName: jwt.given_name ?? '', + lastName: jwt.family_name ?? '', + email: jwt.email ?? '', + aadRoles: jwt.roles ?? [], + }); + return result; + }, + }, +}; + +export default staffUser; diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts index 23ca925d2..8ca4d7dc5 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts @@ -121,7 +121,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting message when document message is undefined', ({ Given, When, Then }) => { Given('a MemberInvitationDomainAdapter for a document with no message', () => { const docWithoutMessage = makeMemberInvitationDoc(); - // biome-ignore lint/performance/noDelete: needed to test undefined message scenario delete (docWithoutMessage as unknown as Record)['message']; doc = docWithoutMessage; adapter = new MemberInvitationDomainAdapter(doc); diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature index 7b6f611eb..f0646f041 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature @@ -151,4 +151,149 @@ Feature: StaffRoleDomainAdapter And the canAssignTickets property should return false And the canWorkOnTickets property should return false When I set the canCreateTickets property to true - Then the violationTicketPermissions' canCreateTickets should be true \ No newline at end of file + Then the violationTicketPermissions' canCreateTickets should be true + + Scenario: Getting and setting canManageCommunities from communityPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the communityPermissions property + And I get the canManageCommunities property + Then it should return false + When I set the canManageCommunities property to true + Then the communityPermissions' canManageCommunities should be true + + Scenario: Getting financePermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + + Scenario: Getting and setting canManageFinance from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canManageFinance property should return false + When I set the canManageFinance property to true + Then the financePermissions' canManageFinance should be true + + Scenario: Getting and setting canViewGLBatchSummaries from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewGLBatchSummaries property should return false + When I set the canViewGLBatchSummaries property to true + Then the financePermissions' canViewGLBatchSummaries should be true + + Scenario: Getting and setting canViewFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canViewFinanceConfigs property should return false + When I set the canViewFinanceConfigs property to true + Then the financePermissions' canViewFinanceConfigs should be true + + Scenario: Getting and setting canCreateFinanceConfigs from financePermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the financePermissions property + Then the canCreateFinanceConfigs property should return false + When I set the canCreateFinanceConfigs property to true + Then the financePermissions' canCreateFinanceConfigs should be true + + Scenario: Getting techAdminPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + + Scenario: Getting and setting canManageTechAdmin from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canManageTechAdmin property should return false + When I set the canManageTechAdmin property to true + Then the techAdminPermissions' canManageTechAdmin should be true + + Scenario: Getting and setting canViewDatabaseExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewDatabaseExplorer property should return false + When I set the canViewDatabaseExplorer property to true + Then the techAdminPermissions' canViewDatabaseExplorer should be true + + Scenario: Getting and setting canViewBlobExplorer from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewBlobExplorer property should return false + When I set the canViewBlobExplorer property to true + Then the techAdminPermissions' canViewBlobExplorer should be true + + Scenario: Getting and setting canViewQueueDashboard from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canViewQueueDashboard property should return false + When I set the canViewQueueDashboard property to true + Then the techAdminPermissions' canViewQueueDashboard should be true + + Scenario: Getting and setting canSendQueueMessages from techAdminPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the techAdminPermissions property + Then the canSendQueueMessages property should return false + When I set the canSendQueueMessages property to true + Then the techAdminPermissions' canSendQueueMessages should be true + + Scenario: Getting userPermissions from permissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + + Scenario: Getting and setting canManageUsers from userPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then the canManageUsers property should return false + When I set the canManageUsers property to true + Then the userPermissions' canManageUsers should be true + + Scenario: Lazy-initialising permissions when document has no permissions object + Given a StaffRoleDomainAdapter wrapping a document with no permissions object + When I get the permissions property + Then it should return a StaffRolePermissionsAdapter instance + + Scenario: Lazy-initialising communityPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document + When I get the permissions property + And I get the communityPermissions property + Then it should return a StaffRoleCommunityPermissionsAdapter instance + And canManageCommunities should default to false + + Scenario: Lazy-initialising financePermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document + When I get the permissions property + And I get the financePermissions property + Then it should return a StaffRoleFinancePermissionsAdapter instance + And canManageFinance should default to false + + Scenario: Lazy-initialising techAdminPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document + When I get the permissions property + And I get the techAdminPermissions property + Then it should return a StaffRoleTechAdminPermissionsAdapter instance + And canManageTechAdmin should default to false + + Scenario: Lazy-initialising userPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document + When I get the permissions property + And I get the userPermissions property + Then it should return a StaffRoleUserPermissionsAdapter instance + And canManageUsers should default to false + + Scenario: Getting roleType returns null when document roleType is undefined + Given a StaffRoleDomainAdapter wrapping a document with no roleType + When I get the roleType property + Then it should return null \ No newline at end of file diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature index 042b8f64a..1874bac51 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.repository.feature @@ -26,6 +26,16 @@ Feature: StaffRoleRepository When I call getByRoleName with "nonexistent-role" Then an error should be thrown indicating "StaffRole with roleName nonexistent-role not found" + Scenario: Getting a default staff role by enterpriseAppRole + Given a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager" + When I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager" + Then I should receive a StaffRole domain object + And the domain object's isDefault should be true + + Scenario: Getting a default staff role by enterpriseAppRole that does not exist + When I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole" + Then an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found" + Scenario: Creating a new staff role instance When I call getNewInstance with name "Supervisor" Then I should receive a new StaffRole domain object diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts index 8f7abbf56..924cb4202 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts @@ -15,6 +15,9 @@ import { StaffRoleServicePermissionsAdapter, StaffRoleServiceTicketPermissionsAdapter, StaffRoleViolationTicketPermissionsAdapter, + StaffRoleFinancePermissionsAdapter, + StaffRoleTechAdminPermissionsAdapter, + StaffRoleUserPermissionsAdapter, } from './staff-role.domain-adapter.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -498,6 +501,443 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => expect(doc.permissions?.violationTicketPermissions?.canCreateTickets).toBe(true); }); }); + + // ─── canManageCommunities ───────────────────────────────────────────────── + + Scenario('Getting and setting canManageCommunities from communityPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let communityPermissions: StaffRoleCommunityPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + communityPermissions = permissions.communityPermissions as StaffRoleCommunityPermissionsAdapter; + }); + And('I get the canManageCommunities property', () => { + result = communityPermissions.canManageCommunities; + }); + Then('it should return false', () => { + expect(result).toBe(false); + }); + When('I set the canManageCommunities property to true', () => { + communityPermissions.canManageCommunities = true; + }); + Then("the communityPermissions' canManageCommunities should be true", () => { + expect(doc.permissions?.communityPermissions?.canManageCommunities).toBe(true); + }); + }); + + // ─── financePermissions ─────────────────────────────────────────────────── + + Scenario('Getting financePermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageFinance from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canManageFinance property should return false', () => { + expect(financePermissions.canManageFinance).toBe(false); + }); + When('I set the canManageFinance property to true', () => { + financePermissions.canManageFinance = true; + }); + Then("the financePermissions' canManageFinance should be true", () => { + expect(doc.permissions?.financePermissions?.canManageFinance).toBe(true); + }); + }); + + Scenario('Getting and setting canViewGLBatchSummaries from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewGLBatchSummaries property should return false', () => { + expect(financePermissions.canViewGLBatchSummaries).toBe(false); + }); + When('I set the canViewGLBatchSummaries property to true', () => { + financePermissions.canViewGLBatchSummaries = true; + }); + Then("the financePermissions' canViewGLBatchSummaries should be true", () => { + expect(doc.permissions?.financePermissions?.canViewGLBatchSummaries).toBe(true); + }); + }); + + Scenario('Getting and setting canViewFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canViewFinanceConfigs property should return false', () => { + expect(financePermissions.canViewFinanceConfigs).toBe(false); + }); + When('I set the canViewFinanceConfigs property to true', () => { + financePermissions.canViewFinanceConfigs = true; + }); + Then("the financePermissions' canViewFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canViewFinanceConfigs).toBe(true); + }); + }); + + Scenario('Getting and setting canCreateFinanceConfigs from financePermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let financePermissions: StaffRoleFinancePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + financePermissions = permissions.financePermissions as StaffRoleFinancePermissionsAdapter; + }); + Then('the canCreateFinanceConfigs property should return false', () => { + expect(financePermissions.canCreateFinanceConfigs).toBe(false); + }); + When('I set the canCreateFinanceConfigs property to true', () => { + financePermissions.canCreateFinanceConfigs = true; + }); + Then("the financePermissions' canCreateFinanceConfigs should be true", () => { + expect(doc.permissions?.financePermissions?.canCreateFinanceConfigs).toBe(true); + }); + }); + + // ─── techAdminPermissions ───────────────────────────────────────────────── + + Scenario('Getting techAdminPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageTechAdmin from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canManageTechAdmin property should return false', () => { + expect(techAdminPermissions.canManageTechAdmin).toBe(false); + }); + When('I set the canManageTechAdmin property to true', () => { + techAdminPermissions.canManageTechAdmin = true; + }); + Then("the techAdminPermissions' canManageTechAdmin should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canManageTechAdmin).toBe(true); + }); + }); + + Scenario('Getting and setting canViewDatabaseExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewDatabaseExplorer property should return false', () => { + expect(techAdminPermissions.canViewDatabaseExplorer).toBe(false); + }); + When('I set the canViewDatabaseExplorer property to true', () => { + techAdminPermissions.canViewDatabaseExplorer = true; + }); + Then("the techAdminPermissions' canViewDatabaseExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewDatabaseExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewBlobExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewBlobExplorer property should return false', () => { + expect(techAdminPermissions.canViewBlobExplorer).toBe(false); + }); + When('I set the canViewBlobExplorer property to true', () => { + techAdminPermissions.canViewBlobExplorer = true; + }); + Then("the techAdminPermissions' canViewBlobExplorer should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewBlobExplorer).toBe(true); + }); + }); + + Scenario('Getting and setting canViewQueueDashboard from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canViewQueueDashboard property should return false', () => { + expect(techAdminPermissions.canViewQueueDashboard).toBe(false); + }); + When('I set the canViewQueueDashboard property to true', () => { + techAdminPermissions.canViewQueueDashboard = true; + }); + Then("the techAdminPermissions' canViewQueueDashboard should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewQueueDashboard).toBe(true); + }); + }); + + Scenario('Getting and setting canSendQueueMessages from techAdminPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; + }); + Then('the canSendQueueMessages property should return false', () => { + expect(techAdminPermissions.canSendQueueMessages).toBe(false); + }); + When('I set the canSendQueueMessages property to true', () => { + techAdminPermissions.canSendQueueMessages = true; + }); + Then("the techAdminPermissions' canSendQueueMessages should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canSendQueueMessages).toBe(true); + }); + }); + + // ─── userPermissions ────────────────────────────────────────────────────── + + Scenario('Getting userPermissions from permissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + }); + + Scenario('Getting and setting canManageUsers from userPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canManageUsers property should return false', () => { + expect(userPermissions.canManageUsers).toBe(false); + }); + When('I set the canManageUsers property to true', () => { + userPermissions.canManageUsers = true; + }); + Then("the userPermissions' canManageUsers should be true", () => { + expect(doc.permissions?.userPermissions?.canManageUsers).toBe(true); + }); + }); + + // ─── Lazy-init paths ────────────────────────────────────────────────────── + + Scenario('Lazy-initialising permissions when document has no permissions object', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no permissions object', () => { + const docWithoutPermissions = makeStaffRoleDoc(); + docWithoutPermissions.set = vi.fn().mockImplementation((key: string, value: unknown) => { + (docWithoutPermissions as unknown as Record)[key] = value; + }); + (docWithoutPermissions as unknown as Record)['permissions'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithoutPermissions); + }); + When('I get the permissions property', () => { + result = adapter.permissions; + }); + Then('it should return a StaffRolePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRolePermissionsAdapter); + }); + }); + + Scenario('Lazy-initialising communityPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['communityPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the communityPermissions property', () => { + result = permissions.communityPermissions; + }); + Then('it should return a StaffRoleCommunityPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleCommunityPermissionsAdapter); + }); + And('canManageCommunities should default to false', () => { + expect((result as StaffRoleCommunityPermissionsAdapter).canManageCommunities).toBe(false); + }); + }); + + Scenario('Lazy-initialising financePermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['financePermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the financePermissions property', () => { + result = permissions.financePermissions; + }); + Then('it should return a StaffRoleFinancePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleFinancePermissionsAdapter); + }); + And('canManageFinance should default to false', () => { + expect((result as StaffRoleFinancePermissionsAdapter).canManageFinance).toBe(false); + }); + }); + + Scenario('Lazy-initialising techAdminPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['techAdminPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the techAdminPermissions property', () => { + result = permissions.techAdminPermissions; + }); + Then('it should return a StaffRoleTechAdminPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleTechAdminPermissionsAdapter); + }); + And('canManageTechAdmin should default to false', () => { + expect((result as StaffRoleTechAdminPermissionsAdapter).canManageTechAdmin).toBe(false); + }); + }); + + Scenario('Lazy-initialising userPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record)['userPermissions'] = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + result = permissions.userPermissions; + }); + Then('it should return a StaffRoleUserPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleUserPermissionsAdapter); + }); + And('canManageUsers should default to false', () => { + expect((result as StaffRoleUserPermissionsAdapter).canManageUsers).toBe(false); + }); + }); + + Scenario('Getting roleType returns null when document roleType is undefined', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter wrapping a document with no roleType', () => { + const docWithout = makeStaffRoleDoc(); + (docWithout as unknown as Record)['roleType'] = undefined; + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the roleType property', () => { + result = adapter.roleType; + }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); }); test.for(typeConverterFeature, ({ Scenario, Background, BeforeEachScenario }) => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts index 751577717..0884c86b8 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts @@ -1,15 +1,17 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; - -import { Domain } from '@ocom/domain'; import type { StaffRole, StaffRoleCommunityPermissions, + StaffRoleFinancePermissions, StaffRolePermissions, StaffRolePropertyPermissions, StaffRoleServicePermissions, StaffRoleServiceTicketPermissions, + StaffRoleTechAdminPermissions, + StaffRoleUserPermissions, StaffRoleViolationTicketPermissions, } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import { Domain } from '@ocom/domain'; export class StaffRoleConverter extends MongooseSeedwork.MongoTypeConverter> { constructor() { @@ -24,8 +26,17 @@ export class StaffRoleDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt set roleName(roleName: string) { this.doc.roleName = roleName; + this.doc.enterpriseAppRole = roleName; } + get enterpriseAppRole(): string { + return this.doc.enterpriseAppRole ?? ''; + } + + set enterpriseAppRole(enterpriseAppRole: string) { + this.doc.enterpriseAppRole = enterpriseAppRole; + } + get isDefault(): boolean { return this.doc.isDefault; } @@ -56,6 +67,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo get communityPermissions(): Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { if (!this.doc.communityPermissions) { this.doc.communityPermissions = { + canManageCommunities: false, canManageStaffRolesAndPermissions: false, canManageAllCommunities: false, canDeleteCommunities: false, @@ -91,6 +103,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } @@ -103,11 +116,46 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo canCreateTickets: false, canManageTickets: false, canAssignTickets: false, + canUpdateTickets: false, canWorkOnTickets: false, }; } return new StaffRoleViolationTicketPermissionsAdapter(this.doc.violationTicketPermissions); } + + get financePermissions(): Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + if (!this.doc.financePermissions) { + this.doc.financePermissions = { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }; + } + return new StaffRoleFinancePermissionsAdapter(this.doc.financePermissions); + } + + get techAdminPermissions(): Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + if (!this.doc.techAdminPermissions) { + this.doc.techAdminPermissions = { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }; + } + return new StaffRoleTechAdminPermissionsAdapter(this.doc.techAdminPermissions); + } + + get userPermissions(): Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + if (!this.doc.userPermissions) { + this.doc.userPermissions = { + canManageUsers: false, + }; + } + return new StaffRoleUserPermissionsAdapter(this.doc.userPermissions); + } } export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { @@ -125,6 +173,13 @@ export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.Use return this.doc.id?.toString(); } + get canManageCommunities(): boolean { + return this.ensureValue(this.doc.canManageCommunities); + } + set canManageCommunities(value: boolean) { + this.doc.canManageCommunities = value; + } + get canManageStaffRolesAndPermissions(): boolean { return this.ensureValue(this.doc.canManageStaffRolesAndPermissions); } @@ -269,3 +324,121 @@ export class StaffRoleViolationTicketPermissionsAdapter implements Domain.Contex this.doc.canWorkOnTickets = value; } } + +export class StaffRoleFinancePermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleFinancePermissionsProps { + private readonly doc: StaffRoleFinancePermissions; + + constructor(permissions: StaffRoleFinancePermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageFinance(): boolean { + return this.ensureValue(this.doc.canManageFinance); + } + set canManageFinance(value: boolean) { + this.doc.canManageFinance = value; + } + + get canViewGLBatchSummaries(): boolean { + return this.ensureValue(this.doc.canViewGLBatchSummaries); + } + set canViewGLBatchSummaries(value: boolean) { + this.doc.canViewGLBatchSummaries = value; + } + + get canViewFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canViewFinanceConfigs); + } + set canViewFinanceConfigs(value: boolean) { + this.doc.canViewFinanceConfigs = value; + } + + get canCreateFinanceConfigs(): boolean { + return this.ensureValue(this.doc.canCreateFinanceConfigs); + } + set canCreateFinanceConfigs(value: boolean) { + this.doc.canCreateFinanceConfigs = value; + } +} + +export class StaffRoleTechAdminPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleTechAdminPermissionsProps { + private readonly doc: StaffRoleTechAdminPermissions; + + constructor(permissions: StaffRoleTechAdminPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageTechAdmin(): boolean { + return this.ensureValue(this.doc.canManageTechAdmin); + } + set canManageTechAdmin(value: boolean) { + this.doc.canManageTechAdmin = value; + } + + get canViewDatabaseExplorer(): boolean { + return this.ensureValue(this.doc.canViewDatabaseExplorer); + } + set canViewDatabaseExplorer(value: boolean) { + this.doc.canViewDatabaseExplorer = value; + } + + get canViewBlobExplorer(): boolean { + return this.ensureValue(this.doc.canViewBlobExplorer); + } + set canViewBlobExplorer(value: boolean) { + this.doc.canViewBlobExplorer = value; + } + + get canViewQueueDashboard(): boolean { + return this.ensureValue(this.doc.canViewQueueDashboard); + } + set canViewQueueDashboard(value: boolean) { + this.doc.canViewQueueDashboard = value; + } + + get canSendQueueMessages(): boolean { + return this.ensureValue(this.doc.canSendQueueMessages); + } + set canSendQueueMessages(value: boolean) { + this.doc.canSendQueueMessages = value; + } +} + +export class StaffRoleUserPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleUserPermissionsProps { + private readonly doc: StaffRoleUserPermissions; + + constructor(permissions: StaffRoleUserPermissions) { + this.doc = permissions; + } + + private ensureValue(value: boolean | undefined): boolean { + return value ?? false; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canManageUsers(): boolean { + return this.ensureValue(this.doc.canManageUsers); + } + set canManageUsers(value: boolean) { + this.doc.canManageUsers = value; + } +} diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts index a68364db6..dc0db7f1c 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts @@ -17,6 +17,7 @@ function makeStaffRoleDoc(overrides: Partial = {}) { const base = { _id: 'role-1', roleName: 'Manager', + enterpriseAppRole: 'Staff.CaseManager', isDefault: false, roleType: 'staff', permissions: { @@ -84,10 +85,17 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }; Object.assign(ModelMock, { findById: vi.fn((id: string) => ({ - exec: vi.fn(async () => (id === String(staffRoleDoc._id) ? staffRoleDoc : null)), + exec: vi.fn(() => Promise.resolve(id === String(staffRoleDoc._id) ? staffRoleDoc : null)), })), - findOne: vi.fn((query: { roleName: string }) => ({ - exec: vi.fn(async () => (query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null)), + findOne: vi.fn((query: { roleName?: string; isDefault?: boolean; enterpriseAppRole?: string }) => ({ + exec: vi.fn(() => { + if (query.enterpriseAppRole !== undefined) { + return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && query.isDefault === staffRoleDoc.isDefault + ? staffRoleDoc + : null; + } + return query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null; + }), })), prototype: {}, }); @@ -167,6 +175,36 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + Scenario('Getting a default staff role by enterpriseAppRole', ({ Given, When, Then, And }) => { + let result: Domain.Contexts.User.StaffRole.StaffRole; + Given('a valid default Mongoose StaffRole document with enterpriseAppRole "Staff.CaseManager"', () => { + staffRoleDoc = makeStaffRoleDoc({ + isDefault: true, + enterpriseAppRole: 'Staff.CaseManager', + }); + }); + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager"', async () => { + result = await repo.getDefaultRoleByEnterpriseAppRole('Staff.CaseManager'); + }); + Then('I should receive a StaffRole domain object', () => { + expect(result).toBeInstanceOf(Domain.Contexts.User.StaffRole.StaffRole); + }); + And("the domain object's isDefault should be true", () => { + expect(result.isDefault).toBe(true); + }); + }); + + Scenario('Getting a default staff role by enterpriseAppRole that does not exist', ({ When, Then }) => { + let getDefaultRoleByEnterpriseAppRole: () => Promise; + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.UnknownRole"', () => { + getDefaultRoleByEnterpriseAppRole = async () => await repo.getDefaultRoleByEnterpriseAppRole('Staff.UnknownRole'); + }); + Then('an error should be thrown indicating "Default StaffRole with enterpriseAppRole Staff.UnknownRole not found"', async () => { + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(); + await expect(getDefaultRoleByEnterpriseAppRole).rejects.toThrow(/Default StaffRole with enterpriseAppRole Staff.UnknownRole not found/); + }); + }); + Scenario('Creating a new staff role instance', ({ When, Then, And }) => { let result: Domain.Contexts.User.StaffRole.StaffRole; When('I call getNewInstance with name "Supervisor"', async () => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts index 7d3e075a5..4cf3379e0 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.ts @@ -27,8 +27,36 @@ export class StaffRoleRepository return this.typeConverter.toDomain(staffRole, this.passport); } + async getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise> { + const staffRole = await this.model.findOne({ isDefault: true, enterpriseAppRole }).exec(); + if (!staffRole) { + throw new Error(`Default StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`); + } + return this.typeConverter.toDomain(staffRole, this.passport); + } + getNewInstance(name: string): Promise> { const adapter = this.typeConverter.toAdapter(new this.model()); return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewInstance(adapter, this.passport, name, false)); } + + getNewDefaultCaseManagerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultCaseManagerInstance(adapter, this.passport)); + } + + getNewDefaultServiceLineOwnerInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultServiceLineOwnerInstance(adapter, this.passport)); + } + + getNewDefaultFinanceInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultFinanceInstance(adapter, this.passport)); + } + + getNewDefaultTechAdminInstance(): Promise> { + const adapter = this.typeConverter.toAdapter(new this.model()); + return Promise.resolve(Domain.Contexts.User.StaffRole.StaffRole.getNewDefaultTechAdminInstance(adapter, this.passport)); + } } diff --git a/packages/ocom/persistence/src/datasources/readonly/index.test.ts b/packages/ocom/persistence/src/datasources/readonly/index.test.ts index 04d231428..536162ec4 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.test.ts @@ -1,13 +1,13 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; - -import type { Domain } from '@ocom/domain'; -import { ReadonlyDataSourceImplementation } from './index.ts'; import type { CommunityModelType } from '@ocom/data-sources-mongoose-models/community'; import type { MemberModelType } from '@ocom/data-sources-mongoose-models/member'; import type { EndUserModelType } from '@ocom/data-sources-mongoose-models/user/end-user'; +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; +import { ReadonlyDataSourceImplementation } from './index.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -32,6 +32,12 @@ function makeMockModelsContext() { create: vi.fn(), aggregate: vi.fn(), } as unknown as EndUserModelType, + StaffUser: { + findById: vi.fn(), + findOne: vi.fn(), + find: vi.fn(), + create: vi.fn(), + } as unknown as StaffUserModelType, } as unknown as Parameters[0]; } @@ -101,6 +107,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { And('the User property should have the correct structure', () => { expect(result.User).toHaveProperty('EndUser'); expect(result.User.EndUser).toHaveProperty('EndUserReadRepo'); + expect(result.User).toHaveProperty('StaffUser'); + expect(result.User.StaffUser).toHaveProperty('StaffUserReadRepo'); }); }); }); diff --git a/packages/ocom/persistence/src/datasources/readonly/index.ts b/packages/ocom/persistence/src/datasources/readonly/index.ts index d8940adb8..9342ba8ad 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.ts @@ -5,6 +5,7 @@ import { CommunityContext } from './community/index.ts'; import type * as Member from './community/member/index.ts'; import type * as EndUser from './user/end-user/index.ts'; import { UserContext } from './user/index.ts'; +import type * as StaffUser from './user/staff-user/index.ts'; export interface ReadonlyDataSource { Community: { @@ -19,6 +20,9 @@ export interface ReadonlyDataSource { EndUser: { EndUserReadRepo: EndUser.EndUserReadRepository; }; + StaffUser: { + StaffUserReadRepo: StaffUser.StaffUserReadRepository; + }; }; } diff --git a/packages/ocom/persistence/src/datasources/readonly/user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/index.ts index 54cb3892a..ab40bc6e7 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/index.ts @@ -1,7 +1,9 @@ import type { Domain } from '@ocom/domain'; import type { ModelsContext } from '../../../index.ts'; import { EndUserReadRepositoryImpl } from './end-user/index.ts'; +import { StaffUserReadRepositoryImpl } from './staff-user/index.ts'; export const UserContext = (models: ModelsContext, passport: Domain.Passport) => ({ EndUser: EndUserReadRepositoryImpl(models, passport), + StaffUser: StaffUserReadRepositoryImpl(models, passport), }); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature new file mode 100644 index 000000000..9aa56131b --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature @@ -0,0 +1,23 @@ +Feature: StaffUserReadRepository + + Scenario: Creating StaffUserReadRepository throws when StaffUser model is missing + Given models context does not contain a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then it should throw an error with message "StaffUser model is not available in the mongoose context" + + Scenario: Creating StaffUserReadRepository succeeds when StaffUser model is present + Given models context contains a StaffUser model + When I call getStaffUserReadRepository with those models and a passport + Then I should receive a StaffUserReadRepository instance + And the repository should have a getByExternalId method + + Scenario: getByExternalId returns entity when document is found + Given a StaffUser document exists with externalId "ext-abc" + When I call getByExternalId with "ext-abc" + Then I should receive a StaffUserEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getByExternalId returns null when no document is found + Given no StaffUser document exists with externalId "missing-ext" + When I call getByExternalId with "missing-ext" + Then I should receive null diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts new file mode 100644 index 000000000..75eef71cf --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/index.ts @@ -0,0 +1,11 @@ +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; + +export type { StaffUserReadRepository } from './staff-user.read-repository.ts'; + +export const StaffUserReadRepositoryImpl = (models: ModelsContext, passport: Domain.Passport) => { + return { + StaffUserReadRepo: getStaffUserReadRepository(models, passport), + }; +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts new file mode 100644 index 000000000..c317f0709 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts @@ -0,0 +1,141 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi } from 'vitest'; + +import type { Domain } from '@ocom/domain'; +import type { StaffUser, StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; +import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; +import type { StaffUserReadRepository } from './staff-user.read-repository.ts'; + +const test = { for: describeFeature }; + +vi.mock('../../../domain/user/staff-user/staff-user.domain-adapter.ts', () => ({ + StaffUserConverter: vi.fn(), +})); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.read-repository.feature')); + +function makeMockPassport() { + return { + user: { + forStaffUser: vi.fn(() => ({ + determineIf: vi.fn(() => true), + })), + }, + } as unknown as Domain.Passport; +} + +function makeMockStaffUserDocument() { + return { + _id: 'doc-id', + id: 'doc-id', + externalId: 'ext-abc', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + } as unknown as StaffUser; +} + +function makeMockModel(doc: StaffUser | null) { + return { + findOne: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(doc), + }), + }), + } as unknown as StaffUserModelType; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let models: ModelsContext; + let passport: Domain.Passport; + let repository: StaffUserReadRepository; + let mockStaffUserDoc: StaffUser; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null | unknown; + let mockConverter: { toDomain: ReturnType }; + let thrownError: unknown; + + BeforeEachScenario(() => { + passport = makeMockPassport(); + mockStaffUserDoc = makeMockStaffUserDocument(); + thrownError = undefined; + result = undefined; + + mockConverter = { + toDomain: vi.fn((_doc: StaffUser, _passport: Domain.Passport) => ({ + id: mockStaffUserDoc.id, + externalId: mockStaffUserDoc.externalId, + })), + }; + + vi.mocked(StaffUserConverter).mockImplementation(function MockStaffUserConverter() { + return mockConverter as unknown as StaffUserConverter; + }); + }); + + Scenario('Creating StaffUserReadRepository throws when StaffUser model is missing', ({ Given, When, Then }) => { + Given('models context does not contain a StaffUser model', () => { + models = {} as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + try { + repository = getStaffUserReadRepository(models, passport); + } catch (err) { + thrownError = err; + } + }); + Then('it should throw an error with message "StaffUser model is not available in the mongoose context"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('StaffUser model is not available in the mongoose context'); + }); + }); + + Scenario('Creating StaffUserReadRepository succeeds when StaffUser model is present', ({ Given, When, Then, And }) => { + Given('models context contains a StaffUser model', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + }); + When('I call getStaffUserReadRepository with those models and a passport', () => { + repository = getStaffUserReadRepository(models, passport); + }); + Then('I should receive a StaffUserReadRepository instance', () => { + expect(repository).toBeDefined(); + }); + And('the repository should have a getByExternalId method', () => { + expect(typeof repository.getByExternalId).toBe('function'); + }); + }); + + Scenario('getByExternalId returns entity when document is found', ({ Given, When, Then, And }) => { + Given('a StaffUser document exists with externalId "ext-abc"', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "ext-abc"', async () => { + result = await repository.getByExternalId('ext-abc'); + }); + Then('I should receive a StaffUserEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockStaffUserDoc, passport); + }); + }); + + Scenario('getByExternalId returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffUser document exists with externalId "missing-ext"', () => { + models = { StaffUser: makeMockModel(null) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "missing-ext"', async () => { + result = await repository.getByExternalId('missing-ext'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts new file mode 100644 index 000000000..0824f8934 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts @@ -0,0 +1,35 @@ +import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; + +export interface StaffUserReadRepository { + getByExternalId: (externalId: string) => Promise; +} + +class StaffUserReadRepositoryImpl implements StaffUserReadRepository { + private readonly model: StaffUserModelType; + private readonly converter: StaffUserConverter; + private readonly passport: Domain.Passport; + + constructor(models: ModelsContext, passport: Domain.Passport) { + if (!models.StaffUser) { + throw new Error('StaffUser model is not available in the mongoose context'); + } + this.model = models.StaffUser; + this.converter = new StaffUserConverter(); + this.passport = passport; + } + + async getByExternalId(externalId: string): Promise { + const doc = await this.model.findOne({ externalId }).populate('role').exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } +} + +export const getStaffUserReadRepository = (models: ModelsContext, passport: Domain.Passport): StaffUserReadRepository => { + return new StaffUserReadRepositoryImpl(models, passport); +}; diff --git a/packages/ocom/service-token-validation/src/index.test.ts b/packages/ocom/service-token-validation/src/index.test.ts index 8bf1747cb..203525c8e 100644 --- a/packages/ocom/service-token-validation/src/index.test.ts +++ b/packages/ocom/service-token-validation/src/index.test.ts @@ -228,7 +228,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Mock successful verification on second attempt mockGetVerifiedJwt - .mockResolvedValueOnce(null) // First provider fails + .mockRejectedValueOnce(Object.assign(new Error('signature verification failed'), { name: 'JWSSignatureVerificationFailed' })) // First provider fails with signature mismatch .mockResolvedValueOnce({ // Second provider succeeds payload: { sub: 'user123', aud: 'audience2' }, diff --git a/packages/ocom/service-token-validation/src/index.ts b/packages/ocom/service-token-validation/src/index.ts index b002722c6..c8824fa05 100644 --- a/packages/ocom/service-token-validation/src/index.ts +++ b/packages/ocom/service-token-validation/src/index.ts @@ -39,12 +39,18 @@ export class ServiceTokenValidation implements ServiceBase { async verifyJwt(token: string): Promise | null> { // Try each config key for verification for (const configKey of this.tokenSettings.keys()) { - const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); - if (result?.payload) { - return { - verifiedJwt: result.payload as ClaimsType, - openIdConfigKey: configKey, - }; + try { + const result = await this.tokenVerifier.getVerifiedJwt(token, configKey); + if (result?.payload) { + return { + verifiedJwt: result.payload as ClaimsType, + openIdConfigKey: configKey, + }; + } + } catch (error) { + if (!this.isRetryableVerificationError(error)) { + throw error; + } } } return null; @@ -74,4 +80,12 @@ export class ServiceTokenValidation implements ServiceBase { return defaultValue; } } + + private isRetryableVerificationError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return ['JWSSignatureVerificationFailed', 'JWTClaimValidationFailed', 'JWTExpired', 'JWTInvalid', 'JWSInvalid'].includes(error.name); + } } diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx index 4930c0c3b..3fc91da04 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.stories.tsx @@ -80,6 +80,7 @@ const mockData = { export const Default: Story = { args: { data: mockData, + canCreateCommunity: true, } satisfies CommunityListProps, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx index c0f38203c..901ed7afc 100644 --- a/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx +++ b/packages/ocom/ui-community-route-accounts/src/components/community-list.tsx @@ -16,7 +16,6 @@ export interface CommunityListProps { export const CommunityList: React.FC = (props) => { const [communityList, setCommunityList] = useState(props.data.communities); const navigate = useNavigate(); - const onChange = (event: ChangeEvent) => { const searchValue = event.target.value; if (searchValue === '') { @@ -120,12 +119,12 @@ export const CommunityList: React.FC = (props) => {

Navigate to a Community

- +
{ const pageLayouts: PageLayoutProps[] = [ { @@ -21,7 +27,10 @@ export const Admin: React.FC = () => { icon: , id: 2, parent: 'ROOT', - // hasPermissions: (member: Member) => member?.isAdmin ?? false + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, { path: '/community/:communityId/admin/:memberId/settings/*', @@ -29,9 +38,10 @@ export const Admin: React.FC = () => { icon: , id: 3, parent: 'ROOT', - // Note: Permission check would be: - // hasPermissions: (member: Member) => member?.role?.permissions?.communityPermissions?.canManageCommunitySettings ?? false - // Currently schema doesn't include role/permissions, so we allow all admin users to access settings + hasPermissions: (data: unknown) => { + const adminData = data as AdminMenuData; + return adminData?.member?.isAdmin ?? false; + }, }, ]; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx index f446d084d..02fedcf53 100644 --- a/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx +++ b/packages/ocom/ui-community-route-admin/src/section-layout.container.tsx @@ -2,7 +2,10 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; import type { PageLayoutProps } from '@ocom/ui-shared'; import { useParams } from 'react-router-dom'; -import { AdminSectionLayoutContainerMembersForCurrentEndUserDocument, type Member } from './generated.tsx'; +import { + AdminSectionLayoutContainerMembersForCurrentEndUserDocument, + type Member, +} from './generated.tsx'; import { SectionLayout } from './section-layout.tsx'; interface SectionLayoutContainerProps { @@ -14,6 +17,7 @@ export const SectionLayoutContainer: React.FC = (pr const { data: membersData, loading: membersLoading, error: membersError } = useQuery(AdminSectionLayoutContainerMembersForCurrentEndUserDocument); + return ( [ + { + path: '/community/:communityId/admin/:memberId', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: '/community/:communityId/admin/:memberId/members/*', + title: 'Members', + icon: , + id: 2, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageUsers ?? false, + }, + { + path: '/community/:communityId/admin/:memberId/settings/*', + title: 'Settings', + icon: , + id: 3, + parent: 'ROOT', + hasPermissions: () => permissions?.canManageCommunities ?? false, + }, +]; + +const meta: Meta = { + title: 'Admin/Layouts/SectionLayout', + component: SectionLayout, + decorators: [ + (Story) => ( + + + + } + /> + + + + ), + ], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllPermissions: Story = { + args: { + pageLayouts: makePageLayouts(allPermissions), + memberData: mockMember, + staffSectionPermissions: allPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const NoPermissions: Story = { + args: { + pageLayouts: makePageLayouts(noPermissions), + memberData: mockMember, + staffSectionPermissions: noPermissions, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const CommunityPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageCommunities: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageCommunities: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const UserPermissionsOnly: Story = { + args: { + pageLayouts: makePageLayouts({ ...noPermissions, canManageUsers: true }), + memberData: mockMember, + staffSectionPermissions: { ...noPermissions, canManageUsers: true }, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; + +export const NullPermissions: Story = { + args: { + pageLayouts: makePageLayouts(null), + memberData: mockMember, + staffSectionPermissions: null, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.queryByText('Members')).not.toBeInTheDocument(); + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-community-route-admin/src/section-layout.tsx b/packages/ocom/ui-community-route-admin/src/section-layout.tsx index 85e655538..846dbfb20 100644 --- a/packages/ocom/ui-community-route-admin/src/section-layout.tsx +++ b/packages/ocom/ui-community-route-admin/src/section-layout.tsx @@ -21,6 +21,13 @@ const handleToggler = (isExpanded: boolean, setIsExpanded: (value: boolean) => v } }; +export interface AdminStaffSectionPermissions { + canManageCommunities: boolean; + canManageUsers: boolean; + canManageFinance: boolean; + canManageTechAdmin: boolean; +} + interface AdminSectionLayoutProps { pageLayouts: PageLayoutProps[]; memberData: Member; @@ -36,7 +43,7 @@ export const SectionLayout: React.FC = (props) => { const menuComponentProps: MenuComponentProps = { pageLayouts: props.pageLayouts, - memberData: props.memberData, + memberData: { member: props.memberData }, theme: 'light', mode: 'inline', }; diff --git a/packages/ocom/ui-staff-route-community-management/src/index.tsx b/packages/ocom/ui-staff-route-community-management/src/index.tsx index 90ceaf1cc..3bbe9a551 100644 --- a/packages/ocom/ui-staff-route-community-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-community-management/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-finance/src/index.tsx b/packages/ocom/ui-staff-route-finance/src/index.tsx index c7116f4ad..fb0360d17 100644 --- a/packages/ocom/ui-staff-route-finance/src/index.tsx +++ b/packages/ocom/ui-staff-route-finance/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-route-user-management/src/index.tsx b/packages/ocom/ui-staff-route-user-management/src/index.tsx index 33b2a3f38..f2c3911df 100644 --- a/packages/ocom/ui-staff-route-user-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-user-management/src/index.tsx @@ -16,7 +16,7 @@ export const Root: React.FC = () => { } /> @@ -26,7 +26,7 @@ export const Root: React.FC = () => { } /> diff --git a/packages/ocom/ui-staff-shared/package.json b/packages/ocom/ui-staff-shared/package.json index 390618cf5..33e6bf774 100644 --- a/packages/ocom/ui-staff-shared/package.json +++ b/packages/ocom/ui-staff-shared/package.json @@ -15,7 +15,10 @@ "test:watch": "vitest" }, "dependencies": { + "@apollo/client": "^3.13.9", "@ant-design/icons": "catalog:", + "@cellix/ui-core": "workspace:*", + "@graphql-typed-document-node/core": "^3.2.0", "@ocom/ui-shared": "workspace:*", "react": "catalog:", "react-dom": "catalog:", @@ -28,6 +31,7 @@ "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", "jsdom": "catalog:", + "react-dom": "^19.1.1", "vite": "catalog:", "vitest": "catalog:", "typescript": "catalog:" diff --git a/packages/ocom/ui-staff-shared/src/index.tsx b/packages/ocom/ui-staff-shared/src/index.tsx index ede529df0..d28d20e4c 100644 --- a/packages/ocom/ui-staff-shared/src/index.tsx +++ b/packages/ocom/ui-staff-shared/src/index.tsx @@ -2,7 +2,10 @@ import React, { createElement, type FC } from 'react'; import { SectionLayout } from './section-layout.tsx'; export { VerticalTabs } from '@ocom/ui-shared'; +export { RequireRole, type RequireRoleProps } from './require-role.tsx'; export { SectionLayout, type SectionLayoutProps } from './section-layout.tsx'; +export { SectionLayoutContainer } from './section-layout.container.tsx'; +export { extractRoles, type StaffAppRole, StaffAppRoles, staffRouteRoles } from './staff-app-roles.ts'; export { type StaffAuth, StaffAuthContext, StaffAuthProvider, StaffRouteShell, type StaffRouteShellProps } from './staff-route-shell.tsx'; export { SubPageLayout } from './sub-page-layout.tsx'; @@ -19,20 +22,13 @@ import { StaffAuthContext } from './staff-route-shell.tsx'; export const PlaceholderPage: React.FC = ({ sectionName, description, expectedRoles, explicitRoles }) => { const auth = React.useContext(StaffAuthContext); - const resolvedRoles = React.useMemo(() => { + const resolvedPermissions = React.useMemo(() => { if (explicitRoles && explicitRoles.length > 0) return explicitRoles; - if (auth) { - const a = auth as StaffAuth; - if (Array.isArray(a.roles) && a.roles.length > 0) return a.roles as string[]; - type RawProfile = { roles?: unknown; role?: unknown }; - const raw = a.raw as RawProfile | undefined; - if (raw) { - const maybe = raw.roles ?? raw.role ?? undefined; - if (Array.isArray(maybe)) return maybe as string[]; - if (typeof maybe === 'string') return [maybe]; - } - } - return []; + const perms = auth?.permissions; + if (!perms) return []; + return Object.entries(perms) + .filter(([, isEnabled]) => isEnabled === true) + .map(([permKey]) => permKey); }, [auth, explicitRoles]); const identitySummary = React.useMemo<{ displayName: string; identifier: string | undefined } | null>(() => { @@ -70,11 +66,11 @@ export const PlaceholderPage: React.FC = ({ sectionName, descr )}
-
Resolved Roles
- {resolvedRoles && resolvedRoles.length > 0 ? ( +
Resolved Permissions
+ {resolvedPermissions.length > 0 ? (
    - {resolvedRoles.map((r) => ( -
  • {r}
  • + {resolvedPermissions.map((permission) => ( +
  • {permission}
  • ))}
) : ( diff --git a/packages/ocom/ui-staff-shared/src/require-role.test.tsx b/packages/ocom/ui-staff-shared/src/require-role.test.tsx new file mode 100644 index 000000000..25db78a99 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.test.tsx @@ -0,0 +1,130 @@ +import type * as React from 'react'; +import { renderToString } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RequireRole } from './require-role.tsx'; +import { StaffAuthProvider } from './staff-route-shell.tsx'; + +const useQueryMock = vi.fn(); +vi.mock('@apollo/client', () => ({ + gql: (strings: TemplateStringsArray, ...values: unknown[]) => String.raw({ raw: strings }, ...values), + useQuery: (...args: unknown[]) => useQueryMock(...args), +})); + +const Protected: React.FC = () =>
protected content
; + +describe('RequireRole', () => { + beforeEach(() => { + useQueryMock.mockReset(); + }); + + it('renders children when the permission key is true', () => { + useQueryMock.mockReturnValue({ + loading: false, + error: undefined, + data: { + staffUserCurrent: { + role: { + permissions: { + communityPermissions: { canManageCommunities: false }, + userPermissions: { canManageUsers: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: true }, + }, + }, + }, + }, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).toContain('protected content'); + }); + + it('redirects to /unauthorized when the permission key is false', () => { + useQueryMock.mockReturnValue({ + loading: false, + error: undefined, + data: { + staffUserCurrent: { + role: { + permissions: { + communityPermissions: { canManageCommunities: false }, + userPermissions: { canManageUsers: false }, + financePermissions: { canManageFinance: true }, + techAdminPermissions: { canManageTechAdmin: false }, + }, + }, + }, + }, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).not.toContain('protected content'); + }); + + it('redirects to /unauthorized when query returns an error', () => { + useQueryMock.mockReturnValue({ + loading: false, + error: new Error('network error'), + data: undefined, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).not.toContain('protected content'); + }); + + it('does not render protected content while loading', () => { + useQueryMock.mockReturnValue({ + loading: true, + error: undefined, + data: undefined, + }); + const identity = {}; + const html = renderToString( + + + + + + + , + ); + expect(html).not.toContain('protected content'); + }); +}); diff --git a/packages/ocom/ui-staff-shared/src/require-role.tsx b/packages/ocom/ui-staff-shared/src/require-role.tsx new file mode 100644 index 000000000..71f2b6d02 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/require-role.tsx @@ -0,0 +1,81 @@ +import { gql, useQuery } from '@apollo/client'; +import type { FC, ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import type { StaffAuth } from './staff-route-shell.tsx'; + +export interface RequireRoleProps { + /** Deprecated. Frontend authorization must use backend permission flags. */ + roles: readonly string[]; + /** Gate by backend permission flag. */ + permKey?: keyof NonNullable; + children: ReactNode; +} + +const STAFF_USER_CURRENT_QUERY = gql` + query RequireRoleStaffUserCurrent { + staffUserCurrent: currentStaffUserAndCreateIfNotExists { + role { + permissions { + communityPermissions { + canManageCommunities + } + userPermissions { + canManageUsers + } + financePermissions { + canManageFinance + } + techAdminPermissions { + canManageTechAdmin + } + } + } + } + } +`; + +interface StaffUserCurrentQueryResult { + staffUserCurrent: { + role?: { + permissions: { + communityPermissions: { canManageCommunities: boolean }; + userPermissions: { canManageUsers: boolean }; + financePermissions: { canManageFinance: boolean }; + techAdminPermissions: { canManageTechAdmin: boolean }; + }; + }; + }; +} + +export const RequireRole: FC = ({ roles, permKey, children }) => { + void roles; + const { data, loading, error } = useQuery(STAFF_USER_CURRENT_QUERY, { + fetchPolicy: 'cache-first', + }); + + if (loading) { + return null; + } + + const rolePermissions = data?.staffUserCurrent?.role?.permissions; + const permissions: NonNullable | undefined = rolePermissions + ? { + canManageCommunities: rolePermissions.communityPermissions.canManageCommunities, + canManageUsers: rolePermissions.userPermissions.canManageUsers, + canManageFinance: rolePermissions.financePermissions.canManageFinance, + canManageTechAdmin: rolePermissions.techAdminPermissions.canManageTechAdmin, + } + : undefined; + const isAuthorized = permKey !== undefined && permissions?.[permKey] === true; + + if (error || !isAuthorized) { + return ( + + ); + } + + return <>{children}; +}; diff --git a/packages/ocom/ui-staff-shared/src/section-layout-header.graphql b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql new file mode 100644 index 000000000..e744d3fde --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout-header.graphql @@ -0,0 +1,13 @@ +query SectionLayoutHeaderCurrentStaffUser { + currentStaffUserAndCreateIfNotExists { + ...SectionLayoutHeaderStaffUserFields + } +} + +fragment SectionLayoutHeaderStaffUserFields on StaffUser { + id + displayName + firstName + lastName + email +} diff --git a/packages/ocom/ui-staff-shared/src/section-layout.container.tsx b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx new file mode 100644 index 000000000..4209b71d6 --- /dev/null +++ b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { PageLayoutProps } from '@ocom/ui-shared'; +import type React from 'react'; +import { SectionLayoutHeaderCurrentStaffUserDocument } from './generated.tsx'; +import { SectionLayout } from './section-layout.tsx'; + +interface SectionLayoutContainerProps { + pageLayouts: PageLayoutProps[]; +} + +export const SectionLayoutContainer: React.FC = (props) => { + const { data: staffUserData, loading: staffUserLoading, error: staffUserError } = useQuery( + SectionLayoutHeaderCurrentStaffUserDocument, + { + fetchPolicy: 'cache-first', + }, + ); + + const displayName = staffUserData?.currentStaffUserAndCreateIfNotExists?.displayName; + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayoutContainer] GraphQL query result:', { + loading: staffUserLoading, + error: staffUserError?.message, + staffUserData, + extractedDisplayName: displayName, + }); + } + } + + const sectionLayoutProps: React.ComponentProps = { + pageLayouts: props.pageLayouts, + // Always pass displayName (even if undefined) so the component can properly handle fallback chain + ...(displayName && { displayName }), + }; + + return ( + } + error={staffUserError} + /> + ); +}; + diff --git a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx index bd43e89e1..f2d05c4cb 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx @@ -18,36 +18,81 @@ const renderIntoDocument = (node: React.ReactNode) => { }; describe('SectionLayout merging behaviour', () => { - it('renders canonical staff navigation merged with consumer pageLayouts', async () => { - const consumerLayouts = [ - { - path: '/staff/community-management', - title: 'Community Management', - icon: , - id: 'ROOT', - }, - ]; + it('renders only the menu items the user has permission for', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + it('shows no menu items when permissions are undefined (loading or no role assigned)', async () => { const container = renderIntoDocument( } + element={} /> , ); - // Wait a tick for ant design components to mount await new Promise((r) => setTimeout(r, 10)); - // Top-level menu items expected - expect(container.textContent).not.toContain('Home'); - expect(container.textContent).toContain('Communities'); - expect(container.textContent).toContain('Users'); + expect(container.textContent).not.toContain('Communities'); + expect(container.textContent).not.toContain('Users'); + expect(container.textContent).not.toContain('Finance'); + expect(container.textContent).not.toContain('Tech Admin'); + }); + + it('renders finance menu from JWT role when backend permissions are unavailable', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).not.toContain('Communities'); + expect(container.textContent).not.toContain('Users'); expect(container.textContent).toContain('Finance'); - expect(container.textContent).toContain('Tech Admin'); + expect(container.textContent).not.toContain('Tech Admin'); }); it('preserves default parent when consumer entry omits parent field', async () => { @@ -63,12 +108,23 @@ describe('SectionLayout merging behaviour', () => { const container = renderIntoDocument( - - } - /> - + + + } + /> + + , ); @@ -142,3 +198,93 @@ describe('PlaceholderPage', () => { expect(container.textContent).toContain('m@example.com'); }); }); + +describe('SectionLayout with displayName prop', () => { + it('renders displayName from prop when provided', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Alice Johnson'); + expect(container.textContent).toContain('Log Out'); + }); + + it('falls back to auth context name when displayName prop is not provided', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Bob Smith'); + }); + + it('uses displayName prop over auth context when both are available', async () => { + const container = renderIntoDocument( + + + + } + /> + + + , + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(container.textContent).toContain('Prop Name'); + expect(container.textContent).not.toContain('Auth Name'); + }); +}); diff --git a/packages/ocom/ui-staff-shared/src/section-layout.tsx b/packages/ocom/ui-staff-shared/src/section-layout.tsx index cd27b722f..45cc05295 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.tsx @@ -30,10 +30,27 @@ export interface SectionLayoutProps { headerContent?: React.ReactNode; /** Optional injected logged in user component (extension slot). */ loggedInUser?: React.ReactNode; + /** Optional displayName from container (e.g., from GraphQL query). When provided, takes priority over auth context. */ + displayName?: string; } export const SectionLayout: React.FC = (props) => { const auth = useContext(StaffAuthContext); + + // Debug logging to track displayName flow + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const href = window.location.href; + if (href.includes('dev') || href.includes('localhost')) { + console.debug('[SectionLayout] Component props & fallback chain:', { + propsDisplayName: props.displayName, + authName: auth?.name, + authUsername: auth?.username, + authEmail: auth?.email, + resolvedDisplayName: props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User', + }); + } + } + // Guard access to localStorage so this component is safe during server-side rendering (no globalThis/localStorage) const [isExpanded, setIsExpanded] = useState(() => { if (typeof globalThis === 'undefined') return true; // default to expanded during SSR @@ -54,35 +71,39 @@ export const SectionLayout: React.FC = (props) => { // Merge canonical staff navigation with consumer-provided pageLayouts. // Defaults are added only when the consumer hasn't provided an entry with the same id. // Consumer-provided entries override defaults when ids conflict. - const defaultPageLayouts: PageLayoutProps[] = [ - { - path: '/staff/community-management', - title: 'Communities', - icon: , - id: 'ROOT', - }, - { - path: '/staff/user-management/*', - title: 'Users', - icon: , - id: 'users', - parent: 'ROOT', - }, - { - path: '/staff/finance/*', - title: 'Finance', - icon: , - id: 'finance', - parent: 'ROOT', - }, - { - path: '/staff/tech/*', - title: 'Tech Admin', - icon: , - id: 'tech', - parent: 'ROOT', - }, - ]; + // Build default page layouts from backend permissions. + const perms = auth?.permissions; + const canManageCommunities = perms?.canManageCommunities === true; + const canManageUsers = perms?.canManageUsers === true; + const canManageFinance = perms?.canManageFinance === true; + const canManageTechAdmin = perms?.canManageTechAdmin === true; + const nestedParentProps = canManageCommunities ? { parent: 'ROOT' as const } : {}; + + // Construct default page layouts ensuring a ROOT entry always exists so MenuComponent renders. + // If Communities is allowed, keep the historic behaviour: Communities is ROOT and others are its children. + // Otherwise, promote the first available section to ROOT so a finance-only user sees a single Finance item. + const defaultPageLayouts: PageLayoutProps[] = []; + + if (canManageCommunities) { + // Communities as canonical root, others as children + defaultPageLayouts.push({ path: '/staff/community-management', title: 'Communities', icon: , id: 'ROOT' }); + if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', ...nestedParentProps }); + if (canManageFinance) defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'finance', ...nestedParentProps }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', ...nestedParentProps }); + } else { + // No Communities root. Promote the first available section to ROOT to render a single top-level item. + if (canManageFinance) { + defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'ROOT' }); + // add others as children if present + if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', parent: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canManageUsers) { + defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'ROOT' }); + if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); + } else if (canManageTechAdmin) { + defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'ROOT' }); + } + } // Build a map from default entries, then overlay consumer entries so consumers can override defaults. // When consumers provide an entry with the same id, merge it with the default so that @@ -160,7 +181,7 @@ export const SectionLayout: React.FC = (props) => { marginLeft: 'auto', }} > - Staff User + {props.displayName || auth?.name || auth?.username || auth?.email || 'Staff User'}