diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index ade0b4f42..f5518cba9 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "test@example.com", "sub": "00000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "test@example.com", "given_name": "Test", @@ -15,7 +15,7 @@ "username": "john.doe@example.com", "sub": "00000000-0000-4000-8000-000000000002", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "john.doe@example.com", "given_name": "John", @@ -27,7 +27,7 @@ "username": "owner@test.example", "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee01", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "owner@test.example", "given_name": "Test", diff --git a/apps/ui-staff/mock-oidc.users.json b/apps/ui-staff/mock-oidc.users.json index 6b635dfb4..1c753ea7c 100644 --- a/apps/ui-staff/mock-oidc.users.json +++ b/apps/ui-staff/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "staff@ownercommunity.onmicrosoft.com", "sub": "10000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "staff-user", + "oidcConfigName": "staff-user", "claims": { "email": "staff@ownercommunity.onmicrosoft.com", "given_name": "Staff", diff --git a/apps/ui-staff/src/App.tsx b/apps/ui-staff/src/App.tsx index 0541f62e4..7a64848d5 100644 --- a/apps/ui-staff/src/App.tsx +++ b/apps/ui-staff/src/App.tsx @@ -27,6 +27,8 @@ function StaffRoutes() { 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; @@ -34,7 +36,7 @@ function StaffRoutes() { const canAccessUserManagement = canManageUsers || canAssignStaffRoles || canViewStaffUsers || canManageStaffRolesAndPermissions || canViewRoles || canAddRole || canEditRole || canRemoveRole || canManageTechAdmin; let defaultStaffRoute = '/unauthorized'; - if (canManageTechAdmin) { + if (canAccessTechAdmin) { defaultStaffRoute = '/staff/tech'; } else if (canManageFinance) { defaultStaffRoute = '/staff/finance'; @@ -73,7 +75,7 @@ function StaffRoutes() { element={} /> )} - {canManageTechAdmin && ( + {canAccessTechAdmin && ( } diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.ts b/apps/ui-staff/src/hooks/use-staff-permissions.ts index 3d42e9ed0..5d19ba144 100644 --- a/apps/ui-staff/src/hooks/use-staff-permissions.ts +++ b/apps/ui-staff/src/hooks/use-staff-permissions.ts @@ -34,6 +34,7 @@ const CURRENT_STAFF_USER_QUERY = gql` } techAdminPermissions { canManageTechAdmin + canViewDatabaseDocuments } } } @@ -49,6 +50,7 @@ interface StaffPermissions { canViewStaffUsers: boolean; canManageFinance: boolean; canManageTechAdmin: boolean; + canViewDatabaseDocuments: boolean; canViewRoles: boolean; canAddRole: boolean; canEditRole: boolean; @@ -72,7 +74,7 @@ interface StaffUserQueryResult { 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 }; }; }; }; @@ -104,6 +106,7 @@ export const useStaffPermissions = (): { canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin, canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, canManageTechAdmin: isTechAdmin, + canViewDatabaseDocuments: rolePermissions.techAdminPermissions.canViewDatabaseDocuments || isTechAdmin, canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts index 04772575f..f6fdee1ca 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts @@ -126,9 +126,7 @@ describe('file user store oidcConfigName filtering', () => { it('users without oidcConfigName are visible to all scoped stores', async () => { if (!tmp) throw new Error('tmp not created'); - writeUsers(tmp, 'mock-oidc.users.json', [ - { username: 'shared@example.com', sub: 'sub-shared' }, - ]); + writeUsers(tmp, 'mock-oidc.users.json', [{ username: 'shared@example.com', sub: 'sub-shared' }]); const endStore = createFileUserStore(tmp, 'end-user'); const staffStore = createFileUserStore(tmp, 'staff-user'); @@ -191,15 +189,7 @@ describe('file user store oidcConfigName filtering', () => { expect(users).toHaveLength(1); expect(users[0]?.username).toBe('good@example.com'); expect(warnSpy.length).toBeGreaterThan(0); - expect( - warnSpy.some((args) => - (args as unknown[]).some( - (arg) => - typeof arg === 'string' && - arg.includes('"oidcConfigName" must be a string'), - ), - ), - ).toBe(true); + expect(warnSpy.some((args) => (args as unknown[]).some((arg) => typeof arg === 'string' && arg.includes('"oidcConfigName" must be a string')))).toBe(true); } finally { console.warn = origWarn; } diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts index d364915c8..565ece8ac 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts @@ -569,11 +569,7 @@ describe('discoverPortalConfigs', () => { envVars: { clientId: 'VITE_APP_PORTAL_STAFF_CLIENTID', redirectUri: 'VITE_APP_PORTAL_STAFF_REDIRECT' }, }, ]); - writeEnv( - tmp, - 'ui-portal/.env', - 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n', - ); + writeEnv(tmp, 'ui-portal/.env', 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n'); const portals = discoverPortalConfigs(tmp); expect(portals).toHaveLength(2); diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts index cba25d6cc..55194db74 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -5,4 +5,4 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; -import '../contexts/staff/step-definitions/index.ts'; +import '../contexts/staff/step-definitions/index.ts'; \ No newline at end of file diff --git a/packages/ocom/application-services/package.json b/packages/ocom/application-services/package.json index 55321704f..99fc2fbec 100644 --- a/packages/ocom/application-services/package.json +++ b/packages/ocom/application-services/package.json @@ -29,7 +29,8 @@ "@ocom/domain": "workspace:*", "@ocom/persistence": "workspace:*", "@ocom/service-blob-storage": "workspace:*", - "@ocom/service-queue-storage": "workspace:*" + "@ocom/service-queue-storage": "workspace:*", + "mongoose": "catalog:" }, "devDependencies": { "@cellix/archunit-tests": "workspace:*", diff --git a/packages/ocom/application-services/src/contexts/tech-admin/database-documents.command-mapper.ts b/packages/ocom/application-services/src/contexts/tech-admin/database-documents.command-mapper.ts new file mode 100644 index 000000000..cf9256cf4 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/tech-admin/database-documents.command-mapper.ts @@ -0,0 +1,61 @@ +type DatabaseDocumentsQueryArgs = { + collection: string; + filter?: string | null | undefined; + page?: number | null | undefined; + pageSize?: number | null | undefined; +}; + +const ALLOWED_OPERATORS = new Set(['$eq', '$in', '$gt', '$gte', '$lt', '$lte', '$exists', '$regex', '$and', '$or', '$not']); + +function validateOperatorKey(key: string): void { + if (!key.startsWith('$')) return; + if (key === '$where' || key === '$function' || key === '$expr') { + throw new Error(`Operator ${key} is not allowed in filter`); + } + if (!ALLOWED_OPERATORS.has(key)) { + throw new Error(`Unknown operator: ${key}`); + } +} + +function validateFilterOperators(obj: unknown): void { + if (obj === null || obj === undefined) return; + if (Array.isArray(obj)) { + for (const item of obj) validateFilterOperators(item); + return; + } + if (typeof obj !== 'object') return; + for (const [k, v] of Object.entries(obj as Record)) { + validateOperatorKey(k); + validateFilterOperators(v); + } +} + +export type DatabaseDocumentsQueryCommand = { + collection: string; + filter: Record; + page: number; + pageSize: number; +}; + +export function buildDatabaseDocumentsQueryCommand(args: DatabaseDocumentsQueryArgs): DatabaseDocumentsQueryCommand { + if (!/^[a-zA-Z0-9_-]+$/.test(args.collection)) { + throw new Error('Invalid collection name'); + } + + let parsedFilter: Record = {}; + if (args.filter) { + try { + parsedFilter = JSON.parse(args.filter) as Record; + } catch { + throw new Error('Invalid filter JSON'); + } + validateFilterOperators(parsedFilter); + } + + return { + collection: args.collection, + filter: parsedFilter, + pageSize: Math.min(Math.max(args.pageSize ?? 20, 1), 100), + page: Math.max(args.page ?? 1, 1), + }; +} diff --git a/packages/ocom/application-services/src/contexts/tech-admin/index.ts b/packages/ocom/application-services/src/contexts/tech-admin/index.ts new file mode 100644 index 000000000..88d26129e --- /dev/null +++ b/packages/ocom/application-services/src/contexts/tech-admin/index.ts @@ -0,0 +1,15 @@ +import { ListCollections } from './list-collections.ts'; +import { DatabaseDocuments } from './query-documents.ts'; +export { buildDatabaseDocumentsQueryCommand } from './database-documents.command-mapper.ts'; + +export interface TechAdminApplicationService { + ListCollections: ReturnType; + DatabaseDocuments: ReturnType; +} + +export const TechAdmin = (): TechAdminApplicationService => { + return { + ListCollections: ListCollections(), + DatabaseDocuments: DatabaseDocuments(), + }; +}; diff --git a/packages/ocom/application-services/src/contexts/tech-admin/list-collections.ts b/packages/ocom/application-services/src/contexts/tech-admin/list-collections.ts new file mode 100644 index 000000000..4b2226f55 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/tech-admin/list-collections.ts @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; + +export const ListCollections = () => { + return async (): Promise => { + const { db } = mongoose.connection; + if (!db) throw new Error('Database connection is not available'); + const cols = await db.listCollections().toArray(); + return cols + .map((c) => c.name) + .filter((n) => !n.startsWith('system.')) + .sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })); + }; +}; diff --git a/packages/ocom/application-services/src/contexts/tech-admin/query-documents.ts b/packages/ocom/application-services/src/contexts/tech-admin/query-documents.ts new file mode 100644 index 000000000..bf8f83b0c --- /dev/null +++ b/packages/ocom/application-services/src/contexts/tech-admin/query-documents.ts @@ -0,0 +1,126 @@ +import mongoose from 'mongoose'; + +function normalizeBsonValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(normalizeBsonValue); + if (value instanceof Date) return value.toISOString(); + if (value && typeof (value as { toHexString?: unknown }).toHexString === 'function') return (value as { toHexString: () => string }).toHexString(); + if (value && (value as { _bsontype?: unknown })._bsontype === 'Decimal128') return (value as { toString: () => string }).toString(); + if (Buffer.isBuffer(value)) return value.toString('base64'); + if (value instanceof Uint8Array) return Buffer.from(value).toString('base64'); + if (typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = normalizeBsonValue(v); + } + return out; + } + return value; +} + +const OBJECT_ID_KEYS = new Set(['_id', 'role']); +const objectIdPattern = /^[a-fA-F0-9]{24}$/; + +function normalizeObjectIdValue(value: unknown): unknown { + if (typeof value === 'string' && objectIdPattern.test(value)) { + return new mongoose.Types.ObjectId(value); + } + if (Array.isArray(value)) { + return value.map((item) => normalizeObjectIdValue(item)); + } + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = normalizeObjectIdValue(v); + } + return out; + } + return value; +} + +function normalizeFilterObjectIds(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (Array.isArray(obj)) { + return obj.map((item) => normalizeFilterObjectIds(item)); + } + if (typeof obj !== 'object') return obj; + const out: Record = {}; + for (const [k, v] of Object.entries(obj as Record)) { + if (k.startsWith('$')) { + out[k] = normalizeFilterObjectIds(v); + continue; + } + if (OBJECT_ID_KEYS.has(k)) { + out[k] = normalizeObjectIdValue(v); + continue; + } + out[k] = normalizeFilterObjectIds(v); + } + return out; +} + +async function enrichStaffUserRoles(collection: string, docs: Record[], db: mongoose.mongo.Db): Promise[]> { + if (collection !== 'users') return docs; + const roleIds = new Set(); + for (const doc of docs) { + //biome-ignore lint:useLiteralKeys + const role = doc['role']; + if (typeof role === 'string' && role.length === 24) roleIds.add(role); + if (role && typeof role === 'object' && typeof (role as { id?: unknown }).id === 'string') roleIds.add((role as { id: string }).id); + } + if (roleIds.size === 0) return docs; + const roleObjectIds = [...roleIds].map((id) => new mongoose.Types.ObjectId(id)); + const roles = await db.collection('roles').find({ _id: { $in: roleObjectIds } }, { projection: { roleName: 1, enterpriseAppRole: 1 } }).toArray(); + const roleMap = new Map( + roles.map((role) => [ + String(role._id), + { + //biome-ignore lint:useLiteralKeys + roleName: role['roleName'] ?? null, + //biome-ignore lint:useLiteralKeys + enterpriseAppRole: role['enterpriseAppRole'] ?? null, + }, + ]), + ); + return docs.map((doc) => { + //biome-ignore lint:useLiteralKeys + const roleId = typeof doc['role'] === 'string' ? doc['role'] : (doc['role'] as { id?: string } | undefined)?.id ?? null; + if (!roleId) return doc; + const roleInfo = roleMap.get(roleId); + if (!roleInfo) return doc; + return { ...doc, role: { id: roleId, ...roleInfo } }; + }); +} + +import type { DatabaseDocumentsQueryCommand } from './database-documents.command-mapper.ts'; + +type DatabaseDocument = { + id: string; + json: string; +}; + +type DatabaseDocumentPage = { + documents: DatabaseDocument[]; + totalCount: number; +}; + +export const DatabaseDocuments = () => { + return async (command: DatabaseDocumentsQueryCommand): Promise => { + const { db } = mongoose.connection; + if (!db) throw new Error('Database connection is not available'); + const skip = (command.page - 1) * command.pageSize; + const coll = db.collection(command.collection); + const filter = normalizeFilterObjectIds(command.filter) as Record; + const totalCount = await coll.countDocuments(filter); + const docs = await coll.find(filter).skip(skip).limit(command.pageSize).toArray(); + const enrichedDocs = await enrichStaffUserRoles(command.collection, docs as Record[], db); + return { + totalCount, + documents: enrichedDocs.map((doc) => { + const sanitized = normalizeBsonValue(doc as Record); + //biome-ignore lint:useLiteralKeys + return { id: String(doc['_id']), json: JSON.stringify(sanitized) }; + }), + }; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/index.ts b/packages/ocom/application-services/src/contexts/user/index.ts index 6841b047b..8f1618fd8 100644 --- a/packages/ocom/application-services/src/contexts/user/index.ts +++ b/packages/ocom/application-services/src/contexts/user/index.ts @@ -2,11 +2,13 @@ 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'; +import { TechAdmin as TechAdminApi, type TechAdminApplicationService } from '../tech-admin/index.ts'; export interface UserContextApplicationService { EndUser: EndUserApplicationService; StaffRole: StaffRoleApplicationService; StaffUser: StaffUserApplicationService; + TechAdmin: TechAdminApplicationService; } export const User = (dataSources: DataSources): UserContextApplicationService => { @@ -14,5 +16,6 @@ export const User = (dataSources: DataSources): UserContextApplicationService => EndUser: EndUserApi(dataSources), StaffRole: StaffRoleApi(dataSources), StaffUser: StaffUserApi(dataSources), + TechAdmin: TechAdminApi(), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/apply-permissions.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/apply-permissions.test.ts new file mode 100644 index 000000000..56909a0a8 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/apply-permissions.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest'; +import { applyCommunityPermissions, applyFinancePermissions, applyRolePermissions, applyTechAdminPermissions, applyUserPermissions } from './apply-permissions.ts'; + +function createMockStaffRole() { + return { + permissions: { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }, + staffRolePermissions: { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + canViewDatabaseDocuments: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + }, + // biome-ignore lint/suspicious/noExplicitAny: + } as any; +} + +describe('applyCommunityPermissions', () => { + it('updates all supplied community permissions', () => { + const role = createMockStaffRole(); + + applyCommunityPermissions(role, { + canManageCommunities: true, + canManageStaffRolesAndPermissions: true, + canManageAllCommunities: true, + canDeleteCommunities: true, + canChangeCommunityOwner: true, + canReIndexSearchCollections: true, + }); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + expect(role.permissions.communityPermissions.canManageAllCommunities).toBe(true); + expect(role.permissions.communityPermissions.canDeleteCommunities).toBe(true); + expect(role.permissions.communityPermissions.canChangeCommunityOwner).toBe(true); + expect(role.permissions.communityPermissions.canReIndexSearchCollections).toBe(true); + }); + + it('does nothing when permissions are undefined', () => { + const role = createMockStaffRole(); + + applyCommunityPermissions(role); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + expect(role.permissions.communityPermissions.canDeleteCommunities).toBe(false); + }); + + it('updates only supplied properties', () => { + const role = createMockStaffRole(); + + applyCommunityPermissions(role, { + canManageCommunities: true, + }); + + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.communityPermissions.canDeleteCommunities).toBe(false); + expect(role.permissions.communityPermissions.canManageAllCommunities).toBe(false); + }); +}); + +describe('applyUserPermissions', () => { + it('updates all user permissions', () => { + const role = createMockStaffRole(); + + applyUserPermissions(role, { + canManageUsers: true, + canAssignStaffRoles: true, + canViewStaffUsers: true, + }); + + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffRoles).toBe(true); + expect(role.permissions.userPermissions.canViewStaffUsers).toBe(true); + }); + + it('does nothing when permissions are undefined', () => { + const role = createMockStaffRole(); + + applyUserPermissions(role); + + expect(role.permissions.userPermissions.canManageUsers).toBe(false); + expect(role.permissions.userPermissions.canAssignStaffRoles).toBe(false); + }); + + it('updates only specified properties', () => { + const role = createMockStaffRole(); + + applyUserPermissions(role, { + canViewStaffUsers: true, + }); + + expect(role.permissions.userPermissions.canViewStaffUsers).toBe(true); + expect(role.permissions.userPermissions.canManageUsers).toBe(false); + expect(role.permissions.userPermissions.canAssignStaffRoles).toBe(false); + }); +}); + +describe('applyRolePermissions', () => { + it('updates all staff role permissions', () => { + const role = createMockStaffRole(); + + applyRolePermissions(role, { + canViewRoles: true, + canAddRole: true, + canEditRole: true, + canRemoveRole: true, + }); + + expect(role.permissions.staffRolePermissions.canViewRoles).toBe(true); + expect(role.permissions.staffRolePermissions.canAddRole).toBe(true); + expect(role.permissions.staffRolePermissions.canEditRole).toBe(true); + expect(role.permissions.staffRolePermissions.canRemoveRole).toBe(true); + }); + + it('does nothing when permissions are undefined', () => { + const role = createMockStaffRole(); + + applyRolePermissions(role); + + expect(role.permissions.staffRolePermissions.canViewRoles).toBe(false); + expect(role.permissions.staffRolePermissions.canRemoveRole).toBe(false); + }); + + it('updates only provided values', () => { + const role = createMockStaffRole(); + + applyRolePermissions(role, { + canEditRole: true, + }); + + expect(role.permissions.staffRolePermissions.canEditRole).toBe(true); + expect(role.permissions.staffRolePermissions.canAddRole).toBe(false); + }); +}); + +describe('applyFinancePermissions', () => { + it('updates all finance permissions', () => { + const role = createMockStaffRole(); + + applyFinancePermissions(role, { + canManageFinance: true, + canViewGLBatchSummaries: true, + canViewFinanceConfigs: true, + canCreateFinanceConfigs: true, + }); + + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + expect(role.permissions.financePermissions.canViewGLBatchSummaries).toBe(true); + expect(role.permissions.financePermissions.canViewFinanceConfigs).toBe(true); + expect(role.permissions.financePermissions.canCreateFinanceConfigs).toBe(true); + }); + + it('does nothing when permissions are undefined', () => { + const role = createMockStaffRole(); + + applyFinancePermissions(role); + + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + expect(role.permissions.financePermissions.canViewFinanceConfigs).toBe(false); + }); + + it('updates only supplied properties', () => { + const role = createMockStaffRole(); + + applyFinancePermissions(role, { + canViewFinanceConfigs: true, + }); + + expect(role.permissions.financePermissions.canViewFinanceConfigs).toBe(true); + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + }); +}); + +describe('applyTechAdminPermissions', () => { + it('updates all tech admin permissions', () => { + const role = createMockStaffRole(); + + applyTechAdminPermissions(role, { + canManageTechAdmin: true, + canViewDatabaseDocuments: true, + canViewBlobExplorer: true, + canViewQueueDashboard: true, + canSendQueueMessages: true, + }); + + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + expect(role.permissions.techAdminPermissions.canViewDatabaseDocuments).toBe(true); + expect(role.permissions.techAdminPermissions.canViewBlobExplorer).toBe(true); + expect(role.permissions.techAdminPermissions.canViewQueueDashboard).toBe(true); + expect(role.permissions.techAdminPermissions.canSendQueueMessages).toBe(true); + }); + + it('does nothing when permissions are undefined', () => { + const role = createMockStaffRole(); + + applyTechAdminPermissions(role); + + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.techAdminPermissions.canViewQueueDashboard).toBe(false); + }); + + it('updates only specified permissions', () => { + const role = createMockStaffRole(); + + applyTechAdminPermissions(role, { + canViewDatabaseDocuments: true, + }); + + expect(role.permissions.techAdminPermissions.canViewDatabaseDocuments).toBe(true); + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + expect(role.permissions.techAdminPermissions.canSendQueueMessages).toBe(false); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/apply-permissions.ts b/packages/ocom/application-services/src/contexts/user/staff-role/apply-permissions.ts index cc4d651e5..0cd2f6ec2 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/apply-permissions.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/apply-permissions.ts @@ -31,7 +31,7 @@ export interface StaffRoleCommandFinancePermissions { export interface StaffRoleCommandTechAdminPermissions { canManageTechAdmin?: boolean; - canViewDatabaseExplorer?: boolean; + canViewDatabaseDocuments?: boolean; canViewBlobExplorer?: boolean; canViewQueueDashboard?: boolean; canSendQueueMessages?: boolean; @@ -122,8 +122,8 @@ export const applyTechAdminPermissions = (staffRole: Domain.Contexts.User.StaffR if (permissions.canManageTechAdmin !== undefined) { techAdminPermissions.canManageTechAdmin = permissions.canManageTechAdmin; } - if (permissions.canViewDatabaseExplorer !== undefined) { - techAdminPermissions.canViewDatabaseExplorer = permissions.canViewDatabaseExplorer; + if (permissions.canViewDatabaseDocuments !== undefined) { + techAdminPermissions.canViewDatabaseDocuments = permissions.canViewDatabaseDocuments; } if (permissions.canViewBlobExplorer !== undefined) { techAdminPermissions.canViewBlobExplorer = permissions.canViewBlobExplorer; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts index 6762a6797..d9d139df7 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts @@ -64,7 +64,7 @@ function makeMockStaffRoleInstance(roleName: string): MockStaffRoleInstance { }; const techAdminPermissions: Record = { canManageTechAdmin: false, - canViewDatabaseExplorer: false, + canViewDatabaseDocuments: false, canViewBlobExplorer: false, canViewQueueDashboard: false, canSendQueueMessages: false, diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/apply-permissions.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/apply-permissions.feature new file mode 100644 index 000000000..6ab9c8c86 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/apply-permissions.feature @@ -0,0 +1,64 @@ +Feature: Apply Staff Role Permissions + + Background: + Given a staff role with all permissions set to false + + Scenario: Apply community permissions + When I apply community permissions with all values set to true + Then the community permissions should all be true + + Scenario: Apply partial community permissions + When I apply community permissions with only canManageCommunities set to true + Then only canManageCommunities should be true + + Scenario: Apply undefined community permissions + When I apply undefined community permissions + Then the community permissions should remain unchanged + + Scenario: Apply user permissions + When I apply user permissions with all values set to true + Then the user permissions should all be true + + Scenario: Apply partial user permissions + When I apply user permissions with only canViewStaffUsers set to true + Then only canViewStaffUsers should be true + + Scenario: Apply undefined user permissions + When I apply undefined user permissions + Then the user permissions should remain unchanged + + Scenario: Apply staff role permissions + When I apply staff role permissions with all values set to true + Then the staff role permissions should all be true + + Scenario: Apply partial staff role permissions + When I apply staff role permissions with only canEditRole set to true + Then only canEditRole should be true + + Scenario: Apply undefined staff role permissions + When I apply undefined staff role permissions + Then the staff role permissions should remain unchanged + + Scenario: Apply finance permissions + When I apply finance permissions with all values set to true + Then the finance permissions should all be true + + Scenario: Apply partial finance permissions + When I apply finance permissions with only canViewFinanceConfigs set to true + Then only canViewFinanceConfigs should be true + + Scenario: Apply undefined finance permissions + When I apply undefined finance permissions + Then the finance permissions should remain unchanged + + Scenario: Apply tech admin permissions + When I apply tech admin permissions with all values set to true + Then the tech admin permissions should all be true + + Scenario: Apply partial tech admin permissions + When I apply tech admin permissions with only canViewDatabaseDocuments set to true + Then only canViewDatabaseDocuments should be true + + Scenario: Apply undefined tech admin permissions + When I apply undefined tech admin permissions + Then the tech admin permissions should remain unchanged \ No newline at end of file diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index 9e5407886..a6987dcc2 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -3,13 +3,16 @@ import { Domain } from '@ocom/domain'; import { Community, type CommunityContextApplicationService } from './contexts/community/index.ts'; import { Service, type ServiceContextApplicationService } from './contexts/service/index.ts'; import { User, type UserContextApplicationService } from './contexts/user/index.ts'; +import { TechAdmin, type TechAdminApplicationService } from './contexts/tech-admin/index.ts'; export type { CommunityUpdateSettingsCommand } from './contexts/community/index.ts'; +export { buildDatabaseDocumentsQueryCommand } from './contexts/tech-admin/index.ts'; export interface ApplicationServices { Community: CommunityContextApplicationService; Service: ServiceContextApplicationService; User: UserContextApplicationService; + TechAdmin: TechAdminApplicationService; get verifiedUser(): VerifiedUser | null; } @@ -74,6 +77,7 @@ export const buildApplicationServicesFactory = (context: ApiContextSpec): Applic Community: Community(dataSources, blobStorageService, queueStorageService), Service: Service(dataSources), User: User(dataSources), + TechAdmin: TechAdmin(), get verifiedUser(): VerifiedUser | null { return { ...tokenValidationResult, hints: hints }; }, 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 6099c443f..708a21e88 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 @@ -57,7 +57,7 @@ export interface StaffRoleFinancePermissions { export interface StaffRoleTechAdminPermissions { id?: ObjectId; canManageTechAdmin: boolean; - canViewDatabaseExplorer: boolean; + canViewDatabaseDocuments: boolean; canViewBlobExplorer: boolean; canViewQueueDashboard: boolean; canSendQueueMessages: boolean; @@ -152,7 +152,7 @@ const StaffRoleSchema = new Schema, StaffRole>( } as SchemaDefinition, techAdminPermissions: { canManageTechAdmin: { type: Boolean, required: true, default: false }, - canViewDatabaseExplorer: { type: Boolean, required: true, default: false }, + canViewDatabaseDocuments: { 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 }, 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 index 33816afec..9d8aa9d34 100644 --- 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 @@ -19,14 +19,14 @@ Feature: StaffRoleTechAdminPermissions When I try to set canManageTechAdmin to true Then a PermissionError should be thrown - Scenario: Changing canViewDatabaseExplorer with manage staff roles permission + Scenario: Changing canViewDatabaseDocuments with manage staff roles permission Given a StaffRoleTechAdminPermissions entity with permission to manage staff roles - When I set canViewDatabaseExplorer to true + When I set canViewDatabaseDocuments to true Then the property should be updated to true - Scenario: Changing canViewDatabaseExplorer without permission + Scenario: Changing canViewDatabaseDocuments without permission Given a StaffRoleTechAdminPermissions entity without permission to manage staff roles or system account - When I try to set canViewDatabaseExplorer to true + When I try to set canViewDatabaseDocuments to true Then a PermissionError should be thrown Scenario: Changing canViewBlobExplorer with manage staff roles permission @@ -62,7 +62,7 @@ Feature: StaffRoleTechAdminPermissions Scenario: Reading tech admin permission flags Given a StaffRoleTechAdminPermissions entity with all permission flags set to true Then canManageTechAdmin should be true - And canViewDatabaseExplorer should be true + And canViewDatabaseDocuments should be true And canViewBlobExplorer should be true And canViewQueueDashboard should be true And canSendQueueMessages should be true 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 4f0f8677d..acaf7b5b1 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 @@ -113,7 +113,7 @@ export class StaffRolePermissions extends ValueObject return new StaffRoleTechAdminPermissions( this.props.techAdminPermissions ?? { canManageTechAdmin: false, - canViewDatabaseExplorer: false, + canViewDatabaseDocuments: false, canViewBlobExplorer: false, canViewQueueDashboard: false, canSendQueueMessages: false, 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 index 07bcae739..29079b750 100644 --- 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 @@ -18,7 +18,7 @@ function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = function makeProps(overrides = {}) { return { canManageTechAdmin: false, - canViewDatabaseExplorer: false, + canViewDatabaseDocuments: false, canViewBlobExplorer: false, canViewQueueDashboard: false, canSendQueueMessages: false, @@ -89,28 +89,28 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); - Scenario('Changing canViewDatabaseExplorer with manage staff roles permission', ({ Given, When, Then }) => { + Scenario('Changing canViewDatabaseDocuments 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; + When('I set canViewDatabaseDocuments to true', () => { + entity.canViewDatabaseDocuments = true; }); Then('the property should be updated to true', () => { - expect(entity.canViewDatabaseExplorer).toBe(true); + expect(entity.canViewDatabaseDocuments).toBe(true); }); }); - Scenario('Changing canViewDatabaseExplorer without permission', ({ Given, When, Then }) => { + Scenario('Changing canViewDatabaseDocuments 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', () => { + When('I try to set canViewDatabaseDocuments to true', () => { setWithoutPermission = () => { - entity.canViewDatabaseExplorer = true; + entity.canViewDatabaseDocuments = true; }; }); Then('a PermissionError should be thrown', () => { @@ -213,7 +213,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Given('a StaffRoleTechAdminPermissions entity with all permission flags set to true', () => { props = makeProps({ canManageTechAdmin: true, - canViewDatabaseExplorer: true, + canViewDatabaseDocuments: true, canViewBlobExplorer: true, canViewQueueDashboard: true, canSendQueueMessages: true, @@ -223,8 +223,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Then('canManageTechAdmin should be true', () => { expect(entity.canManageTechAdmin).toBe(true); }); - And('canViewDatabaseExplorer should be true', () => { - expect(entity.canViewDatabaseExplorer).toBe(true); + And('canViewDatabaseDocuments should be true', () => { + expect(entity.canViewDatabaseDocuments).toBe(true); }); And('canViewBlobExplorer should be true', () => { expect(entity.canViewBlobExplorer).toBe(true); 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 index 9d225e6c7..dbce9fd89 100644 --- 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 @@ -5,7 +5,7 @@ import type { UserVisa } from '../user.visa.ts'; interface StaffRoleTechAdminPermissionsSpec { canManageTechAdmin: boolean; - canViewDatabaseExplorer: boolean; + canViewDatabaseDocuments: boolean; canViewBlobExplorer: boolean; canViewQueueDashboard: boolean; canSendQueueMessages: boolean; @@ -36,12 +36,12 @@ export class StaffRoleTechAdminPermissions extends ValueObject unknown>; const Mutation = { - ...(staffRoleResolvers.Mutation ?? {}), ...(staffUserResolvers.Mutation ?? {}), } as Record unknown>; diff --git a/packages/ocom/graphql/src/schema/types/tech-admin.graphql b/packages/ocom/graphql/src/schema/types/tech-admin.graphql new file mode 100644 index 000000000..db819e836 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/tech-admin.graphql @@ -0,0 +1,14 @@ +type DatabaseDocument { + id: String! + json: String! +} + +type DatabaseDocumentPage { + documents: [DatabaseDocument!]! + totalCount: Int! +} + +extend type Query { + techAdminDatabaseCollections: [String!]! + techAdminDatabaseDocuments(collection: String!, filter: String, page: Int, pageSize: Int): DatabaseDocumentPage! +} diff --git a/packages/ocom/graphql/src/schema/types/tech-admin.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/tech-admin.resolvers.test.ts new file mode 100644 index 000000000..9e0df37a2 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/tech-admin.resolvers.test.ts @@ -0,0 +1,654 @@ +import mongoose from 'mongoose'; +import { describe, beforeEach, expect, it, vi } from 'vitest'; +import { type FieldNode, type GraphQLObjectType, type GraphQLResolveInfo, type GraphQLSchema, Kind, type OperationDefinitionNode } from 'graphql'; + +import type { GraphContext } from '../context.ts'; +import techAdminResolvers from './tech-admin.resolvers.ts'; + +type JwtOverride = { + sub?: string; +}; + +type MockStaffUser = { + role?: { + permissions?: { + techAdminPermissions?: { + canViewDatabaseDocuments?: boolean; + canManageTechAdmin?: boolean; + }; + }; + }; +}; + +type MockStaffUserService = GraphContext['applicationServices']['User']['StaffUser'] & { + queryByExternalId: ReturnType; +}; + +type MockTechAdminService = { + ListCollections: ReturnType; + DatabaseDocuments: ReturnType; +}; + +type TestGraphContext = Omit & { + applicationServices: Omit & { + User: Omit & { + StaffUser: MockStaffUserService; + }; + TechAdmin: MockTechAdminService; + }; +}; + +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(options: { jwt?: JwtOverride | null; staffUser?: MockStaffUser } = {}): TestGraphContext { + const { jwt = {}, staffUser } = options; + + return { + applicationServices: { + User: { + StaffUser: { + queryByExternalId: vi.fn().mockResolvedValue(staffUser), + }, + }, + verifiedUser: + jwt === null + ? undefined + : { + verifiedJwt: { + sub: 'user-123', + ...jwt, + }, + }, + TechAdmin: { + ListCollections: vi.fn().mockImplementation(async () => { + const { db } = mongoose.connection; + if (!db) throw new Error('Database connection is not available'); + const cols = await db.listCollections().toArray(); + return cols.map((c) => c.name).filter((n) => !n.startsWith('system.')).sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })); + }), + DatabaseDocuments: vi.fn().mockImplementation(async (command: { collection: string; filter: Record; page: number; pageSize: number }) => { + const { db } = mongoose.connection; + if (!db) throw new Error('Database connection is not available'); + const cols = await db.listCollections().toArray(); + const available = cols.map((c) => c.name).filter((n) => !n.startsWith('system.')); + if (!available.includes(command.collection)) { + throw new Error('Collection not found or not allowed'); + } + const coll = db.collection(command.collection); + const totalCount = await coll.countDocuments(command.filter); + const docs = await coll.find(command.filter).skip((command.page - 1) * command.pageSize).limit(command.pageSize).toArray(); + return { + totalCount, + documents: docs.map((d) => ({ id: String((d as { _id?: unknown })._id), json: JSON.stringify(d) })), + }; + }), + }, + }, + } as unknown as TestGraphContext; +} + +const Query = { + ...(techAdminResolvers.Query ?? {}), +} as Record unknown>; + +const callQuery = (name: string, context: TestGraphContext, args: object = {}) => Query[name]?.({}, args, context, makeMockInfo(name)) as Promise; + +let mockCollections: { name: string }[]; +let mockDocuments: Record[]; + +let countDocumentsMock: ReturnType; +let skipMock: ReturnType; +let limitMock: ReturnType; +let toArrayMock: ReturnType; +let findMock: ReturnType; +let collectionMock: ReturnType; + +beforeEach(() => { + vi.restoreAllMocks(); + + mockCollections = [{ name: 'users' }, { name: 'roles' }, { name: 'system.profile' }]; + + mockDocuments = []; + + countDocumentsMock = vi.fn().mockResolvedValue(0); + + toArrayMock = vi.fn().mockResolvedValue(mockDocuments); + + limitMock = vi.fn().mockReturnValue({ + toArray: toArrayMock, + }); + + skipMock = vi.fn().mockReturnValue({ + limit: limitMock, + }); + + findMock = vi.fn().mockReturnValue({ + skip: skipMock, + }); + + collectionMock = vi.fn().mockReturnValue({ + countDocuments: countDocumentsMock, + find: findMock, + }); + + vi.spyOn(mongoose, 'connection', 'get').mockReturnValue({ + db: { + listCollections: vi.fn().mockReturnValue({ + toArray: vi.fn().mockResolvedValue(mockCollections), + }), + collection: collectionMock, + }, + } as never); +}); +describe('techAdminDatabaseCollections', () => { + let context: TestGraphContext; + + beforeEach(() => { + context = makeMockGraphContext(); + }); + + it('throws Unauthorized when JWT is missing', async () => { + context = makeMockGraphContext({ + jwt: null, + }); + + await expect(callQuery('techAdminDatabaseCollections', context)).rejects.toThrow('Unauthorized'); + }); + + it('throws Unauthorized when user cannot view or manage Tech Admin', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: false, + canManageTechAdmin: false, + }, + }, + }, + }, + }); + + await expect(callQuery('techAdminDatabaseCollections', context)).rejects.toThrow('Unauthorized'); + + expect(context.applicationServices.User.StaffUser.queryByExternalId).toHaveBeenCalledWith({ + externalId: 'user-123', + }); + }); + + it('throws Unauthorized for database collections and documents when user lacks permission', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: false, + canManageTechAdmin: false, + }, + }, + }, + }, + }); + + await expect(callQuery('techAdminDatabaseCollections', context)).rejects.toThrow('Unauthorized'); + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + }), + ).rejects.toThrow('Unauthorized'); + }); + + it('allows users with canViewDatabaseDocuments permission', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + canManageTechAdmin: false, + }, + }, + }, + }, + }); + + const result = await callQuery('techAdminDatabaseCollections', context); + + expect(result).toEqual(['roles', 'users']); + }); + + it('allows users with canManageTechAdmin permission', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: false, + canManageTechAdmin: true, + }, + }, + }, + }, + }); + + const result = await callQuery('techAdminDatabaseCollections', context); + + expect(result).toEqual(['roles', 'users']); + }); + + it('filters out system collections', async () => { + mockCollections = [{ name: 'users' }, { name: 'roles' }, { name: 'system.profile' }, { name: 'system.indexes' }, { name: 'orders' }]; + + vi.spyOn(mongoose, 'connection', 'get').mockReturnValue({ + db: { + listCollections: vi.fn().mockReturnValue({ + toArray: vi.fn().mockResolvedValue(mockCollections), + }), + collection: collectionMock, + }, + } as never); + + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + const result = await callQuery('techAdminDatabaseCollections', context); + + expect(result).toEqual(['orders', 'roles', 'users']); + + expect(result).not.toContain('system.profile'); + expect(result).not.toContain('system.indexes'); + }); + + it('returns collections sorted alphabetically', async () => { + mockCollections = [{ name: 'zebra' }, { name: 'apple' }, { name: 'Monkey' }, { name: 'banana' }]; + + vi.spyOn(mongoose, 'connection', 'get').mockReturnValue({ + db: { + listCollections: vi.fn().mockReturnValue({ + toArray: vi.fn().mockResolvedValue(mockCollections), + }), + collection: collectionMock, + }, + } as never); + + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + const result = await callQuery('techAdminDatabaseCollections', context); + + expect(result).toEqual(['apple', 'banana', 'Monkey', 'zebra']); + }); + + it('throws when database connection is unavailable', async () => { + vi.spyOn(mongoose, 'connection', 'get').mockReturnValue({ + db: undefined, + } as never); + + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await expect(callQuery('techAdminDatabaseCollections', context)).rejects.toThrow('Database connection is not available'); + }); + + it('calls queryByExternalId with JWT subject', async () => { + context = makeMockGraphContext({ + jwt: { + sub: 'external-user-id', + }, + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await callQuery('techAdminDatabaseCollections', context); + + expect(context.applicationServices.User.StaffUser.queryByExternalId).toHaveBeenCalledTimes(1); + + expect(context.applicationServices.User.StaffUser.queryByExternalId).toHaveBeenCalledWith({ + externalId: 'external-user-id', + }); + }); + it('throws Unauthorized when JWT is missing', async () => { + context = makeMockGraphContext({ + jwt: null, + }); + + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + }), + ).rejects.toThrow('Unauthorized'); + }); + it('throws Unauthorized when user lacks permissions', async () => { + context = makeMockGraphContext({ + staffUser: {}, + }); + + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + }), + ).rejects.toThrow('Unauthorized'); + }); + it('throws for invalid collection name', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: '../users', + }), + ).rejects.toThrow('Invalid collection name'); + }); + it('throws when collection does not exist', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: 'unknown', + }), + ).rejects.toThrow('Collection not found or not allowed'); + }); + it('throws for invalid filter JSON', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + filter: '{invalid}', + }), + ).rejects.toThrow('Invalid filter JSON'); + }); + it('rejects $where operator', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + filter: JSON.stringify({ + $where: 'this.age > 18', + }), + }), + ).rejects.toThrow('Operator $where is not allowed in filter'); + }); + it('rejects unknown operators', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await expect( + callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + filter: JSON.stringify({ + name: { + $foo: 'abc', + }, + }), + }), + ).rejects.toThrow('Unknown operator: $foo'); + }); + it('caps pageSize at 100', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + page: 1, + pageSize: 500, + }); + + expect(limitMock).toHaveBeenCalledWith(100); + }); + it('uses page 1 when page is less than 1', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + page: -5, + pageSize: 20, + }); + + expect(skipMock).toHaveBeenCalledWith(0); + }); + it('calculates skip correctly', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + await callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + page: 3, + pageSize: 10, + }); + + expect(skipMock).toHaveBeenCalledWith(20); + }); + it('passes parsed filter to countDocuments', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + const filter = { + name: 'John', + }; + + await callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + filter: JSON.stringify(filter), + }); + + expect(countDocumentsMock).toHaveBeenCalledWith(filter); + }); + it('passes parsed filter to find', async () => { + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + const filter = { + email: 'abc@test.com', + }; + + await callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + filter: JSON.stringify(filter), + }); + + expect(findMock).toHaveBeenCalledWith(filter); + }); + it('returns totalCount', async () => { + countDocumentsMock.mockResolvedValue(45); + + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + const result = await callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + }); + + expect(result).toMatchObject({ + totalCount: 45, + }); + }); + it('returns empty documents when none found', async () => { + mockDocuments = []; + toArrayMock.mockResolvedValue(mockDocuments); + + context = makeMockGraphContext({ + staffUser: { + role: { + permissions: { + techAdminPermissions: { + canViewDatabaseDocuments: true, + }, + }, + }, + }, + }); + + const result = await callQuery('techAdminDatabaseDocuments', context, { + collection: 'users', + }); + + expect(result).toEqual({ + documents: [], + totalCount: 0, + }); + }); +}); diff --git a/packages/ocom/graphql/src/schema/types/tech-admin.resolvers.ts b/packages/ocom/graphql/src/schema/types/tech-admin.resolvers.ts new file mode 100644 index 000000000..49f0b22cd --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/tech-admin.resolvers.ts @@ -0,0 +1,59 @@ +import { GraphQLError, type GraphQLResolveInfo } from 'graphql'; +import type { Resolvers } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; +import { buildDatabaseDocumentsQueryCommand } from '@ocom/application-services'; + +function unauthorizedError() { + return new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } }); +} + +function userInputError(message: string) { + return new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } }); +} + +const techAdminResolvers: Resolvers = { + Query: { + techAdminDatabaseCollections: async (_parent: unknown, _args: unknown, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + throw unauthorizedError(); + } + + const staff = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + + const canView = staff?.role?.permissions?.techAdminPermissions?.canViewDatabaseDocuments === true; + const canManage = staff?.role?.permissions?.techAdminPermissions?.canManageTechAdmin === true; + if (!canView && !canManage) { + throw unauthorizedError(); + } + + return await context.applicationServices.TechAdmin.ListCollections(); + }, + + techAdminDatabaseDocuments: async (_parent: unknown, args, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + throw unauthorizedError(); + } + + const staff = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + + const canView = staff?.role?.permissions?.techAdminPermissions?.canViewDatabaseDocuments === true; + const canManage = staff?.role?.permissions?.techAdminPermissions?.canManageTechAdmin === true; + if (!canView && !canManage) { + throw unauthorizedError(); + } + + const command = (() => { + try { + return buildDatabaseDocumentsQueryCommand(args); + } catch (error) { + throw userInputError((error as Error).message); + } + })(); + return await context.applicationServices.TechAdmin.DatabaseDocuments(command); + }, + }, +}; + +export default techAdminResolvers; 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 3fba1f420..35221f752 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 @@ -234,13 +234,13 @@ Feature: StaffRoleDomainAdapter When I set the canManageTechAdmin property to true Then the techAdminPermissions' canManageTechAdmin should be true - Scenario: Getting and setting canViewDatabaseExplorer from techAdminPermissions + Scenario: Getting and setting canViewDatabaseDocuments 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 + Then the canViewDatabaseDocuments property should return false + When I set the canViewDatabaseDocuments property to true + Then the techAdminPermissions' canViewDatabaseDocuments should be true Scenario: Getting and setting canViewBlobExplorer from techAdminPermissions Given a StaffRoleDomainAdapter for the document 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 bcf9cf40e..46a2abf00 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 @@ -737,7 +737,7 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => }); }); - Scenario('Getting and setting canViewDatabaseExplorer from techAdminPermissions', ({ Given, When, And, Then }) => { + Scenario('Getting and setting canViewDatabaseDocuments from techAdminPermissions', ({ Given, When, And, Then }) => { let permissions: StaffRolePermissionsAdapter; let techAdminPermissions: StaffRoleTechAdminPermissionsAdapter; Given('a StaffRoleDomainAdapter for the document', () => { @@ -749,14 +749,14 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => And('I get the techAdminPermissions property', () => { techAdminPermissions = permissions.techAdminPermissions as StaffRoleTechAdminPermissionsAdapter; }); - Then('the canViewDatabaseExplorer property should return false', () => { - expect(techAdminPermissions.canViewDatabaseExplorer).toBe(false); + Then('the canViewDatabaseDocuments property should return false', () => { + expect(techAdminPermissions.canViewDatabaseDocuments).toBe(false); }); - When('I set the canViewDatabaseExplorer property to true', () => { - techAdminPermissions.canViewDatabaseExplorer = true; + When('I set the canViewDatabaseDocuments property to true', () => { + techAdminPermissions.canViewDatabaseDocuments = true; }); - Then("the techAdminPermissions' canViewDatabaseExplorer should be true", () => { - expect(doc.permissions?.techAdminPermissions?.canViewDatabaseExplorer).toBe(true); + Then("the techAdminPermissions' canViewDatabaseDocuments should be true", () => { + expect(doc.permissions?.techAdminPermissions?.canViewDatabaseDocuments).toBe(true); }); }); 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 1a94a9907..d14d4958b 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 @@ -140,7 +140,7 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo if (!this.doc.techAdminPermissions) { this.doc.techAdminPermissions = { canManageTechAdmin: false, - canViewDatabaseExplorer: false, + canViewDatabaseDocuments: false, canViewBlobExplorer: false, canViewQueueDashboard: false, canSendQueueMessages: false, @@ -406,11 +406,11 @@ export class StaffRoleTechAdminPermissionsAdapter implements Domain.Contexts.Use this.doc.canManageTechAdmin = value; } - get canViewDatabaseExplorer(): boolean { - return this.ensureValue(this.doc.canViewDatabaseExplorer); + get canViewDatabaseDocuments(): boolean { + return this.ensureValue(this.doc.canViewDatabaseDocuments); } - set canViewDatabaseExplorer(value: boolean) { - this.doc.canViewDatabaseExplorer = value; + set canViewDatabaseDocuments(value: boolean) { + this.doc.canViewDatabaseDocuments = value; } get canViewBlobExplorer(): boolean { 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 e0e2acb49..1eba4fb76 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 @@ -30,7 +30,7 @@ export class StaffRoleRepository async getDefaultRoleByEnterpriseAppRole(enterpriseAppRole: string): Promise> { const staffRole = await this.model.findOne({ isDefault: true, enterpriseAppRole }).exec(); if (!staffRole) { - throw new NotFoundError(`Default StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`); + throw new Error(`Default StaffRole with enterpriseAppRole ${enterpriseAppRole} not found`); } return this.typeConverter.toDomain(staffRole, this.passport); } diff --git a/packages/ocom/persistence/src/datasources/readonly/index.ts b/packages/ocom/persistence/src/datasources/readonly/index.ts index 5845c7d6b..875d9dfce 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.ts @@ -4,8 +4,8 @@ import type * as Community from './community/community/index.ts'; 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 StaffRole from './user/staff-role/index.ts'; +import { UserContext } from './user/index.ts'; import type * as StaffUser from './user/staff-user/index.ts'; export interface ReadonlyDataSource { diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts index 5b586d7ae..3f517d441 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts @@ -1,14 +1,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import type { StaffRole, StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import { expect, vi } from 'vitest'; import type { Domain } from '@ocom/domain'; -import { expect, vi } from 'vitest'; +import type { StaffRole, StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; import type { ModelsContext } from '../../../../index.ts'; import { StaffRoleConverter } from '../../../domain/user/staff-role/staff-role.domain-adapter.ts'; -import type { StaffRoleReadRepository } from './staff-role.read-repository.ts'; import { getStaffRoleReadRepository } from './staff-role.read-repository.ts'; +import type { StaffRoleReadRepository } from './staff-role.read-repository.ts'; const test = { for: describeFeature }; 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 index 9bd5f9932..ba6ae2598 100644 --- 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 @@ -1,14 +1,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import type { StaffUser, StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import { expect, vi } from 'vitest'; import type { Domain } from '@ocom/domain'; -import { expect, vi } from 'vitest'; +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 type { StaffUserReadRepository } from './staff-user.read-repository.ts'; import { getStaffUserReadRepository } from './staff-user.read-repository.ts'; +import type { StaffUserReadRepository } from './staff-user.read-repository.ts'; const test = { for: describeFeature }; 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 aac337ff8..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 @@ -119,12 +119,12 @@ export const CommunityList: React.FC = (props) => {

Navigate to a Community

- +
= (pr const { data: membersData, loading: membersLoading, error: membersError } = useQuery(AdminSectionLayoutContainerMembersForCurrentEndUserDocument); + return ( = { component: SectionLayout, decorators: [ (Story) => ( - + boolean; } @@ -51,12 +52,14 @@ export const MenuComponent: React.FC = ({ pageLayouts, membe key={child.id} title={child.title} > - - {child.title} - + {!child.hideSelfLinkWhenHasChildren && ( + + {child.title} + + )} {buildMenu(child.id)} ) : ( diff --git a/packages/ocom/ui-staff-route-tech-admin/package.json b/packages/ocom/ui-staff-route-tech-admin/package.json index a1622d8fb..0734d71a3 100644 --- a/packages/ocom/ui-staff-route-tech-admin/package.json +++ b/packages/ocom/ui-staff-route-tech-admin/package.json @@ -18,11 +18,16 @@ "@ocom/ui-staff-shared": "workspace:*", "react": "catalog:", "react-dom": "catalog:", - "react-router-dom": "catalog:" + "react-router-dom": "catalog:", + "antd": "catalog:", + "@apollo/client": "^3.13.9", + "@cellix/ui-core": "workspace:*" }, + "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@testing-library/react": "^16.3.0", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", "jsdom": "catalog:", diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.container.graphql b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.container.graphql new file mode 100644 index 000000000..cecda10ed --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.container.graphql @@ -0,0 +1,10 @@ +query TechAdminDatabaseExplorerContainerCollections { + techAdminDatabaseCollections +} + +query TechAdminDatabaseExplorerContainerDocuments($collection: String!, $filter: String, $page: Int, $pageSize: Int) { + techAdminDatabaseDocuments(collection: $collection, filter: $filter, page: $page, pageSize: $pageSize) { + documents { id json } + totalCount + } +} diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.container.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.container.tsx new file mode 100644 index 000000000..5a2028a89 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.container.tsx @@ -0,0 +1,79 @@ +import type React from 'react'; +import { useMemo, useState } from 'react'; +import { useQuery, gql } from '@apollo/client'; +import { DatabaseExplorer } from './database-explorer.tsx'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { DatabaseDocument } from './database-explorer.tsx'; +import { Empty } from 'antd'; + +const COLLECTIONS_QUERY = gql` +query TechAdminDatabaseExplorerContainerCollections { + techAdminDatabaseCollections +} +`; + +const DOCUMENTS_QUERY = gql` +query TechAdminDatabaseExplorerContainerDocuments($collection: String!, $filter: String, $page: Int, $pageSize: Int) { + techAdminDatabaseDocuments(collection: $collection, filter: $filter, page: $page, pageSize: $pageSize) { + documents { id json } + totalCount + } +} +`; + +interface DocumentsQueryResult { + techAdminDatabaseDocuments: { + documents: { id: string; json: string }[]; + totalCount: number; + }; +} + +export const DatabaseExplorerContainer: React.FC = () => { + const [selectedCollection, setSelectedCollection] = useState(undefined); + const [draftFilter, setDraftFilter] = useState(''); + const [appliedFilter, setAppliedFilter] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + const { data: collectionsData, loading: collectionsLoading, error: collectionsError } = useQuery<{ techAdminDatabaseCollections: string[] }>(COLLECTIONS_QUERY, { fetchPolicy: 'cache-first' }); + + const variables = useMemo(() => ({ collection: selectedCollection ?? '', filter: appliedFilter ?? undefined, page, pageSize }), [selectedCollection, appliedFilter, page, pageSize]); + const { data: documentsData, loading: documentsLoading } = useQuery(DOCUMENTS_QUERY, { + variables, + skip: !selectedCollection, + pollInterval: 5000, + }); + + const collections = collectionsData?.techAdminDatabaseCollections ?? []; + const documents: DatabaseDocument[] = (documentsData?.techAdminDatabaseDocuments?.documents ?? []).map((d: { id: string; json: string }) => ({ id: d.id, json: d.json })); + const totalCount = documentsData?.techAdminDatabaseDocuments?.totalCount ?? 0; + + return ( + + ) : ( + { setSelectedCollection(c); setPage(1); }} + filter={draftFilter} + onChangeFilter={setDraftFilter} + onApplyFilter={() => { setAppliedFilter(draftFilter); setPage(1); }} + documents={documents} + totalCount={totalCount} + page={page} + pageSize={pageSize} + onChangePage={(p: number, ps?: number) => { setPage(p); if (ps) setPageSize(ps); }} + loading={documentsLoading} + /> + ) + } + noDataComponent={} + /> + ); +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.stories.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.stories.tsx new file mode 100644 index 000000000..8eae69c39 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.stories.tsx @@ -0,0 +1,19 @@ +import { DatabaseExplorer } from './database-explorer.tsx'; + +export default { title: 'Tech Admin/Database Explorer' }; + +export const Default = () => ( + undefined} + filter="" + onChangeFilter={() => undefined} + onApplyFilter={() => undefined} + documents={[{ id: '1', json: JSON.stringify({ name: 'Alice', age: 30 }) }]} + totalCount={1} + page={1} + pageSize={20} + onChangePage={() => undefined} + /> +); diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.test.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.test.tsx new file mode 100644 index 000000000..fd690ee2f --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { DatabaseExplorer } from './database-explorer.tsx'; + +const writeTextMock = vi.fn(); + +beforeEach(() => { + writeTextMock.mockReset(); + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: { writeText: writeTextMock }, + configurable: true, + }); +}); + +test('renders database explorer title', () => { + render( + undefined} + filter="" + onChangeFilter={() => undefined} + onApplyFilter={() => undefined} + documents={[]} + totalCount={0} + page={1} + pageSize={20} + onChangePage={() => undefined} + />, + ); + + expect(screen.getByText(/Database Explorer/i)).toBeTruthy(); +}); + +test('opens full JSON modal from action button', () => { + render( + undefined} + filter="" + onChangeFilter={() => undefined} + onApplyFilter={() => undefined} + documents={[{ id: '1', json: JSON.stringify({ name: 'Alice', age: 30 }) }]} + totalCount={1} + page={1} + pageSize={20} + onChangePage={() => undefined} + />, + ); + + fireEvent.click(screen.getByLabelText('Open JSON modal for 1')); + + expect(screen.getByText('Full JSON')).toBeTruthy(); + expect(screen.getByText(/"Alice"/)).toBeTruthy(); + expect(screen.getByText('string')).toBeTruthy(); +}); + +test('copies json when clipboard icon is clicked', () => { + render( + undefined} + filter="" + onChangeFilter={() => undefined} + onApplyFilter={() => undefined} + documents={[{ id: '1', json: JSON.stringify({ name: 'Alice', age: 30 }) }]} + totalCount={1} + page={1} + pageSize={20} + onChangePage={() => undefined} + />, + ); + + fireEvent.click(screen.getByLabelText('Copy JSON for 1')); + + expect(writeTextMock).toHaveBeenCalledWith('{"name":"Alice","age":30}'); +}); diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.tsx new file mode 100644 index 000000000..e4df9aeed --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/database-explorer.tsx @@ -0,0 +1,297 @@ +import { CopyOutlined, ExpandOutlined } from '@ant-design/icons'; +import { Button, Input, Modal, Select, Space, Table, Tooltip, Typography } from 'antd'; +import type { TableColumnsType } from 'antd'; +import type React from 'react'; +import { useState } from 'react'; + +const { Title } = Typography; +const { TextArea } = Input; +export interface DatabaseDocument { + id: string; + json: string; +} + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; + +interface DatabaseExplorerProps { + collections: string[]; + selectedCollection?: string; + onSelectCollection: (col: string) => void; + filter: string; + onChangeFilter: (val: string) => void; + onApplyFilter: () => void; + documents: DatabaseDocument[]; + totalCount: number; + page: number; + pageSize: number; + onChangePage: (page: number, pageSize?: number) => void; + loading?: boolean; +} + +export const DatabaseExplorer: React.FC = ({ + collections, + selectedCollection, + onSelectCollection, + filter, + onChangeFilter, + onApplyFilter, + documents, + totalCount, + page, + pageSize, + onChangePage, + loading, +}) => { + const [isJsonModalOpen, setIsJsonModalOpen] = useState(false); + const [selectedJson, setSelectedJson] = useState(''); + + 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) => ( + + +