('');
+
+ const getJsonType = (value: JsonValue): string => {
+ if (Array.isArray(value)) {
+ return 'array';
+ }
+ if (value === null) {
+ return 'null';
+ }
+ return typeof value;
+ };
+
+ const renderJsonValue = (value: JsonValue): string => {
+ if (typeof value === 'string') {
+ return `"${value}"`;
+ }
+ if (value === null) {
+ return 'null';
+ }
+ return String(value);
+ };
+
+ const renderJsonLines = (value: JsonValue, depth = 0): React.ReactNode[] => {
+ const indent = ' '.repeat(depth);
+ const childIndent = ' '.repeat(depth + 1);
+
+ if (Array.isArray(value)) {
+ const lines: React.ReactNode[] = [{indent}[
];
+ value.forEach((item: JsonValue, index: number) => {
+ if (item !== null && typeof item === 'object') {
+ lines.push(...renderJsonLines(item, depth + 1));
+ } else {
+ lines.push(
+
+ {childIndent}
+ {getJsonType(item)}{' '}
+ {renderJsonValue(item)}
+ {index < value.length - 1 ? ',' : ''}
+
,
+ );
+ }
+ });
+ lines.push({indent}]
);
+ return lines;
+ }
+
+ if (value !== null && typeof value === 'object') {
+ const entries = Object.entries(value);
+ const lines: React.ReactNode[] = [{indent}{'{'}
];
+ entries.forEach(([field, fieldValue]: [string, JsonValue], index: number) => {
+ const isLast = index === entries.length - 1;
+ if (fieldValue !== null && typeof fieldValue === 'object') {
+ lines.push(
+
+ {childIndent}
+ "{field}"
+ :{' '}
+ {getJsonType(fieldValue)}
+
,
+ );
+ const childLines = renderJsonLines(fieldValue, depth + 1);
+ if (isLast) {
+ lines.push(...childLines);
+ } else {
+ const lastLine = childLines[childLines.length - 1];
+ lines.push(...childLines.slice(0, -1));
+ lines.push(
+
+ {lastLine}
+ ,
+
,
+ );
+ }
+ return;
+ }
+
+ lines.push(
+
+ {childIndent}
+ "{field}"
+ :{' '}
+ {getJsonType(fieldValue)}{' '}
+ {renderJsonValue(fieldValue)}
+ {isLast ? '' : ','}
+
,
+ );
+ });
+ lines.push({indent}{'}'}
);
+ return lines;
+ }
+
+ return [
+
+ {indent}
+ {getJsonType(value)}{' '}
+ {renderJsonValue(value)}
+
,
+ ];
+ };
+
+ const renderSelectedJson = (): React.ReactNode => {
+ try {
+ const parsed = JSON.parse(selectedJson) as JsonValue;
+ return (
+
+ {renderJsonLines(parsed)}
+
+ );
+ } catch (_error) {
+ return (
+
+ {selectedJson}
+
+ );
+ }
+ };
+
+ const columns: TableColumnsType = [
+ { title: 'ID', dataIndex: 'id', key: 'id' },
+ {
+ title: 'Preview',
+ dataIndex: 'json',
+ key: 'preview',
+ render: (json: string) => {
+ try {
+ const obj = JSON.parse(json);
+ return {JSON.stringify(obj, null, 2)};
+ } catch (_e) {
+ return Invalid JSON;
+ }
+ },
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ render: (_value: unknown, record: DatabaseDocument) => (
+
+
+ }
+ onClick={() => {
+ try {
+ setSelectedJson(JSON.stringify(JSON.parse(record.json), null, 2));
+ } catch (_e) {
+ setSelectedJson(record.json);
+ }
+ setIsJsonModalOpen(true);
+ }}
+ size="small"
+ type="text"
+ />
+
+
+ }
+ onClick={() => {
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
+ void navigator.clipboard.writeText(record.json);
+ }
+ }}
+ size="small"
+ type="text"
+ />
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
Database Explorer
+
+
+
+
+ dataSource={documents}
+ columns={columns}
+ rowKey="id"
+ loading={!!loading}
+ pagination={{
+ current: page,
+ pageSize,
+ total: totalCount,
+ onChange: onChangePage,
+ showSizeChanger: true,
+ }}
+ />
+ }}
+ okType="primary"
+ onCancel={() => {
+ setIsJsonModalOpen(false);
+ setSelectedJson('');
+ }}
+ onOk={() => {
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
+ void navigator.clipboard.writeText(selectedJson);
+ }
+ }}
+ open={isJsonModalOpen}
+ style={{ top: 12 }}
+ title="Full JSON"
+ width="95vw"
+ >
+ {renderSelectedJson()}
+
+
+ );
+};
diff --git a/packages/ocom/ui-staff-route-tech-admin/src/index.tsx b/packages/ocom/ui-staff-route-tech-admin/src/index.tsx
index b427e099e..ca384b0ca 100644
--- a/packages/ocom/ui-staff-route-tech-admin/src/index.tsx
+++ b/packages/ocom/ui-staff-route-tech-admin/src/index.tsx
@@ -1,7 +1,7 @@
-import { PlaceholderPage } from '@ocom/ui-staff-shared';
import type React from 'react';
import { Route, Routes } from 'react-router-dom';
import { SectionLayout } from './section-layout.tsx';
+import { DatabaseExplorerPage } from './pages/database-explorer.tsx';
export const Root: React.FC = () => {
return (
@@ -11,24 +11,8 @@ export const Root: React.FC = () => {
element={}
>
- }
- />
-
- }
+ path="database-explorer"
+ element={}
/>
diff --git a/packages/ocom/ui-staff-route-tech-admin/src/pages/database-explorer.tsx b/packages/ocom/ui-staff-route-tech-admin/src/pages/database-explorer.tsx
new file mode 100644
index 000000000..4b4668407
--- /dev/null
+++ b/packages/ocom/ui-staff-route-tech-admin/src/pages/database-explorer.tsx
@@ -0,0 +1,19 @@
+import { RequireRole, StaffAppRoles, SubPageLayout } from '@ocom/ui-staff-shared';
+import type React from 'react';
+import { DatabaseExplorerContainer } from '../components/database-explorer.container.tsx';
+
+export const DatabaseExplorerPage: React.FC = () => {
+ return (
+
+ Database Explorer}
+ >
+
+
+
+ );
+};
diff --git a/packages/ocom/ui-staff-route-tech-admin/src/section-layout.tsx b/packages/ocom/ui-staff-route-tech-admin/src/section-layout.tsx
index 9d0cd47dc..680b4ee4a 100644
--- a/packages/ocom/ui-staff-route-tech-admin/src/section-layout.tsx
+++ b/packages/ocom/ui-staff-route-tech-admin/src/section-layout.tsx
@@ -1,13 +1,28 @@
-import { ToolOutlined } from '@ant-design/icons';
-import { type SectionLayoutProps, SectionLayout as SharedSectionLayout } from '@ocom/ui-staff-shared';
+import { DashboardOutlined, ToolOutlined } from '@ant-design/icons';
+import { type SectionLayoutProps, SectionLayout as SharedSectionLayout, type StaffAuth } from '@ocom/ui-staff-shared';
import type React from 'react';
export const SectionLayout: React.FC = () => {
+ const hasDatabaseExplorerAccess = (member: unknown): boolean => {
+ const auth = member as StaffAuth | undefined;
+ const perms = auth?.permissions;
+ return perms?.canManageTechAdmin === true || perms?.canViewDatabaseDocuments === true;
+ };
+
const pageLayouts: SectionLayoutProps['pageLayouts'] = [
{
- path: '/staff/tech',
+ path: '/staff/tech/*',
title: 'Tech Admin',
icon: ,
id: 'tech',
+ hideSelfLinkWhenHasChildren: true,
+ },
+ {
+ path: '/staff/tech/database-explorer',
+ title: 'Database',
+ icon: ,
+ id: 'database-explorer',
+ parent: 'tech',
+ hasPermissions: hasDatabaseExplorerAccess,
},
];
diff --git a/packages/ocom/ui-staff-route-tech-admin/vitest.config.ts b/packages/ocom/ui-staff-route-tech-admin/vitest.config.ts
index 17bec4371..9f92f5d21 100644
--- a/packages/ocom/ui-staff-route-tech-admin/vitest.config.ts
+++ b/packages/ocom/ui-staff-route-tech-admin/vitest.config.ts
@@ -7,6 +7,7 @@ export default mergeConfig(
test: {
environment: 'jsdom',
passWithNoTests: true,
+ setupFiles: ['./vitest.setup.ts'],
},
}),
);
diff --git a/packages/ocom/ui-staff-route-tech-admin/vitest.setup.ts b/packages/ocom/ui-staff-route-tech-admin/vitest.setup.ts
new file mode 100644
index 000000000..3a7c9e380
--- /dev/null
+++ b/packages/ocom/ui-staff-route-tech-admin/vitest.setup.ts
@@ -0,0 +1,50 @@
+const safeAssign = (name: string, value: unknown) => {
+ try {
+ (globalThis as Record)[name] = value;
+ } catch {
+ Object.defineProperty(globalThis, name, {
+ value,
+ writable: true,
+ configurable: true,
+ });
+ }
+};
+
+if (typeof globalThis !== 'undefined' && !globalThis.matchMedia) {
+ safeAssign(
+ 'matchMedia',
+ (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => undefined,
+ removeListener: () => undefined,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ dispatchEvent: () => false,
+ }),
+ );
+}
+
+if (typeof globalThis !== 'undefined' && !globalThis.getComputedStyle) {
+ safeAssign('getComputedStyle', () => ({
+ getPropertyValue: () => '',
+ }));
+}
+
+if (!('ResizeObserver' in globalThis)) {
+ safeAssign(
+ 'ResizeObserver',
+ class {
+ observe() {
+ /* noop */
+ }
+ unobserve() {
+ /* noop */
+ }
+ disconnect() {
+ /* noop */
+ }
+ },
+ );
+}
diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/main.ts b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts
index 055dca4e5..89a44ade1 100644
--- a/packages/ocom/ui-staff-route-user-management/.storybook/main.ts
+++ b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts
@@ -1,6 +1,9 @@
+import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import type { StorybookConfig } from '@storybook/react-vite';
+const require = createRequire(import.meta.url);
+
function getAbsolutePath(value: string) {
return dirname(require.resolve(join(value, 'package.json')));
}
diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx
index bddd68428..948ebff58 100644
--- a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx
+++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx
@@ -35,7 +35,10 @@ export const StaffRoleCreateContainer: React.FC = () => {
const auth = useContext(StaffAuthContext);
const availableEnterpriseAppRoles = getAllowedEnterpriseAppRoles(auth?.enterpriseAppRole);
const showTechAdminPermissions = auth?.permissions?.canManageTechAdmin === true;
- const canCreateRole = auth?.permissions?.canAddRole === true || auth?.permissions?.canManageStaffRolesAndPermissions === true || auth?.permissions?.canManageTechAdmin === true;
+ const canCreateRole =
+ auth?.permissions?.canAddRole === true ||
+ auth?.permissions?.canManageStaffRolesAndPermissions === true ||
+ auth?.permissions?.canManageTechAdmin === true;
const [staffRoleCreate, { loading }] = useMutation(StaffRoleCreateDocument, {
update: (cache, { data }) => {
@@ -102,7 +105,7 @@ export const StaffRoleCreateContainer: React.FC = () => {
? {
techAdminPermissions: {
canManageTechAdmin: values.canManageTechAdmin,
- canViewDatabaseExplorer: values.canViewDatabaseExplorer,
+ canViewDatabaseDocuments: values.canViewDatabaseDocuments,
canViewBlobExplorer: values.canViewBlobExplorer,
canViewQueueDashboard: values.canViewQueueDashboard,
canSendQueueMessages: values.canSendQueueMessages,
diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx
index f4613977b..0d3ee2274 100644
--- a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx
+++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx
@@ -24,7 +24,7 @@ export interface StaffRoleFormValues {
canViewFinanceConfigs: boolean;
canCreateFinanceConfigs: boolean;
canManageTechAdmin: boolean;
- canViewDatabaseExplorer: boolean;
+ canViewDatabaseDocuments: boolean;
canViewBlobExplorer: boolean;
canViewQueueDashboard: boolean;
canSendQueueMessages: boolean;
@@ -95,7 +95,7 @@ const PERMISSION_GROUPS: Array<{
topLevelKey: 'canManageTechAdmin',
fields: [
{ key: 'canManageTechAdmin', label: 'Can Manage Tech Admin' },
- { key: 'canViewDatabaseExplorer', label: 'Can View Database Explorer' },
+ { key: 'canViewDatabaseDocuments', label: 'Can View Database Documents' },
{ key: 'canViewBlobExplorer', label: 'Can View Blob Explorer' },
{ key: 'canViewQueueDashboard', label: 'Can View Queue Dashboard' },
{ key: 'canSendQueueMessages', label: 'Can Send Queue Messages' },
@@ -137,7 +137,7 @@ const DEFAULT_VALUES: StaffRoleFormValues = {
canViewFinanceConfigs: false,
canCreateFinanceConfigs: false,
canManageTechAdmin: false,
- canViewDatabaseExplorer: false,
+ canViewDatabaseDocuments: false,
canViewBlobExplorer: false,
canViewQueueDashboard: false,
canSendQueueMessages: false,
diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.graphql b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.graphql
index fc19d6ae8..301eb80a4 100644
--- a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.graphql
+++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.graphql
@@ -48,7 +48,7 @@ fragment StaffRoleEditFields on StaffRole {
}
techAdminPermissions {
canManageTechAdmin
- canViewDatabaseExplorer
+ canViewDatabaseDocuments
canViewBlobExplorer
canViewQueueDashboard
canSendQueueMessages
diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.tsx
index 99d73c4aa..49d0bff9f 100644
--- a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.tsx
+++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.container.tsx
@@ -36,7 +36,10 @@ export const StaffRoleEditContainer: React.FC = () => {
const auth = useContext(StaffAuthContext);
const availableEnterpriseAppRoles = getAllowedEnterpriseAppRoles(auth?.enterpriseAppRole);
const showTechAdminPermissions = auth?.permissions?.canManageTechAdmin === true;
- const canEditRole = auth?.permissions?.canEditRole === true || auth?.permissions?.canManageStaffRolesAndPermissions === true || auth?.permissions?.canManageTechAdmin === true;
+ const canEditRole =
+ auth?.permissions?.canEditRole === true ||
+ auth?.permissions?.canManageStaffRolesAndPermissions === true ||
+ auth?.permissions?.canManageTechAdmin === true;
const { data, loading: queryLoading } = useQuery(StaffRoleByIdDocument, {
variables: { id: id ?? '' },
@@ -95,7 +98,7 @@ export const StaffRoleEditContainer: React.FC = () => {
? {
techAdminPermissions: {
canManageTechAdmin: values.canManageTechAdmin,
- canViewDatabaseExplorer: values.canViewDatabaseExplorer,
+ canViewDatabaseDocuments: values.canViewDatabaseDocuments,
canViewBlobExplorer: values.canViewBlobExplorer,
canViewQueueDashboard: values.canViewQueueDashboard,
canSendQueueMessages: values.canSendQueueMessages,
@@ -152,7 +155,7 @@ export const StaffRoleEditContainer: React.FC = () => {
canViewFinanceConfigs: role.permissions.financePermissions.canViewFinanceConfigs,
canCreateFinanceConfigs: role.permissions.financePermissions.canCreateFinanceConfigs,
canManageTechAdmin: role.permissions.techAdminPermissions.canManageTechAdmin,
- canViewDatabaseExplorer: role.permissions.techAdminPermissions.canViewDatabaseExplorer,
+ canViewDatabaseDocuments: role.permissions.techAdminPermissions.canViewDatabaseDocuments,
canViewBlobExplorer: role.permissions.techAdminPermissions.canViewBlobExplorer,
canViewQueueDashboard: role.permissions.techAdminPermissions.canViewQueueDashboard,
canSendQueueMessages: role.permissions.techAdminPermissions.canSendQueueMessages,
diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.stories.tsx
index a392b5c63..1ba67f19c 100644
--- a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.stories.tsx
+++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-edit.stories.tsx
@@ -24,7 +24,7 @@ const existingRoleValues = {
canViewFinanceConfigs: false,
canCreateFinanceConfigs: false,
canManageTechAdmin: false,
- canViewDatabaseExplorer: false,
+ canViewDatabaseDocuments: false,
canViewBlobExplorer: false,
canViewQueueDashboard: false,
canSendQueueMessages: false,
@@ -78,7 +78,7 @@ export const CaseManagerEditView: Story = {
canViewFinanceConfigs: false,
canCreateFinanceConfigs: false,
canManageTechAdmin: false,
- canViewDatabaseExplorer: false,
+ canViewDatabaseDocuments: false,
canViewBlobExplorer: false,
canViewQueueDashboard: false,
canSendQueueMessages: false,
diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-roles-list.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-roles-list.tsx
index 505847beb..cfc9019ab 100644
--- a/packages/ocom/ui-staff-route-user-management/src/components/staff-roles-list.tsx
+++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-roles-list.tsx
@@ -41,7 +41,7 @@ export const StaffRolesList: React.FC = ({ data, onEdit, on
{
title: 'Action',
key: 'action',
- render: (_: unknown, record: StaffRole) =>
+ render: (_: unknown, record: StaffRole) => (
canEdit ? (
- ) : null,
+ ) : null
+ ),
},
];
diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-user-detail.container.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-user-detail.container.tsx
index 8c3302f98..6f0294b16 100644
--- a/packages/ocom/ui-staff-route-user-management/src/components/staff-user-detail.container.tsx
+++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-user-detail.container.tsx
@@ -4,7 +4,7 @@ import { App } from 'antd';
import type React from 'react';
import { useContext, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
-import { CurrentStaffUserDocument, StaffRolesForSelectDocument, StaffUserAssignRoleDocument, StaffUserDetailDocument, StaffUsersListDocument } from '../generated.tsx';
+import { StaffRolesForSelectDocument, StaffUserAssignRoleDocument, StaffUserDetailDocument, StaffUsersListDocument, CurrentStaffUserDocument } from '../generated.tsx';
import { StaffUserDetail } from './staff-user-detail.tsx';
const EnterpriseAppRoleNames = {
@@ -34,7 +34,10 @@ export const StaffUserDetailContainer: React.FC = () => {
const userId = params.id ?? '';
const auth = useContext(StaffAuthContext);
const canAssignRoles =
- auth?.permissions?.canAssignStaffRoles === true || auth?.permissions?.canManageUsers === true || auth?.permissions?.canManageStaffRolesAndPermissions === true || auth?.permissions?.canManageTechAdmin === true;
+ auth?.permissions?.canAssignStaffRoles === true ||
+ auth?.permissions?.canManageUsers === true ||
+ auth?.permissions?.canManageStaffRolesAndPermissions === true ||
+ auth?.permissions?.canManageTechAdmin === true;
const { message } = App.useApp();
const { data: userData, loading: userLoading } = useQuery(StaffUserDetailDocument, {
@@ -57,7 +60,10 @@ export const StaffUserDetailContainer: React.FC = () => {
const [selectedRoleId, setSelectedRoleId] = useState(null);
const [assignRole, { loading: assignLoading }] = useMutation(StaffUserAssignRoleDocument, {
- refetchQueries: [{ query: StaffUserDetailDocument, variables: { id: userId } }, { query: StaffUsersListDocument }],
+ refetchQueries: [
+ { query: StaffUserDetailDocument, variables: { id: userId } },
+ { query: StaffUsersListDocument },
+ ],
});
useEffect(() => {
@@ -100,12 +106,12 @@ export const StaffUserDetailContainer: React.FC = () => {
if (targetUserEnterpriseAppRole && r.enterpriseAppRole !== targetUserEnterpriseAppRole) return false;
return true;
});
-
- // Always include the user's current role in the options so the Select can render its label
- // (the current role may be outside the viewer's assignable set but must still display correctly)
- const currentUserRole = user?.role ? { id: String(user.role.id), roleName: user.role.roleName } : null;
- const mappedFilteredRoles = filteredRoles.map((r) => ({ id: String(r.id), roleName: r.roleName }));
- const availableRolesWithCurrent = currentUserRole && !mappedFilteredRoles.some((r) => r.id === currentUserRole.id) ? [currentUserRole, ...mappedFilteredRoles] : mappedFilteredRoles;
+ const availableRoles = filteredRoles.map((r) => ({ id: String(r.id), roleName: r.roleName }));
+ const currentRole = user?.role ? { id: String(user.role.id), roleName: user.role.roleName } : null;
+ const availableRolesWithCurrent =
+ currentRole && !availableRoles.some((r) => r.id === currentRole.id)
+ ? [currentRole, ...availableRoles]
+ : availableRoles;
return (
= ({ data, onEdit, ca
{
title: 'Action',
key: 'action',
- render: (_: unknown, record: StaffUser) =>
+ render: (_: unknown, record: StaffUser) => (
canEdit ? (
- ) : null,
+ ) : null
+ ),
},
];
diff --git a/packages/ocom/ui-staff-shared/package.json b/packages/ocom/ui-staff-shared/package.json
index 0a07ee14a..0d52c1aa7 100644
--- a/packages/ocom/ui-staff-shared/package.json
+++ b/packages/ocom/ui-staff-shared/package.json
@@ -33,6 +33,7 @@
"@storybook/react": "catalog:",
"storybook": "catalog:",
"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 fb8278245..d28d20e4c 100644
--- a/packages/ocom/ui-staff-shared/src/index.tsx
+++ b/packages/ocom/ui-staff-shared/src/index.tsx
@@ -3,8 +3,8 @@ import { SectionLayout } from './section-layout.tsx';
export { VerticalTabs } from '@ocom/ui-shared';
export { RequireRole, type RequireRoleProps } from './require-role.tsx';
-export { SectionLayoutContainer } from './section-layout.container.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';
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..1353ab8d1
--- /dev/null
+++ b/packages/ocom/ui-staff-shared/src/require-role.test.tsx
@@ -0,0 +1,132 @@
+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, canManageStaffRolesAndPermissions: false },
+ userPermissions: { canManageUsers: false, canAssignStaffRoles: false, canViewStaffUsers: false },
+ staffRolePermissions: { canViewRoles: false, canAddRole: false, canEditRole: false, canRemoveRole: 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, canManageStaffRolesAndPermissions: false },
+ userPermissions: { canManageUsers: false, canAssignStaffRoles: false, canViewStaffUsers: false },
+ staffRolePermissions: { canViewRoles: false, canAddRole: false, canEditRole: false, canRemoveRole: 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
index ae363ad57..9665d2701 100644
--- a/packages/ocom/ui-staff-shared/src/require-role.tsx
+++ b/packages/ocom/ui-staff-shared/src/require-role.tsx
@@ -36,6 +36,7 @@ const STAFF_USER_CURRENT_QUERY = gql`
}
techAdminPermissions {
canManageTechAdmin
+ canViewDatabaseDocuments
}
}
}
@@ -51,7 +52,7 @@ interface StaffUserCurrentQueryResult {
userPermissions: { canManageUsers: boolean; canAssignStaffRoles: boolean; canViewStaffUsers: boolean };
staffRolePermissions: { canViewRoles: boolean; canAddRole: boolean; canEditRole: boolean; canRemoveRole: boolean };
financePermissions: { canManageFinance: boolean };
- techAdminPermissions: { canManageTechAdmin: boolean };
+ techAdminPermissions: { canManageTechAdmin: boolean; canViewDatabaseDocuments: boolean };
};
};
};
@@ -68,6 +69,7 @@ export const RequireRole: FC = ({ roles, permKey, children })
}
const rolePermissions = data?.staffUserCurrent?.role?.permissions;
+ const canManageTechAdmin = rolePermissions?.techAdminPermissions.canManageTechAdmin ?? false;
const permissions: NonNullable | undefined = rolePermissions
? {
canManageCommunities: rolePermissions.communityPermissions.canManageCommunities,
@@ -76,7 +78,8 @@ export const RequireRole: FC = ({ roles, permKey, children })
canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles,
canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers,
canManageFinance: rolePermissions.financePermissions.canManageFinance,
- canManageTechAdmin: rolePermissions.techAdminPermissions.canManageTechAdmin,
+ canManageTechAdmin,
+ canViewDatabaseDocuments: rolePermissions.techAdminPermissions.canViewDatabaseDocuments || canManageTechAdmin,
canViewRoles: rolePermissions.staffRolePermissions.canViewRoles,
canAddRole: rolePermissions.staffRolePermissions.canAddRole,
canEditRole: rolePermissions.staffRolePermissions.canEditRole,
diff --git a/packages/ocom/ui-staff-shared/src/section-layout.container.tsx b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx
index 5e3993744..48c88f7a3 100644
--- a/packages/ocom/ui-staff-shared/src/section-layout.container.tsx
+++ b/packages/ocom/ui-staff-shared/src/section-layout.container.tsx
@@ -47,4 +47,4 @@ export const SectionLayoutContainer: React.FC = (pr
error={staffUserError}
/>
);
-};
+};
\ No newline at end of file
diff --git a/packages/ocom/ui-staff-shared/src/section-layout.tsx b/packages/ocom/ui-staff-shared/src/section-layout.tsx
index 93dd098d2..1446860d9 100644
--- a/packages/ocom/ui-staff-shared/src/section-layout.tsx
+++ b/packages/ocom/ui-staff-shared/src/section-layout.tsx
@@ -80,12 +80,15 @@ export const SectionLayout: React.FC = (props) => {
const canViewStaffUsers = perms?.canViewStaffUsers === true;
const canManageFinance = perms?.canManageFinance === true;
const canManageTechAdmin = perms?.canManageTechAdmin === true;
+ const canViewDatabaseDocuments = perms?.canViewDatabaseDocuments === true;
+ const canAccessTechAdmin = canManageTechAdmin || canViewDatabaseDocuments;
const canViewRoles = perms?.canViewRoles === true;
const canAddRole = perms?.canAddRole === true;
const canEditRole = perms?.canEditRole === true;
const canRemoveRole = perms?.canRemoveRole === true;
const nestedParentProps = canManageCommunities ? { parent: 'ROOT' as const } : {};
- const canAccessUserManagement = canManageUsers || canAssignStaffRoles || canViewStaffUsers || canManageStaffRolesAndPermissions || canViewRoles || canAddRole || canEditRole || canRemoveRole || canManageTechAdmin;
+ const canAccessUserManagement =
+ canManageUsers || canAssignStaffRoles || canViewStaffUsers || canManageStaffRolesAndPermissions || canViewRoles || canAddRole || canEditRole || canRemoveRole || canManageTechAdmin;
// 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.
@@ -97,18 +100,18 @@ export const SectionLayout: React.FC = (props) => {
defaultPageLayouts.push({ path: '/staff/community-management', title: 'Communities', icon: , id: 'ROOT' });
if (canAccessUserManagement) 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 });
+ if (canAccessTechAdmin) 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 (canAccessUserManagement) 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' });
+ if (canAccessTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' });
} else if (canAccessUserManagement) {
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) {
+ if (canAccessTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' });
+ } else if (canAccessTechAdmin) {
defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'ROOT' });
}
}
diff --git a/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx b/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx
index 5c3130919..3a222d07d 100644
--- a/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx
+++ b/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx
@@ -22,6 +22,7 @@ export type StaffAuth = {
canViewStaffUsers?: boolean;
canManageFinance?: boolean;
canManageTechAdmin?: boolean;
+ canViewDatabaseDocuments?: boolean;
canViewRoles?: boolean;
canAddRole?: boolean;
canEditRole?: boolean;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 553643687..c43fe8074 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -168,7 +168,7 @@ overrides:
'@opentelemetry/exporter-prometheus@0.57.2': 0.217.0
'@opentelemetry/core@2.7.1': 2.8.0
ws: 8.21.0
- shell-quote: 1.8.4
+ shell-quote: 1.9.0
'@grpc/grpc-js': '>=1.14.4'
form-data: ^4.0.6
webpack-dev-server>http-proxy-middleware: 3.0.7
@@ -1509,6 +1509,9 @@ importers:
'@ocom/service-queue-storage':
specifier: workspace:*
version: link:../service-queue-storage
+ mongoose:
+ specifier: 'catalog:'
+ version: 8.17.0(supports-color@8.1.1)
devDependencies:
'@cellix/archunit-tests':
specifier: workspace:*
@@ -1691,6 +1694,9 @@ importers:
graphql:
specifier: 'catalog:'
version: 16.12.0
+ mongoose:
+ specifier: 'catalog:'
+ version: 8.17.0(supports-color@8.1.1)
devDependencies:
'@cellix/archunit-tests':
specifier: workspace:*
@@ -2606,9 +2612,18 @@ importers:
'@ant-design/icons':
specifier: 'catalog:'
version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@apollo/client':
+ specifier: ^3.13.9
+ version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@cellix/ui-core':
+ specifier: workspace:*
+ version: link:../../cellix/ui-core
'@ocom/ui-staff-shared':
specifier: workspace:*
version: link:../ui-staff-shared
+ antd:
+ specifier: 'catalog:'
+ version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react:
specifier: 'catalog:'
version: 19.2.0
@@ -2625,6 +2640,9 @@ importers:
'@cellix/config-vitest':
specifier: workspace:*
version: link:../../cellix/config-vitest
+ '@testing-library/react':
+ specifier: ^16.3.0
+ version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@types/react':
specifier: ^19.1.11
version: 19.2.7
@@ -12651,8 +12669,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
- shell-quote@1.8.4:
- resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
+ shell-quote@1.9.0:
+ resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
engines: {node: '>= 0.4'}
shimmer@1.2.1:
@@ -17059,7 +17077,7 @@ snapshots:
listr2: 4.0.5
log-symbols: 4.1.0
micromatch: 4.0.8
- shell-quote: 1.8.4
+ shell-quote: 1.9.0
string-env-interpolation: 1.0.1
ts-log: 2.2.7
tslib: 2.8.1
@@ -21063,7 +21081,7 @@ snapshots:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
- shell-quote: 1.8.4
+ shell-quote: 1.9.0
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
@@ -23353,7 +23371,7 @@ snapshots:
launch-editor@2.14.1:
dependencies:
picocolors: 1.1.1
- shell-quote: 1.8.4
+ shell-quote: 1.9.0
less@4.4.2:
dependencies:
@@ -26344,7 +26362,7 @@ snapshots:
shebang-regex@3.0.0: {}
- shell-quote@1.8.4: {}
+ shell-quote@1.9.0: {}
shimmer@1.2.1: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 168859512..b48bb6c08 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -119,7 +119,7 @@ overrides:
'@opentelemetry/exporter-prometheus@0.57.2': 0.217.0
'@opentelemetry/core@2.7.1': 2.8.0
ws: 8.21.0
- shell-quote: 1.8.4
+ shell-quote: 1.9.0
'@grpc/grpc-js': '>=1.14.4'
form-data: ^4.0.6
'webpack-dev-server>http-proxy-middleware': 3.0.7
diff --git a/sonar-project.properties b/sonar-project.properties
index d02ad968a..5f9a00e3a 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -77,7 +77,16 @@ packages/ocom/service-mongoose/src,\
packages/ocom/service-otel/src,\
packages/ocom/service-token-validation/src,\
packages/ocom/ui-staff-shared/src,\
-packages/ocom/ui-shared/src
+packages/ocom/ui-shared/src,\
+packages/ocom/ui-community-route-accounts/src,\
+packages/ocom/ui-community-route-admin/src,\
+packages/ocom/ui-community-route-root/src,\
+packages/ocom/ui-community-shared/src,\
+packages/ocom/ui-staff-route-community-management/src,\
+packages/ocom/ui-staff-route-finance/src,\
+packages/ocom/ui-staff-route-root/src,\
+packages/ocom/ui-staff-route-tech-admin/src,\
+packages/ocom/ui-staff-route-user-management/src
# Test file patterns
sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.stories.tsx
@@ -86,7 +95,7 @@ sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*
sonar.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/**
# Coverage exclusions
-sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,build-pipeline/scripts/**,scripts/local-dev/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/**,packages/ocom/data-sources-mongoose-models/**,packages/ocom/graphql/src/schema/builder/schema-builder.ts,apps/api/src/index.ts,apps/api/src/service-config/**,packages/cellix/archunit-tests/**,packages/ocom-verification/archunit-tests/**,packages/cellix/ui-core/**,apps/ui-community/**,packages/ocom/ui-shared/src/components/organisms/header/index.tsx
+sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,build-pipeline/scripts/**,scripts/local-dev/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/**,packages/ocom/data-sources-mongoose-models/**,packages/ocom/graphql/src/schema/builder/schema-builder.ts,apps/api/src/index.ts,apps/api/src/service-config/**,packages/cellix/archunit-tests/**,packages/ocom-verification/archunit-tests/**,packages/cellix/ui-core/**,apps/ui-community/**,packages/ocom/ui-shared/src/components/organisms/header/index.tsx,packages/ocom/ui-*/**
# CPD (code duplication) exclusions
sonar.cpd.exclusions=**/*.test.ts,**/*.generated.ts,**/*.generated.tsx,packages/cellix/archunit-tests/src/test-suites/**,packages/cellix/archunit-tests/src/fixtures/**