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/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/timeline/Sparkline.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx index af69eaa55..82b63e2ee 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx @@ -151,12 +151,13 @@ export function Sparkline({ {/* Track row. A single-version resource has nothing to scrub, so the track is suppressed (the version pill already states what's shown). - overflow-hidden clips long labels at the track edge (#2728). */} + Individual labels are bounded by maxWidth + ellipsis (#2728); the + track itself uses overflow-visible so the last label isn't clipped. */} {!singleVersion && (
{/* Inner track wrapper inset 10px each side so dot percentages map directly */}
diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx index 360e83d3e..9f962268d 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx @@ -257,13 +257,12 @@ describe('TimelineBar', () => { // #2728 — long moment names must not block the expand control or clip cards. describe('long moment names are bounded (#2728)', () => { - it('clips the collapsed sparkline track so labels cannot paint over the expand button', () => { + it('does not clip the sparkline track so edge labels remain fully visible', () => { renderBar(); - // The centre track is clipped so an overlong label can never overflow - // out to cover the statically-positioned expand button. - expect(screen.getByTestId('timeline-sparkline-track')).toHaveStyle({ - overflow: 'hidden', - }); + // Per-label maxWidth + ellipsis bounds individual labels (#2728); + // the track itself must NOT clip so the last label is not cut off. + const track = screen.getByTestId('timeline-sparkline-track'); + expect(track).not.toHaveStyle({ overflow: 'hidden' }); }); it('truncates each collapsed label with an ellipsis while keeping its full-name tooltip', () => { diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx new file mode 100644 index 000000000..ed2755037 --- /dev/null +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { TimelineHeader } from './TimelineHeader.js'; + +describe('TimelineHeader', () => { + it('prepends "v" for semver versions', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveTextContent('v1.5.0'); + }); + + it('does not prepend "v" for commit SHAs', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveTextContent('cb7686e'); + expect(pill.textContent).not.toMatch(/^v/); + }); + + it('does not prepend "v" for full-length commit SHAs', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill.textContent).not.toMatch(/^v/); + }); + + it('prepends "v" for versions with non-hex characters', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveTextContent('v2.0.0-beta'); + }); + + it('sets the title attribute with the raw version', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveAttribute('title', 'Viewing version cb7686e'); + }); +}); diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx index d851da2bb..a220bbe85 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx @@ -18,7 +18,10 @@ interface TimelineHeaderProps { * explanatory copy makes it obvious that clicking a moment re-renders both the * Diagram and JSON views. */ +const isCommitSha = (v: string) => /^[0-9a-f]{5,40}$/.test(v); + export function TimelineHeader({ currentVersion, children }: TimelineHeaderProps) { + const displayVersion = isCommitSha(currentVersion) ? currentVersion : `v${currentVersion}`; return (
@@ -46,7 +49,7 @@ export function TimelineHeader({ currentVersion, children }: TimelineHeaderProps }} title={`Viewing version ${currentVersion}`} > - v{currentVersion} + {displayVersion} {children}
diff --git a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx index cf8035ca7..565ed91c1 100644 --- a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx +++ b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx @@ -211,4 +211,58 @@ describe('DocumentDetailSection', () => { expect(mockFetchVersionsByCustomId).toHaveBeenCalledWith('test-ns', 'my-payment-standard', 'Standards'); expect(mockFetchStandardVersions).not.toHaveBeenCalled(); }); + + it('renders markdown content when data is a markdown string', () => { + const data: Data = { + id: 'std-123', + version: 'latest', + name: 'test-ns', + calmType: 'Standards', + data: '# TLS Policy\n\nAll services must use TLS 1.2+.', + }; + + render( + + + + ); + + expect(screen.getByText('All services must use TLS 1.2+.')).toBeInTheDocument(); + }); + + it('shows display name from markdown heading in breadcrumb', () => { + const data: Data = { + id: '12345', + version: 'latest', + name: 'test-ns', + calmType: 'Standards', + data: '# My Standard Name\n\nContent.', + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toContain('My Standard Name'); + }); + + it('shows type label in breadcrumb', () => { + const data: Data = { + id: 'std-1', + version: 'latest', + name: 'fae-calm', + calmType: 'Standards', + data: '# Test\n\nBody.', + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toContain('Standards'); + }); }); diff --git a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx index 59ceae1cd..7ec535fc9 100644 --- a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx +++ b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { IoGridOutline, IoGitNetworkOutline } from 'react-icons/io5'; +import Markdown from 'react-markdown'; import { Data, isSlug } from '../../../model/calm.js'; import { CalmService } from '../../../service/calm-service.js'; import { sortVersionsDescending } from '../../../model/version.js'; @@ -11,6 +12,24 @@ interface DocumentDetailSectionProps { data?: Data; } +function getDisplayName(data: Data): string { + if (typeof data.data === 'string') { + const content = data.data as string; + const headingMatch = content.match(/^#\s+(.+)$/m); + if (headingMatch) return headingMatch[1]; + } + if (typeof data.data === 'object' && data.data && 'name' in (data.data as object)) { + return String((data.data as Record).name); + } + return data.id; +} + +function isMarkdownContent(data: Data): boolean { + if (typeof data.data !== 'string') return false; + const content = data.data as string; + return content.startsWith('#') || content.startsWith('---') || !content.startsWith('{'); +} + function calmTypeToUrlSegment(calmType: string): string { switch (calmType) { case 'Standards': return 'standards'; @@ -69,6 +88,8 @@ export function DocumentDetailSection({ data }: DocumentDetailSectionProps) { icon={getIcon()} namespace={data.name} id={data.id} + displayName={getDisplayName(data)} + typeLabel={data.calmType} version={data.version} typeSegment={calmTypeToUrlSegment(data.calmType)} versions={versions} @@ -76,7 +97,13 @@ export function DocumentDetailSection({ data }: DocumentDetailSectionProps) { />
- + {isMarkdownContent(data) ? ( +
+ {data.data as string} +
+ ) : ( + + )}
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) => (