From 60e2749c7b9b7285095764c998df70baf0ee9d03 Mon Sep 17 00:00:00 2001 From: Shivaji Byrapaneni Date: Mon, 17 Aug 2026 09:05:10 -0400 Subject: [PATCH 01/10] feat(calmhub): OIDC Support & Git Hub as Backend --- calm-hub-ui/src/ProtectedRoute.test.tsx | 188 +++++++++++++ calm-hub-ui/src/ProtectedRoute.tsx | 56 ++++ calm-hub-ui/src/authConfig.test.ts | 70 +++++ calm-hub-ui/src/authConfig.ts | 56 ++++ calm-hub-ui/src/authService.test.tsx | 6 + calm-hub-ui/src/authService.tsx | 75 ++--- .../github-link/GitHubLinkStatus.test.tsx | 61 ++++ .../github-link/GitHubLinkStatus.tsx | 56 ++++ 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 | 41 ++- .../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/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 +++++++++++++ .../exception/PendingWriteException.java | 32 +++ .../steps/MongoLayoutFormatMigrationStep.java | 108 +++++++ .../steps/NamespaceAccessBackfillStep.java | 2 + .../NitriteLayoutFormatMigrationStep.java | 111 ++++++++ .../calm/observability/GitHubMetrics.java | 85 ++++++ .../calm/resources/ArchitectureResource.java | 3 +- .../calm/resources/AuthConfigResource.java | 80 ++++++ .../calm/resources/GitHubLinkResource.java | 215 ++++++++++++++ .../PendingWriteExceptionMapper.java | 28 ++ .../ResourceValidationConstants.java | 1 + .../calm/resources/StandardResource.java | 2 +- .../UnsupportedOperationExceptionMapper.java | 20 ++ .../calm/security/GitHubRequestContext.java | 101 +++++++ .../security/GitHubSessionCookieService.java | 139 +++++++++ .../classpath/ClasspathCoreSchemaStore.java | 123 ++++++++ .../calm/store/github/GitHubAdrStore.java | 85 ++++++ .../store/github/GitHubArchitectureStore.java | 141 ++++++++++ .../store/github/GitHubAuditLogStore.java | 33 +++ .../calm/store/github/GitHubControlStore.java | 94 +++++++ .../store/github/GitHubDecoratorStore.java | 63 +++++ .../calm/store/github/GitHubDomainStore.java | 70 +++++ .../calm/store/github/GitHubFlowStore.java | 76 +++++ .../store/github/GitHubInterfaceStore.java | 67 +++++ .../calm/store/github/GitHubLayoutStore.java | 33 +++ .../store/github/GitHubNamespaceStore.java | 55 ++++ .../calm/store/github/GitHubPatternStore.java | 80 ++++++ .../calm/store/github/GitHubSearchStore.java | 70 +++++ .../store/github/GitHubStandardStore.java | 112 ++++++++ .../store/github/GitHubTimelineStore.java | 72 +++++ .../store/github/GitHubUserAccessStore.java | 113 ++++++++ .../github/util/CalmContentDetector.java | 135 +++++++++ .../store/github/util/CalmResourceType.java | 15 + .../store/github/util/GitHubCloneManager.java | 138 +++++++++ .../github/util/GitHubForkPRService.java | 52 ++++ .../store/github/util/GitHubRepoSync.java | 77 +++++ .../github/util/GitHubStartupInitializer.java | 72 +++++ .../store/github/util/GitHubStoreConfig.java | 51 ++++ .../github/util/GitHubSyncScheduler.java | 60 ++++ .../github/util/GitHubVersionService.java | 133 +++++++++ .../github/util/InMemoryRegistryService.java | 190 +++++++++++++ .../calm/store/github/util/RegistryEntry.java | 12 + .../store/github/util/RegistrySnapshot.java | 34 +++ .../store/noop/NoOpResourceMappingStore.java | 55 ++++ .../store/noop/NoOpSchemaVersionStore.java | 35 +++ .../calm/store/producer/AdrStoreProducer.java | 22 +- .../producer/ArchitectureStoreProducer.java | 20 +- .../store/producer/AuditLogStoreProducer.java | 18 +- .../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 | 66 +++++ .../src/main/resources/application.properties | 26 +- .../cache/TestCaffeineCacheServiceShould.java | 147 ++++++++++ .../TestPendingWriteExceptionShould.java | 38 +++ ...tMongoLayoutFormatMigrationStepShould.java | 165 +++++++++++ ...itriteLayoutFormatMigrationStepShould.java | 137 +++++++++ .../TestGitHubMetricsShould.java | 106 +++++++ .../TestAuthConfigResourceShould.java | 149 ++++++++++ ...TestPendingWriteExceptionMapperShould.java | 39 +++ ...pportedOperationExceptionMapperShould.java | 35 +++ .../TestGitHubRequestContextFilterShould.java | 164 +++++++++++ .../TestGitHubRequestContextShould.java | 32 +++ .../TestGitHubSessionCookieServiceShould.java | 144 ++++++++++ .../TestClasspathCoreSchemaStoreShould.java | 82 ++++++ .../github/TestGitHubAdrStoreShould.java | 115 ++++++++ .../TestGitHubArchitectureStoreShould.java | 220 +++++++++++++++ .../github/TestGitHubAuditLogStoreShould.java | 41 +++ .../github/TestGitHubControlStoreShould.java | 99 +++++++ .../TestGitHubDecoratorStoreShould.java | 114 ++++++++ .../github/TestGitHubDomainStoreShould.java | 93 ++++++ .../github/TestGitHubFlowStoreShould.java | 97 +++++++ .../TestGitHubInterfaceStoreShould.java | 81 ++++++ .../github/TestGitHubLayoutStoreShould.java | 46 +++ .../TestGitHubNamespaceStoreShould.java | 88 ++++++ .../github/TestGitHubPatternStoreShould.java | 98 +++++++ .../github/TestGitHubSearchStoreShould.java | 90 ++++++ .../github/TestGitHubStandardStoreShould.java | 171 +++++++++++ .../github/TestGitHubTimelineStoreShould.java | 88 ++++++ .../TestGitHubUserAccessStoreShould.java | 161 +++++++++++ .../util/TestCalmContentDetectorShould.java | 247 ++++++++++++++++ .../util/TestGitHubCloneManagerShould.java | 152 ++++++++++ .../util/TestGitHubForkPRServiceShould.java | 32 +++ .../github/util/TestGitHubRepoSyncShould.java | 125 +++++++++ .../TestGitHubStartupInitializerShould.java | 85 ++++++ .../util/TestGitHubStoreConfigShould.java | 48 ++++ .../util/TestGitHubSyncSchedulerShould.java | 70 +++++ .../util/TestGitHubVersionServiceShould.java | 110 ++++++++ .../TestInMemoryRegistryServiceShould.java | 265 ++++++++++++++++++ .../TestNoOpResourceMappingStoreShould.java | 70 +++++ .../TestNoOpSchemaVersionStoreShould.java | 46 +++ .../producer/TestAdrStoreProducerShould.java | 22 +- .../TestArchitectureStoreProducerShould.java | 22 +- .../TestAuditLogStoreProducerShould.java | 19 ++ .../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 ++- 172 files changed, 9852 insertions(+), 416 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/github-link/GitHubLinkStatus.test.tsx create mode 100644 calm-hub-ui/src/components/github-link/GitHubLinkStatus.tsx 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/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/GitHubLinkResource.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/UnsupportedOperationExceptionMapper.java create mode 100644 calm-hub/src/main/java/org/finos/calm/security/GitHubRequestContext.java create mode 100644 calm-hub/src/main/java/org/finos/calm/security/GitHubSessionCookieService.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/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/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/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/TestUnsupportedOperationExceptionMapperShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestGitHubRequestContextFilterShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestGitHubRequestContextShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestGitHubSessionCookieServiceShould.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/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/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 diff --git a/calm-hub-ui/src/ProtectedRoute.test.tsx b/calm-hub-ui/src/ProtectedRoute.test.tsx new file mode 100644 index 000000000..1d70a5cf8 --- /dev/null +++ b/calm-hub-ui/src/ProtectedRoute.test.tsx @@ -0,0 +1,188 @@ +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(), + }, +})); + +vi.mock('./authConfig.js', () => ({ + fetchAuthConfig: vi.fn().mockResolvedValue({ oidc: { enabled: false }, github: { enabled: false } }), + isGitHubLinkingEnabled: vi.fn().mockReturnValue(false), +})); + +vi.mock('axios'); + +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..0269cc1e4 100644 --- a/calm-hub-ui/src/ProtectedRoute.tsx +++ b/calm-hub-ui/src/ProtectedRoute.tsx @@ -1,14 +1,19 @@ import React, { ReactNode, useEffect, useState } from 'react'; import { User } from 'oidc-client-ts'; import { authService } from './authService.js'; +import { fetchAuthConfig, isGitHubLinkingEnabled } from './authConfig.js'; +import axios from 'axios'; 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); + const [githubLinked, setGithubLinked] = useState(null); useEffect(() => { const authenticate = async () => { @@ -17,8 +22,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); @@ -27,6 +42,42 @@ const ProtectedRoute: React.FC = ({ children }) => { authenticate(); }, []); + useEffect(() => { + if (!user) { + setGithubLinked(true); + return; + } + + const checkGithubLink = async () => { + await fetchAuthConfig(); + if (!isGitHubLinkingEnabled()) { + setGithubLinked(true); + return; + } + + try { + const token = user.id_token || user.access_token; + const response = await axios.get('/api/calm/github/status', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.data.linked) { + sessionStorage.removeItem('calm_gh_link_attempted'); + setGithubLinked(true); + } else if (sessionStorage.getItem('calm_gh_link_attempted')) { + setGithubLinked(true); + } else { + sessionStorage.setItem('calm_gh_link_attempted', 'true'); + const username = user.profile?.preferred_username || user.profile?.email || ''; + window.location.href = `/api/calm/github/link?user=${encodeURIComponent(username)}`; + } + } catch { + setGithubLinked(true); + } + }; + + checkGithubLink(); + }, [user]); + if (loading) { return
Loading...
; } @@ -34,6 +85,11 @@ const ProtectedRoute: React.FC = ({ children }) => { if (!user) { return
Redirecting to login...
; } + + if (githubLinked === null) { + return
Checking GitHub link...
; + } + return <>{children}; }; export default ProtectedRoute; diff --git a/calm-hub-ui/src/authConfig.test.ts b/calm-hub-ui/src/authConfig.test.ts new file mode 100644 index 000000000..9c8ace1c0 --- /dev/null +++ b/calm-hub-ui/src/authConfig.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import axios from 'axios'; +import { getAuthConfig, isOidcEnabled, isGitHubMode, isGitHubLinkingEnabled } 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'] }, + github: { enabled: true, oauthClientId: 'gh-client' }, + 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.github.enabled).toBe(true); + expect(result.databaseMode).toBe('github'); + }); + + it('should return defaults when fetch fails', async () => { + vi.mocked(axios.get).mockRejectedValue(new Error('Network error')); + + const { fetchAuthConfig: fetch } = await import('./authConfig.js'); + const result = await fetch(); + + expect(result.oidc.enabled).toBe(false); + expect(result.github.enabled).toBe(false); + expect(result.databaseMode).toBe('mongo'); + }); + }); + + 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); + }); + }); + + describe('isGitHubLinkingEnabled', () => { + it('should return false when not fetched', () => { + expect(isGitHubLinkingEnabled()).toBe(false); + }); + }); +}); diff --git a/calm-hub-ui/src/authConfig.ts b/calm-hub-ui/src/authConfig.ts new file mode 100644 index 000000000..153c8a438 --- /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; + }; + github: { + enabled: boolean; + oauthClientId?: string; + }; + databaseMode: string; +} + +const DEFAULT_CONFIG: AuthConfig = { + oidc: { enabled: false }, + github: { enabled: false }, + databaseMode: 'mongo', +}; + +let cachedConfig: AuthConfig | null = null; + +export async function fetchAuthConfig(): Promise { + if (cachedConfig) { + return cachedConfig; + } + try { + const response = await axios.get('/api/calm/auth/config'); + cachedConfig = response.data; + return cachedConfig; + } catch (error) { + console.warn('Failed to fetch auth config, using defaults:', error); + cachedConfig = DEFAULT_CONFIG; + return cachedConfig; + } +} + +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'; +} + +export function isGitHubLinkingEnabled(): boolean { + return cachedConfig?.github?.enabled ?? false; +} 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/github-link/GitHubLinkStatus.test.tsx b/calm-hub-ui/src/components/github-link/GitHubLinkStatus.test.tsx new file mode 100644 index 000000000..2448344e8 --- /dev/null +++ b/calm-hub-ui/src/components/github-link/GitHubLinkStatus.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { GitHubLinkStatus } from './GitHubLinkStatus.js'; +import axios from 'axios'; +import * as authConfig from '../../authConfig.js'; + +vi.mock('axios'); +vi.mock('../../authService.js', () => ({ + getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: 'Bearer token' }), +})); + +describe('GitHubLinkStatus', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should render nothing when github linking is not enabled', () => { + vi.spyOn(authConfig, 'isGitHubLinkingEnabled').mockReturnValue(false); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should show username when linked', async () => { + vi.spyOn(authConfig, 'isGitHubLinkingEnabled').mockReturnValue(true); + vi.mocked(axios.get).mockResolvedValue({ + data: { linked: true, username: 'alice-gh' }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub: alice-gh')).toBeDefined(); + }); + }); + + it('should show link button when not linked', async () => { + vi.spyOn(authConfig, 'isGitHubLinkingEnabled').mockReturnValue(true); + vi.mocked(axios.get).mockResolvedValue({ + data: { linked: false }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Link GitHub Account')).toBeDefined(); + }); + }); + + it('should handle error gracefully', async () => { + vi.spyOn(authConfig, 'isGitHubLinkingEnabled').mockReturnValue(true); + vi.mocked(axios.get).mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Link GitHub Account')).toBeDefined(); + }); + }); +}); diff --git a/calm-hub-ui/src/components/github-link/GitHubLinkStatus.tsx b/calm-hub-ui/src/components/github-link/GitHubLinkStatus.tsx new file mode 100644 index 000000000..25fb51e82 --- /dev/null +++ b/calm-hub-ui/src/components/github-link/GitHubLinkStatus.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { getAuthHeaders } from '../../authService.js'; +import { isGitHubLinkingEnabled } from '../../authConfig.js'; + +interface LinkStatus { + linked: boolean; + username?: string; +} + +export const GitHubLinkStatus: React.FC = () => { + const [status, setStatus] = useState({ linked: false }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isGitHubLinkingEnabled()) { + setLoading(false); + return; + } + + const checkStatus = async () => { + try { + const headers = await getAuthHeaders(); + const response = await axios.get('/api/calm/github/status', { headers }); + setStatus(response.data); + } catch (error) { + console.error('Failed to check GitHub link status:', error); + } finally { + setLoading(false); + } + }; + + checkStatus(); + }, []); + + if (!isGitHubLinkingEnabled() || loading) { + return null; + } + + if (status.linked) { + return ( +
+ GitHub: {status.username} +
+ ); + } + + return ( + + Link GitHub Account + + ); +}; 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) => (