From a03d8f789db84067bb8fbb62f9ff550f8898775a Mon Sep 17 00:00:00 2001 From: Shivaji Byrapaneni Date: Tue, 1 Sep 2026 08:31:53 -0400 Subject: [PATCH 1/6] feat(calmhub): OIDC support & GH as backend support --- calm-hub-ui/src/ProtectedRoute.test.tsx | 182 ++++++++ calm-hub-ui/src/ProtectedRoute.tsx | 13 + .../admin/panels/EntitlementsPanel.test.tsx | 4 +- calm-hub-ui/src/authConfig.test.ts | 74 ++++ calm-hub-ui/src/authConfig.ts | 56 +++ calm-hub-ui/src/authService.test.tsx | 6 + calm-hub-ui/src/authService.tsx | 75 ++-- calm-hub-ui/src/components/navbar/Navbar.tsx | 3 + .../components/user-menu/UserMenu.test.tsx | 55 +++ .../src/components/user-menu/UserMenu.tsx | 64 +++ calm-hub-ui/src/hub/Hub.tsx | 2 + .../diagram-section/DiagramSection.tsx | 22 +- .../diagram-section/timeline/Sparkline.tsx | 5 +- .../timeline/TimelineBar.test.tsx | 11 +- .../timeline/TimelineHeader.test.tsx | 36 ++ .../timeline/TimelineHeader.tsx | 5 +- .../DocumentDetailSection.test.tsx | 54 +++ .../DocumentDetailSection.tsx | 29 +- .../explore-rail/ExploreRail.test.tsx | 20 +- .../components/explore-rail/ExploreRail.tsx | 52 ++- .../components/intro-screen/IntroScreen.tsx | 5 +- .../components/namespace-page/ItemCard.tsx | 6 +- .../section-header/SectionHeader.test.tsx | 8 +- .../section-header/SectionHeader.tsx | 6 +- .../tree-navigation/MobileNavMenu.test.tsx | 22 + .../tree-navigation/MobileNavMenu.tsx | 11 +- .../src/hub/hooks/useDefaultLayout.test.tsx | 94 +++- calm-hub-ui/src/hub/hooks/useDefaultLayout.ts | 88 +++- calm-hub-ui/src/index.tsx | 36 +- calm-hub-ui/src/model/layout.test.ts | 107 +++++ calm-hub-ui/src/model/layout.ts | 54 ++- calm-hub-ui/src/model/version.spec.ts | 17 + calm-hub-ui/src/model/version.ts | 5 + .../reactflow/ArchitectureGraph.test.tsx | 13 +- .../reactflow/ArchitectureGraph.tsx | 8 +- .../components/reactflow/CustomNode.test.tsx | 45 +- .../components/reactflow/CustomNode.tsx | 15 +- .../components/reactflow/FloatingEdge.tsx | 46 +- .../reactflow/PatternGraph.test.tsx | 13 +- .../components/reactflow/PatternGraph.tsx | 5 +- .../reactflow/utils/edgeFactory.test.ts | 22 +- .../components/reactflow/utils/edgeFactory.ts | 2 - .../services/node-position-service.test.tsx | 56 +++ .../services/node-position-service.tsx | 56 ++- calm-hub/.gitignore | 2 + calm-hub/nitrite/init-nitrite.sh | 48 ++ calm-hub/pom.xml | 35 ++ .../integration/NitriteEndToEndResource.java | 1 + .../calm/cache/CaffeineCacheService.java | 73 ++++ .../finos/calm/cache/CalmCacheService.java | 15 + .../org/finos/calm/config/DatabaseMode.java | 10 + .../calm/config/StandaloneDemoSeeder.java | 193 +++++++++ .../org/finos/calm/domain/ResourceType.java | 4 +- .../calm/domain/audit/AuditEntityType.java | 4 +- .../BuildingBlockNotFoundException.java | 7 + ...BuildingBlockVersionNotFoundException.java | 7 + .../GitHubWriteNotSupportedException.java | 8 + .../exception/PendingWriteException.java | 32 ++ .../steps/MongoLayoutFormatMigrationStep.java | 108 +++++ .../steps/NamespaceAccessBackfillStep.java | 2 + .../NitriteLayoutFormatMigrationStep.java | 111 +++++ .../calm/observability/GitHubMetrics.java | 65 +++ .../calm/resources/ArchitectureResource.java | 3 +- .../calm/resources/AuthConfigResource.java | 68 +++ .../calm/resources/CalmDocumentParser.java | 28 +- .../finos/calm/resources/ControlResource.java | 2 +- .../finos/calm/resources/FlowResource.java | 3 +- .../resources/MappingControllerResource.java | 18 +- .../calm/resources/NamespaceResource.java | 30 +- .../finos/calm/resources/PatternResource.java | 3 +- .../PendingWriteExceptionMapper.java | 28 ++ .../calm/resources/PluginAuthResource.java | 231 ++++++++++ .../ResourceValidationConstants.java | 6 +- .../calm/resources/StandardResource.java | 2 +- .../UnsupportedOperationExceptionMapper.java | 21 + .../security/CalmHubPermissionChecker.java | 15 +- .../calm/security/LocationSegmentParser.java | 2 +- .../calm/security/OidcPluginAuthClient.java | 155 +++++++ .../finos/calm/security/OidcRoleResolver.java | 76 ++++ .../services/MappingControllerService.java | 11 + .../finos/calm/store/BuildingBlockStore.java | 16 + .../classpath/ClasspathCoreSchemaStore.java | 123 ++++++ .../calm/store/github/GitHubAdrStore.java | 87 ++++ .../store/github/GitHubArchitectureStore.java | 136 ++++++ .../store/github/GitHubAuditLogStore.java | 33 ++ .../github/GitHubBuildingBlockStore.java | 132 ++++++ .../calm/store/github/GitHubControlStore.java | 193 +++++++++ .../store/github/GitHubDecoratorStore.java | 65 +++ .../calm/store/github/GitHubDomainStore.java | 72 +++ .../calm/store/github/GitHubFlowStore.java | 142 ++++++ .../store/github/GitHubInterfaceStore.java | 136 ++++++ .../calm/store/github/GitHubLayoutStore.java | 33 ++ .../store/github/GitHubNamespaceStore.java | 57 +++ .../calm/store/github/GitHubPatternStore.java | 144 ++++++ .../github/GitHubResourceMappingStore.java | 125 ++++++ .../calm/store/github/GitHubSearchStore.java | 70 +++ .../store/github/GitHubStandardStore.java | 146 +++++++ .../store/github/GitHubTimelineStore.java | 136 ++++++ .../store/github/GitHubUserAccessStore.java | 132 ++++++ .../github/util/CalmContentDetector.java | 138 ++++++ .../store/github/util/CalmResourceType.java | 16 + .../store/github/util/GitHubCloneManager.java | 148 +++++++ .../github/util/GitHubForkPRService.java | 54 +++ .../store/github/util/GitHubRepoSync.java | 84 ++++ .../github/util/GitHubStartupInitializer.java | 98 +++++ .../store/github/util/GitHubStoreConfig.java | 51 +++ .../github/util/GitHubSyncScheduler.java | 60 +++ .../github/util/GitHubVersionService.java | 186 ++++++++ .../github/util/InMemoryRegistryService.java | 194 +++++++++ .../calm/store/github/util/RegistryEntry.java | 12 + .../store/github/util/RegistrySnapshot.java | 34 ++ .../store/noop/NoOpResourceMappingStore.java | 57 +++ .../store/noop/NoOpSchemaVersionStore.java | 35 ++ .../calm/store/producer/AdrStoreProducer.java | 22 +- .../producer/ArchitectureStoreProducer.java | 20 +- .../store/producer/AuditLogStoreProducer.java | 18 +- .../producer/BuildingBlockStoreProducer.java | 65 +++ .../store/producer/ControlStoreProducer.java | 22 +- .../producer/CoreSchemaStoreProducer.java | 20 +- .../producer/DecoratorStoreProducer.java | 27 +- .../store/producer/DomainStoreProducer.java | 20 +- .../store/producer/FlowStoreProducer.java | 22 +- .../producer/InterfaceStoreProducer.java | 20 +- .../store/producer/LayoutStoreProducer.java | 25 +- .../producer/NamespaceStoreProducer.java | 20 +- .../store/producer/PatternStoreProducer.java | 20 +- .../ResourceMappingStoreProducer.java | 15 +- .../producer/SchemaVersionStoreProducer.java | 18 +- .../store/producer/SearchStoreProducer.java | 20 +- .../store/producer/StandardStoreProducer.java | 22 +- .../store/producer/TimelineStoreProducer.java | 20 +- .../producer/UserAccessStoreProducer.java | 20 +- .../resources/application-oidc.properties | 54 +++ .../src/main/resources/application.properties | 23 +- .../cache/TestCaffeineCacheServiceShould.java | 147 +++++++ .../TestPendingWriteExceptionShould.java | 38 ++ ...tMongoLayoutFormatMigrationStepShould.java | 165 +++++++ ...itriteLayoutFormatMigrationStepShould.java | 137 ++++++ .../TestGitHubMetricsShould.java | 87 ++++ .../TestAuthConfigResourceShould.java | 118 +++++ ...TestPendingWriteExceptionMapperShould.java | 39 ++ .../TestPluginAuthResourceShould.java | 410 ++++++++++++++++++ ...pportedOperationExceptionMapperShould.java | 36 ++ .../TestOidcPluginAuthClientShould.java | 268 ++++++++++++ .../TestClasspathCoreSchemaStoreShould.java | 82 ++++ .../github/TestGitHubAdrStoreShould.java | 115 +++++ .../TestGitHubArchitectureStoreShould.java | 220 ++++++++++ .../github/TestGitHubAuditLogStoreShould.java | 41 ++ .../TestGitHubBuildingBlockStoreShould.java | 231 ++++++++++ .../github/TestGitHubControlStoreShould.java | 270 ++++++++++++ .../TestGitHubDecoratorStoreShould.java | 114 +++++ .../github/TestGitHubDomainStoreShould.java | 93 ++++ .../github/TestGitHubFlowStoreShould.java | 223 ++++++++++ .../TestGitHubInterfaceStoreShould.java | 228 ++++++++++ .../github/TestGitHubLayoutStoreShould.java | 46 ++ .../TestGitHubNamespaceStoreShould.java | 88 ++++ .../github/TestGitHubPatternStoreShould.java | 224 ++++++++++ .../TestGitHubResourceMappingStoreShould.java | 209 +++++++++ .../github/TestGitHubSearchStoreShould.java | 90 ++++ .../github/TestGitHubStandardStoreShould.java | 245 +++++++++++ .../github/TestGitHubTimelineStoreShould.java | 240 ++++++++++ .../TestGitHubUserAccessStoreShould.java | 207 +++++++++ .../util/TestCalmContentDetectorShould.java | 268 ++++++++++++ .../util/TestGitHubCloneManagerShould.java | 152 +++++++ .../util/TestGitHubForkPRServiceShould.java | 32 ++ .../github/util/TestGitHubRepoSyncShould.java | 125 ++++++ .../TestGitHubStartupInitializerShould.java | 98 +++++ .../util/TestGitHubStoreConfigShould.java | 48 ++ .../util/TestGitHubSyncSchedulerShould.java | 70 +++ .../util/TestGitHubVersionServiceShould.java | 113 +++++ .../TestInMemoryRegistryServiceShould.java | 265 +++++++++++ .../TestNoOpResourceMappingStoreShould.java | 67 +++ .../TestNoOpSchemaVersionStoreShould.java | 46 ++ .../producer/TestAdrStoreProducerShould.java | 22 +- .../TestArchitectureStoreProducerShould.java | 22 +- .../TestAuditLogStoreProducerShould.java | 19 + .../TestBuildingBlockStoreProducerShould.java | 113 +++++ .../TestControlStoreProducerShould.java | 18 + .../TestCoreSchemaStoreProducerShould.java | 32 +- .../TestDecoratorStoreProducerShould.java | 24 +- .../TestDomainStoreProducerShould.java | 22 +- .../producer/TestFlowStoreProducerShould.java | 22 +- .../TestInterfaceStoreProducerShould.java | 19 + .../TestLayoutStoreProducerShould.java | 24 +- .../TestNamespaceStoreProducerShould.java | 22 +- .../TestPatternStoreProducerShould.java | 22 +- ...estResourceMappingStoreProducerShould.java | 23 +- .../TestSchemaVersionStoreProducerShould.java | 23 +- .../TestSearchStoreProducerShould.java | 19 + .../TestStandardStoreProducerShould.java | 22 +- .../TestTimelineStoreProducerShould.java | 19 + .../TestUserAccessStoreProducerShould.java | 32 +- .../META-INF/calm-schemas/1.0/files.txt | 1 + .../META-INF/calm-schemas/1.0/meta/core.json | 1 + .../META-INF/calm-schemas/versions.txt | 1 + 195 files changed, 12359 insertions(+), 451 deletions(-) create mode 100644 calm-hub-ui/src/ProtectedRoute.test.tsx create mode 100644 calm-hub-ui/src/authConfig.test.ts create mode 100644 calm-hub-ui/src/authConfig.ts create mode 100644 calm-hub-ui/src/components/user-menu/UserMenu.test.tsx create mode 100644 calm-hub-ui/src/components/user-menu/UserMenu.tsx create mode 100644 calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx create mode 100644 calm-hub-ui/src/model/layout.test.ts create mode 100644 calm-hub/src/main/java/org/finos/calm/cache/CaffeineCacheService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/cache/CalmCacheService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/config/DatabaseMode.java create mode 100644 calm-hub/src/main/java/org/finos/calm/config/StandaloneDemoSeeder.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/GitHubWriteNotSupportedException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/migration/steps/MongoLayoutFormatMigrationStep.java create mode 100644 calm-hub/src/main/java/org/finos/calm/migration/steps/NitriteLayoutFormatMigrationStep.java create mode 100644 calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/AuthConfigResource.java create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/PluginAuthResource.java create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/UnsupportedOperationExceptionMapper.java create mode 100644 calm-hub/src/main/java/org/finos/calm/security/OidcPluginAuthClient.java create mode 100644 calm-hub/src/main/java/org/finos/calm/security/OidcRoleResolver.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/classpath/ClasspathCoreSchemaStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubAuditLogStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubLayoutStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/noop/NoOpResourceMappingStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/noop/NoOpSchemaVersionStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java create mode 100644 calm-hub/src/main/resources/application-oidc.properties create mode 100644 calm-hub/src/test/java/org/finos/calm/cache/TestCaffeineCacheServiceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/migration/steps/TestMongoLayoutFormatMigrationStepShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/migration/steps/TestNitriteLayoutFormatMigrationStepShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestAuthConfigResourceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestPluginAuthResourceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestUnsupportedOperationExceptionMapperShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestOidcPluginAuthClientShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAuditLogStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubLayoutStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpResourceMappingStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpSchemaVersionStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java create mode 100644 calm-hub/src/test/resources/META-INF/calm-schemas/1.0/files.txt create mode 100644 calm-hub/src/test/resources/META-INF/calm-schemas/1.0/meta/core.json create mode 100644 calm-hub/src/test/resources/META-INF/calm-schemas/versions.txt diff --git a/calm-hub-ui/src/ProtectedRoute.test.tsx b/calm-hub-ui/src/ProtectedRoute.test.tsx new file mode 100644 index 000000000..07d2fcc0e --- /dev/null +++ b/calm-hub-ui/src/ProtectedRoute.test.tsx @@ -0,0 +1,182 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./authService.js', () => ({ + authService: { + getUser: vi.fn(), + login: vi.fn(), + processRedirect: vi.fn(), + }, +})); + + +import ProtectedRoute from './ProtectedRoute.js'; +import { authService } from './authService.js'; + +const PRE_AUTH_HASH_KEY = 'calm_pre_auth_hash'; + +const fakeUser = { + expired: false, + id_token: 'test-id-token', + access_token: 'test-access-token', + profile: { preferred_username: 'testuser' }, +} as unknown as import('oidc-client-ts').User; + +describe('ProtectedRoute', () => { + let replaceStateSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + replaceStateSpy = vi.spyOn(window.history, 'replaceState'); + }); + + afterEach(() => { + replaceStateSpy.mockRestore(); + Object.defineProperty(window, 'location', { + value: window.location, + writable: true, + }); + }); + + describe('hash preservation before OIDC redirect', () => { + it('saves window.location.hash to sessionStorage before calling login', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.login).mockResolvedValue(undefined); + Object.defineProperty(window, 'location', { + value: { ...window.location, hash: '#/fae-calm/architectures/123/abc', search: '' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(authService.login).toHaveBeenCalled(); + }); + expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBe('#/fae-calm/architectures/123/abc'); + }); + + it('does not save an empty hash to sessionStorage', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.login).mockResolvedValue(undefined); + Object.defineProperty(window, 'location', { + value: { ...window.location, hash: '', search: '' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(authService.login).toHaveBeenCalled(); + }); + expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBeNull(); + }); + }); + + describe('hash restoration after OIDC callback', () => { + it('restores the saved hash after processing the redirect callback', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + sessionStorage.setItem(PRE_AUTH_HASH_KEY, '#/fae-calm/architectures/123/abc'); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + '', + '/#/fae-calm/architectures/123/abc' + ); + }); + expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBeNull(); + }); + + it('falls back to #/ when no hash was saved', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/#/'); + }); + }); + + it('falls back to #/ when saved hash is just #', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + sessionStorage.setItem(PRE_AUTH_HASH_KEY, '#'); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/#/'); + }); + }); + + it('renders children after successful redirect processing', async () => { + vi.mocked(authService.getUser).mockResolvedValue(null); + vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser); + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' }, + writable: true, + }); + + render( + +
Protected Content
+
+ ); + + expect(await screen.findByText('Protected Content')).toBeInTheDocument(); + }); + }); + + describe('already authenticated user', () => { + it('renders children immediately without redirect when session exists', async () => { + vi.mocked(authService.getUser).mockResolvedValue(fakeUser); + + render( + +
Protected Content
+
+ ); + + expect(await screen.findByText('Protected Content')).toBeInTheDocument(); + expect(authService.login).not.toHaveBeenCalled(); + expect(authService.processRedirect).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/calm-hub-ui/src/ProtectedRoute.tsx b/calm-hub-ui/src/ProtectedRoute.tsx index 64ff61be7..035f26032 100644 --- a/calm-hub-ui/src/ProtectedRoute.tsx +++ b/calm-hub-ui/src/ProtectedRoute.tsx @@ -6,6 +6,8 @@ interface ProtectedRouteProps { children: ReactNode; } +const PRE_AUTH_HASH_KEY = 'calm_pre_auth_hash'; + const ProtectedRoute: React.FC = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -17,8 +19,18 @@ const ProtectedRoute: React.FC = ({ children }) => { setUser(currentUser); } else if (window.location.search.includes('code=')) { const loggedInUser = await authService.processRedirect(); + const savedHash = sessionStorage.getItem(PRE_AUTH_HASH_KEY); + sessionStorage.removeItem(PRE_AUTH_HASH_KEY); + if (savedHash && savedHash !== '#' && savedHash !== '#/') { + window.history.replaceState(null, '', window.location.pathname + savedHash); + } else { + window.history.replaceState(null, '', window.location.pathname + '#/'); + } setUser(loggedInUser); } else { + if (window.location.hash) { + sessionStorage.setItem(PRE_AUTH_HASH_KEY, window.location.hash); + } await authService.login(); } setLoading(false); @@ -34,6 +46,7 @@ const ProtectedRoute: React.FC = ({ children }) => { if (!user) { return
Redirecting to login...
; } + return <>{children}; }; export default ProtectedRoute; diff --git a/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx b/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx index cf361038e..ee2cae5c6 100644 --- a/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx +++ b/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx @@ -192,7 +192,9 @@ describe('EntitlementsPanel', () => { await waitFor(() => expect(screen.getByRole('region', { name: /global admin access/i })).toBeInTheDocument() ); - expect(userAccessSvc.getNamespaceUserAccess).toHaveBeenCalledWith('GLOBAL'); + await waitFor(() => + expect(userAccessSvc.getNamespaceUserAccess).toHaveBeenCalledWith('GLOBAL') + ); }); }); diff --git a/calm-hub-ui/src/authConfig.test.ts b/calm-hub-ui/src/authConfig.test.ts new file mode 100644 index 000000000..729d151d8 --- /dev/null +++ b/calm-hub-ui/src/authConfig.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import axios from 'axios'; +import { getAuthConfig, isOidcEnabled, isGitHubMode } from './authConfig.js'; + +vi.mock('axios'); + +describe('authConfig', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('fetchAuthConfig', () => { + it('should fetch config from backend', async () => { + const mockConfig = { + oidc: { enabled: true, provider: 'entra-id', authority: 'https://login.microsoft.com/tenant', clientId: 'client-123', scopes: ['openid', 'profile'] }, + databaseMode: 'github', + }; + vi.mocked(axios.get).mockResolvedValue({ data: mockConfig }); + + const { fetchAuthConfig: fetch } = await import('./authConfig.js'); + const result = await fetch(); + + expect(result.oidc.enabled).toBe(true); + expect(result.oidc.provider).toBe('entra-id'); + expect(result.databaseMode).toBe('github'); + }); + + it('should retry once and succeed on transient failure', async () => { + const mockConfig = { + oidc: { enabled: true, provider: 'generic-oidc' }, + databaseMode: 'mongo', + }; + vi.mocked(axios.get) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ data: mockConfig }); + + const { fetchAuthConfig: fetch } = await import('./authConfig.js'); + const result = await fetch(); + + expect(result.oidc.enabled).toBe(true); + }); + + it('should throw when both attempts fail', async () => { + vi.mocked(axios.get).mockRejectedValue(new Error('Network error')); + + const { fetchAuthConfig: fetch } = await import('./authConfig.js'); + + await expect(fetch()).rejects.toThrow('Unable to load authentication configuration'); + }); + }); + + describe('getAuthConfig', () => { + it('should return default config before fetch', () => { + const config = getAuthConfig(); + expect(config.oidc.enabled).toBe(false); + }); + }); + + describe('isOidcEnabled', () => { + it('should return false when not fetched', () => { + expect(isOidcEnabled()).toBe(false); + }); + }); + + describe('isGitHubMode', () => { + it('should return false when not fetched', () => { + expect(isGitHubMode()).toBe(false); + }); + }); +}); diff --git a/calm-hub-ui/src/authConfig.ts b/calm-hub-ui/src/authConfig.ts new file mode 100644 index 000000000..c7b20d24c --- /dev/null +++ b/calm-hub-ui/src/authConfig.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; + +export interface AuthConfig { + oidc: { + enabled: boolean; + provider?: string; + authority?: string; + clientId?: string; + scopes?: string[]; + redirectUri?: string; + }; + databaseMode: string; +} + +const DEFAULT_CONFIG: AuthConfig = { + oidc: { enabled: false }, + databaseMode: 'mongo', +}; + +let cachedConfig: AuthConfig | null = null; + +export async function fetchAuthConfig(): Promise { + if (cachedConfig) { + return cachedConfig; + } + const attempt = async (): Promise => { + const response = await axios.get('/api/calm/auth/config'); + return response.data; + }; + try { + cachedConfig = await attempt(); + return cachedConfig; + } catch { + // Retry once after a short delay before giving up + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + cachedConfig = await attempt(); + return cachedConfig; + } catch (retryError) { + console.error('Failed to fetch auth config after retry:', retryError); + throw new Error('Unable to load authentication configuration'); + } + } +} + +export function getAuthConfig(): AuthConfig { + return cachedConfig || DEFAULT_CONFIG; +} + +export function isOidcEnabled(): boolean { + return cachedConfig?.oidc?.enabled ?? false; +} + +export function isGitHubMode(): boolean { + return cachedConfig?.databaseMode === 'github'; +} diff --git a/calm-hub-ui/src/authService.test.tsx b/calm-hub-ui/src/authService.test.tsx index e9fe517ad..2d7b11dc2 100644 --- a/calm-hub-ui/src/authService.test.tsx +++ b/calm-hub-ui/src/authService.test.tsx @@ -6,6 +6,7 @@ import { getAuthHeaders, isAuthServiceEnabled, } from './authService.js'; +import * as authConfig from './authConfig.js'; vi.mock('axios'); @@ -16,6 +17,11 @@ describe('authService', () => { describe('checkAuthorityService', () => { it('should return true when the authority service responds successfully', async () => { + vi.spyOn(authConfig, 'getAuthConfig').mockReturnValue({ + oidc: { enabled: true, authority: 'https://auth.example.com' }, + github: { enabled: false }, + databaseMode: 'mongo', + }); vi.mocked(axios.head).mockResolvedValue({ status: 200 }); const result = await checkAuthorityService(); expect(result).toBe(true); diff --git a/calm-hub-ui/src/authService.tsx b/calm-hub-ui/src/authService.tsx index 8e5457777..517edc8e3 100644 --- a/calm-hub-ui/src/authService.tsx +++ b/calm-hub-ui/src/authService.tsx @@ -1,35 +1,40 @@ -import { UserManager, Log, User } from 'oidc-client-ts'; +import { UserManager, User } from 'oidc-client-ts'; import axios from 'axios'; +import { fetchAuthConfig, getAuthConfig } from './authConfig.js'; -const config = { - authority: 'https://calm-hub.finos.org:9443/realms/calm-hub-realm', - client_id: 'calm-hub-authz-code', - redirect_uri: window.location.origin, - response_type: 'code', - scope: 'openid profile architectures:read adrs:all', - post_logout_redirect_uri: window.location.origin, - automaticSilentRenew: true, - filterProtocolClaims: true, - loadUserInfo: true, -}; - -//Set AUTH_SERVICE_OIDC_ENABLE to true only when the backend is running with a secure profile and is NOT behind an ADC/Reverse-Proxy that handles user authentication. -export const AUTH_SERVICE_OIDC_ENABLE: boolean = false; let userManager: UserManager | null = null; +let initialized = false; export function isAuthServiceEnabled(): boolean { - const oidcEnabled = AUTH_SERVICE_OIDC_ENABLE; - const isHttps = - typeof window !== 'undefined' && - typeof window.location !== 'undefined' && - window.location.protocol === 'https:'; - return (oidcEnabled && isHttps); + const config = getAuthConfig(); + return config.oidc.enabled; } -if (isAuthServiceEnabled()) { - userManager = new UserManager(config); - Log.setLogger(console); - Log.setLevel(Log.INFO); +export async function initAuthService(): Promise { + if (initialized) return; + + const config = await fetchAuthConfig(); + if (!config.oidc.enabled) { + initialized = true; + return; + } + + const oidcConfig = { + authority: config.oidc.authority || '', + client_id: config.oidc.clientId || '', + redirect_uri: config.oidc.redirectUri + ? new URL(config.oidc.redirectUri, window.location.origin).toString() + : window.location.origin, + response_type: 'code', + scope: config.oidc.scopes?.join(' ') || 'openid profile email', + post_logout_redirect_uri: window.location.origin, + automaticSilentRenew: true, + filterProtocolClaims: true, + loadUserInfo: true, + }; + + userManager = new UserManager(oidcConfig); + initialized = true; } export async function getUser(): Promise { @@ -61,25 +66,26 @@ export async function logout(): Promise { export async function clearSession(): Promise { try { await userManager?.removeUser(); - console.log('Session cleared successfully.'); } catch (error) { console.error('Error clearing session:', error); } } export async function getToken(): Promise { - if (!AUTH_SERVICE_OIDC_ENABLE) { + if (!userManager) { return ''; } - const user = await userManager?.getUser(); + const user = await userManager.getUser(); if (user && !user.expired) { - return user.access_token; + // Entra ID: access_token audience is MS Graph, not our API. + // Send the id_token which has our client_id as audience. + return user.id_token || user.access_token; } if (user && user.expired) { try { - const refreshedUser = await userManager?.signinSilent(); - return refreshedUser?.access_token || ''; + const refreshedUser = await userManager.signinSilent(); + return refreshedUser?.id_token || refreshedUser?.access_token || ''; } catch (error) { console.error('Error refreshing token:', error); return ''; @@ -100,8 +106,12 @@ export async function getAuthHeaders(): Promise { } export async function checkAuthorityService(): Promise { + const config = getAuthConfig(); + if (!config.oidc.enabled || !config.oidc.authority) { + return false; + } try { - await axios.head(config.authority); + await axios.head(config.oidc.authority); return true; } catch (error) { console.error('Authority Service Check Error:', error); @@ -118,4 +128,5 @@ export const authService = { getToken, getAuthHeaders, isAuthServiceEnabled, + initAuthService, }; diff --git a/calm-hub-ui/src/components/navbar/Navbar.tsx b/calm-hub-ui/src/components/navbar/Navbar.tsx index bbacb1c79..541878a5e 100644 --- a/calm-hub-ui/src/components/navbar/Navbar.tsx +++ b/calm-hub-ui/src/components/navbar/Navbar.tsx @@ -6,6 +6,8 @@ import { GlobalSearchBar } from './GlobalSearchBar.js'; import { ThemeToggle } from './ThemeToggle.js'; import { useTheme } from '../../theme/useTheme.js'; import { UserAccessContext } from '../../admin/context/UserAccessContext.js'; +import { UserMenu } from '../user-menu/UserMenu.js'; +import { isAuthServiceEnabled } from '../../authService.js'; /** * The navy-and-blue lockup is unreadable on a dark base, so dark gets a white one. @@ -58,6 +60,7 @@ export function Navbar({ storage }: NavbarProps = {}) {
+ {isAuthServiceEnabled() && } {/* Backdrop — dims the page behind the drawer; tap to close */} diff --git a/calm-hub-ui/src/components/user-menu/UserMenu.test.tsx b/calm-hub-ui/src/components/user-menu/UserMenu.test.tsx new file mode 100644 index 000000000..887a86338 --- /dev/null +++ b/calm-hub-ui/src/components/user-menu/UserMenu.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { UserMenu } from './UserMenu.js'; + +vi.mock('../../authService.js', () => ({ + getUser: vi.fn().mockResolvedValue({ + profile: { + name: 'Shivaji Byrapaneni', + preferred_username: 'shivaji.byrapaneni@fmr.com', + email: 'shivaji.byrapaneni@fmr.com', + }, + }), + authService: { + logout: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe('UserMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should show user initial and name when loaded', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('S')).toBeDefined(); + expect(screen.getByText('Shivaji Byrapaneni')).toBeDefined(); + }); + }); + + it('should show dropdown with email when clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Shivaji Byrapaneni')).toBeDefined(); + }); + + fireEvent.click(screen.getByLabelText('User menu')); + + expect(screen.getByText('shivaji.byrapaneni@fmr.com')).toBeDefined(); + expect(screen.getByText('Sign out')).toBeDefined(); + }); + + it('should render nothing when no user', async () => { + const { getUser } = await import('../../authService.js'); + vi.mocked(getUser).mockResolvedValue(null); + + const { container } = render(); + + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/calm-hub-ui/src/components/user-menu/UserMenu.tsx b/calm-hub-ui/src/components/user-menu/UserMenu.tsx new file mode 100644 index 000000000..1da082a41 --- /dev/null +++ b/calm-hub-ui/src/components/user-menu/UserMenu.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useState } from 'react'; +import { authService, getUser } from '../../authService.js'; +import { User } from 'oidc-client-ts'; + +export const UserMenu: React.FC = () => { + const [user, setUser] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const loadUser = async () => { + const currentUser = await getUser(); + setUser(currentUser); + }; + loadUser(); + }, []); + + const handleLogout = async () => { + await authService.logout(); + }; + + const displayName = user?.profile?.name || user?.profile?.preferred_username || 'User'; + const email = user?.profile?.email || ''; + + if (!user) { + return null; + } + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+

{displayName}

+ {email &&

{email}

} +
+
+ +
+
+ + )} +
+ ); +}; diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index a68f25961..ae8ee6ede 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -465,6 +465,7 @@ export default function Hub() { setIsSidebarOpen(false)} /> ) : ( @@ -498,6 +499,7 @@ export default function Hub() { setIsMobileNavOpen(false)} />
diff --git a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx index 0abcc03cf..355da682f 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -87,7 +87,7 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel, breadcrumb [nodeSearchTerm, nodeTypeFilter, nodeTypes] ); const calmService = useMemo(() => new CalmService(), []); - const defaultLayoutState = useDefaultLayout(data.name, data.id, data.calmType); + const defaultLayoutState = useDefaultLayout(data.name, data.id, data.calmType, data.data as Record | undefined); // Destructured locals so handleSaveLayout/handleResetLayout below can depend // on exactly the (already useCallback-stable) functions they call, rather // than the whole result object — which still changes identity whenever @@ -428,6 +428,26 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel, breadcrumb const showLayoutActions = !comparing && activeTab === 'diagram'; const layoutActions = showLayoutActions && (
+ {defaultLayoutState.hasBothSources && ( +
+ + +
+ )} {canSaveLayout && (
diff --git a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx index c20af66e3..a002fca8c 100644 --- a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx +++ b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx @@ -14,7 +14,7 @@ const domainCounts: DomainControlCount[] = [ { domain: 'compliance', controlCount: 0 }, ]; -const renderRail = (path = '/', onCollapse?: () => void) => +const renderRail = (path = '/', onCollapse?: () => void, loading?: boolean) => render( @@ -26,6 +26,7 @@ const renderRail = (path = '/', onCollapse?: () => void) => } @@ -91,4 +92,21 @@ describe('ExploreRail', () => { expect(onCollapse).toHaveBeenCalled(); await screen.findByRole('link', { name: /finos/ }); }); + + it('shows loading spinners instead of items when loading is true', () => { + renderRail('/', undefined, true); + const spinners = screen.getAllByClassName + ? document.querySelectorAll('.loading-spinner') + : screen.getByText('NAMESPACES').parentElement!.querySelectorAll('.loading-spinner'); + expect(spinners.length).toBeGreaterThanOrEqual(2); + expect(screen.queryByRole('link', { name: /finos/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /security/ })).not.toBeInTheDocument(); + }); + + it('shows items instead of spinners when loading is false', async () => { + renderRail('/', undefined, false); + expect(await screen.findByRole('link', { name: /finos/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /security/ })).toBeInTheDocument(); + expect(document.querySelectorAll('.loading-spinner').length).toBe(0); + }); }); diff --git a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx index 800333aa5..769bfe562 100644 --- a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx +++ b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx @@ -12,6 +12,8 @@ interface ExploreRailProps { namespaceCounts: NamespaceCounts[]; /** Per-domain control counts, fetched once by {@link Hub} and passed down. */ domainCounts: DomainControlCount[]; + /** True while the counts are still being fetched from the backend. */ + loading?: boolean; /** Collapse the rail (keeps the existing sidebar collapse affordance). */ onCollapse?: () => void; } @@ -28,7 +30,7 @@ type RailRouteParams = { ns?: string; domain?: string; namespace?: string }; * once there and shared), so this component takes them as props rather than * re-fetching them itself. */ -export function ExploreRail({ namespaceCounts, domainCounts, onCollapse }: ExploreRailProps) { +export function ExploreRail({ namespaceCounts, domainCounts, loading, onCollapse }: ExploreRailProps) { // `ns` comes from /namespace/:ns; on the detail route /:namespace/:type/:id/:version the // param is `namespace`. Fall back to it so the rail keeps its highlight during a detail session. const { ns, domain: activeDomain, namespace } = useParams(); @@ -82,28 +84,40 @@ export function ExploreRail({ namespaceCounts, domainCounts, onCollapse }: Explo
NAMESPACES
- {filteredNamespaces.map((nc) => ( - - ))} + {loading ? ( +
+ +
+ ) : ( + filteredNamespaces.map((nc) => ( + + )) + )}
CONTROL DOMAINS
- {domainCounts.map((dc) => ( - - ))} + {loading ? ( +
+ +
+ ) : ( + domainCounts.map((dc) => ( + + )) + )}
diff --git a/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx b/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx index 90c00597a..7b819a4b6 100644 --- a/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx +++ b/calm-hub-ui/src/hub/components/intro-screen/IntroScreen.tsx @@ -1,6 +1,8 @@ import { NamespaceCounts, DomainControlCount } from '../../../model/counts.js'; import { useTheme } from '../../../theme/useTheme.js'; import { ThemeToggle } from '../../../components/navbar/ThemeToggle.js'; +import { UserMenu } from '../../../components/user-menu/UserMenu.js'; +import { isAuthServiceEnabled } from '../../../authService.js'; import { IntroSearchBar } from './IntroSearchBar.js'; import { IntroBrowse } from './IntroBrowse.js'; @@ -27,8 +29,9 @@ export function IntroScreen({ namespaceCounts, domainCounts, storage }: IntroScr return (
-
+
+ {isAuthServiceEnabled() && }
diff --git a/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx b/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx index 97aaf9583..b547fb915 100644 --- a/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx +++ b/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx @@ -95,9 +95,11 @@ export function ItemCard({ const chip = meta !== undefined ? meta - : versionCount !== undefined + : versionCount !== undefined && versionCount > 0 ? `${versionCount} ${versionCount === 1 ? 'version' : 'versions'}` - : customId; + : versionCount === 0 + ? customId || undefined + : customId; return (
) { + return ; +} + describe('SectionHeader', () => { it('renders icon, namespace, id, and version', () => { const icon = Icon; diff --git a/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx b/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx index b08325b7a..d4c667c9c 100644 --- a/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx +++ b/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx @@ -1,4 +1,5 @@ import { ReactNode, useState } from 'react'; +import { Link } from 'react-router-dom'; import { IoCopyOutline, IoCheckmarkOutline, IoLinkOutline } from 'react-icons/io5'; import { BreadcrumbItem, isSlug } from '../../../model/calm.js'; import { BreadcrumbTrail } from './BreadcrumbTrail.js'; @@ -45,11 +46,12 @@ export function SectionHeader({ icon, namespace, id, version, typeSegment, right

{icon} {breadcrumbs && } - {namespace} + {namespace} {typeLabel && ( <> {' '} - / {typeLabel} + /{' '} + {typeLabel} )}{' '} /{' '} diff --git a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx index 518362bb6..05d7be2b5 100644 --- a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx @@ -185,4 +185,26 @@ describe('MobileNavMenu', () => { expect(await screen.findByText('traderx')).toBeInTheDocument(); expect(screen.queryByText('Architectures')).not.toBeInTheDocument(); }); + + it('shows a spinner at the root level when countsLoading is true', () => { + render( + + + + ); + const spinner = document.querySelector('.loading-spinner'); + expect(spinner).toBeInTheDocument(); + expect(screen.queryByText('Namespaces')).not.toBeInTheDocument(); + }); + + it('shows rows at the root level when countsLoading is false', () => { + render( + + + + ); + expect(document.querySelector('.loading-spinner')).not.toBeInTheDocument(); + expect(screen.getByText('Namespaces')).toBeInTheDocument(); + expect(screen.getByText('Control Domains')).toBeInTheDocument(); + }); }); diff --git a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx index 821dbbec2..ebc8c2325 100644 --- a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx @@ -27,6 +27,8 @@ interface MobileNavMenuProps { namespaceCounts: NamespaceCounts[]; /** Per-domain control counts, fetched once by {@link Hub} and passed down. */ domainCounts: DomainControlCount[]; + /** True while the counts are still being fetched from the backend. */ + countsLoading?: boolean; /** Dismiss the menu (e.g. after a resource is chosen). */ onClose: () => void; } @@ -66,7 +68,7 @@ interface LeafItem { * {@link Hub} (fetched once and shared) and passed in as props rather than * re-fetched here. */ -export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: MobileNavMenuProps) { +export function MobileNavMenu({ namespaceCounts, domainCounts, countsLoading, onClose }: MobileNavMenuProps) { const navigate = useNavigate(); const params = useParams(); @@ -287,7 +289,8 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile } })(); - const isEmpty = !loading && rows.length === 0; + const showLoading = loading || (countsLoading && (view.level === 'root' || view.level === 'namespaces' || view.level === 'domains')); + const isEmpty = !showLoading && rows.length === 0; return (
@@ -309,7 +312,7 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile {!searching && (
    - {loading && ( + {showLoading && (
  • @@ -317,7 +320,7 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile {isEmpty && (
  • Nothing here
  • )} - {!loading && + {!showLoading && rows.map((row) => (