diff --git a/.github/workflows/deploy-to-firebase.yml b/.github/workflows/deploy-to-firebase.yml index c89c0760..af0519f3 100644 --- a/.github/workflows/deploy-to-firebase.yml +++ b/.github/workflows/deploy-to-firebase.yml @@ -43,6 +43,11 @@ jobs: node-version: 24 cache: pnpm + - name: Disable strict dep builds check globally + run: | + pnpm config set strict-dep-builds false --global + echo "strict-dep-builds=false" >> ~/.npmrc + - name: Resolve DMV branch for preview id: dmv if: github.event_name == 'pull_request' diff --git a/.npmrc b/.npmrc index 4c608920..7efa432e 100644 --- a/.npmrc +++ b/.npmrc @@ -3,3 +3,7 @@ shamefully-hoist=true # react-scripts 5 peer dependency ranges are outdated. strict-peer-dependencies=false + +# pnpm 11+ requires explicit approval for build scripts from dependencies. +# Disable strict dependency build checking to avoid CI failures. +strict-dep-builds=false diff --git a/README.md b/README.md index b11f5634..740f2274 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,13 @@ _Slim_ also supports interactive visualization of image annotations and analysis **Raster graphics:** -- [DICOM Segmentation](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.51.html) instances that contain binary or fractional segmentation masks +- [DICOM Segmentation](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.51.html) instances that contain binary or fractional segmentation masks, including TILED_SPARSE segmentations at non-standard resolution levels (e.g., segmentations created from rescaled image patches that don't match any pyramid level) - [DICOM Parametric Map](https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_A.75.html) instances that contain saliency maps, attention maps, class activation maps, and similar derived images Fractional segmentations and parametric maps show an in-viewport color legend when at least one overlay is visible. The legend is collapsible and its per-item visibility toggles stay in sync with the switches in the right-hand panel. +Clicking on a segment label in the right-hand panel zooms the viewport to that segment's bounding box, providing quick navigation to regions of interest. + | | DICOM IOD | | :-: | :-------- | | IDC CPTAC Segmentation | Segmentation | @@ -162,6 +164,27 @@ Custom selections are stored in `localStorage`, re-apply the current Bearer toke See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#runtime-server-selection-header-button) for details. +#### Runtime OIDC Configuration + +When `enableServerSelection` is enabled, users can also configure OIDC authentication settings at runtime through the server selection modal. This allows connecting to servers that require different authentication providers without redeploying the application. + +To use a custom OIDC provider, enter a JSON configuration in the OIDC config field: + +```json +{ + "authority": "https://accounts.google.com", + "clientId": "your-client-id.apps.googleusercontent.com", + "scope": "email profile openid https://www.googleapis.com/auth/cloud-healthcare", + "grantType": "implicit" +} +``` + +Required fields: `authority`, `clientId`, `scope` + +Optional fields: `grantType`, `authorizationEndpoint`, `endSessionEndpoint` + +The OIDC configuration is cached in localStorage. If not provided, the deployment's default OIDC settings are used. + ### Handling mixed content and HTTPS When deploying Slim with HTTPS, you may encounter mixed content scenarios where your PACS/VNA server returns HTTP URLs in its responses. This commonly occurs when: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ead71e1..318f3d3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ shamefullyHoist: true strictPeerDependencies: false allowBuilds: + '@parcel/watcher': true core-js: true core-js-pure: true dicom-microscopy-viewer: true diff --git a/src/App.tsx b/src/App.tsx index 7131bdc1..8243d1b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,11 @@ import { } from 'react-router-dom' import type AppConfig from './AppConfig' -import type { ErrorMessageSettings, ServerSettings } from './AppConfig' +import type { + ErrorMessageSettings, + OidcSettings, + ServerSettings, +} from './AppConfig' import type { AuthManager, User } from './auth' import OidcManager from './auth/OidcManager' import AppLoading from './components/AppLoading' @@ -200,7 +204,7 @@ interface AppState { } class App extends React.Component { - private readonly auth?: AuthManager + private auth?: AuthManager private reauthInProgress = false private unsubscribeAuthorization?: () => void @@ -537,9 +541,34 @@ class App extends React.Component { } } - handleServerSelection = async ({ url }: { url: string }): Promise => { + handleServerSelection = async ({ + url, + oidc, + }: { + url: string + oidc?: OidcSettings + }): Promise => { const trimmedUrl = url.trim() console.info('select DICOMweb server: ', trimmedUrl) + + /** Handle OIDC configuration change */ + if (oidc != null) { + console.info('applying custom OIDC configuration') + const { protocol, host } = window.location + const baseUri = `${protocol}//${host}` + const appUri = joinUrl(this.props.config.path, baseUri) + this.auth = new OidcManager(appUri, oidc) + /** Re-subscribe to authorization changes */ + if (this.unsubscribeAuthorization != null) { + this.unsubscribeAuthorization() + } + this.unsubscribeAuthorization = this.auth.onAuthorizationChange( + (authorization) => { + this.applyAuthorization(authorization) + }, + ) + } + if ( trimmedUrl === '' || window.localStorage.getItem('slim_server_selection_mode') === 'default' @@ -726,16 +755,72 @@ class App extends React.Component { } } + /** + * Parses cached OIDC config from localStorage. + * Handles both JSON and JavaScript object notation (unquoted keys). + */ + private static parseCachedOidcConfig(): OidcSettings | undefined { + const cachedOidcConfig = window.localStorage.getItem('slim_oidc_config') + if (cachedOidcConfig == null || cachedOidcConfig.trim() === '') { + return undefined + } + try { + /** Convert JS object notation to JSON (quote unquoted keys) */ + const normalized = cachedOidcConfig + .trim() + .replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, '$1"$2"$3') + const parsed = JSON.parse(normalized) + if ( + typeof parsed === 'object' && + parsed !== null && + typeof parsed.authority === 'string' && + typeof parsed.clientId === 'string' && + typeof parsed.scope === 'string' + ) { + return { + authority: parsed.authority, + clientId: parsed.clientId, + scope: parsed.scope, + grantType: parsed.grantType, + authorizationEndpoint: parsed.authorizationEndpoint, + endSessionEndpoint: parsed.endSessionEndpoint, + } + } + } catch { + /** Invalid format, ignore cached config */ + } + return undefined + } + componentDidMount(): void { - // Restore cached server selection if it exists + /** Restore cached OIDC config and server selection if they exist */ + const cachedOidcConfig = App.parseCachedOidcConfig() const cachedServerUrl = window.localStorage.getItem('slim_selected_server') + const cachedMode = window.localStorage.getItem('slim_server_selection_mode') + + /** + * Apply cached OIDC config if present (even for default server mode). + * This allows users to configure OIDC once and have it persist. + */ + if (cachedOidcConfig != null) { + console.info('restoring cached OIDC configuration') + const { protocol, host } = window.location + const baseUri = `${protocol}//${host}` + const appUri = joinUrl(this.props.config.path, baseUri) + this.auth = new OidcManager(appUri, cachedOidcConfig) + } + if ( + cachedMode === 'custom' && cachedServerUrl !== null && cachedServerUrl !== undefined && cachedServerUrl !== '' ) { // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.handleServerSelection({ url: cachedServerUrl }) + this.handleServerSelection({ + url: cachedServerUrl, + oidc: cachedOidcConfig, + }) } if (this.auth != null) { diff --git a/src/components/Description.tsx b/src/components/Description.tsx index d07d13b2..99ccbe22 100644 --- a/src/components/Description.tsx +++ b/src/components/Description.tsx @@ -13,7 +13,7 @@ export interface AttributeGroup { } interface DescriptionProps { - header?: string + header?: React.ReactNode icon?: React.ComponentType> attributes: Attribute[] selectable?: boolean diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 719dfb42..65d3b022 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -3,6 +3,7 @@ import { BugOutlined, CheckOutlined, FileSearchOutlined, + InfoCircleOutlined, InfoOutlined, StopOutlined, UnorderedListOutlined, @@ -27,6 +28,7 @@ import React from 'react' import { NavLink } from 'react-router-dom' import { v4 as uuidv4 } from 'uuid' import appPackageJson from '../../package.json' +import type { OidcSettings } from '../AppConfig' import type { User } from '../auth' import { SettingsButton } from '../contexts/SettingsContext' import type DicomWebManager from '../DicomWebManager' @@ -44,6 +46,8 @@ import { normalizeServerUrl } from '../utils/url' import Button from './Button' import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser' +const { TextArea } = Input + const aboutModalCopyTooltips: [React.ReactNode, React.ReactNode] = [ 'Copy hash', 'Copied!', @@ -178,7 +182,13 @@ interface HeaderProps extends RouteComponentProps { clients?: { [key: string]: DicomWebManager } defaultClients?: { [key: string]: DicomWebManager } showWorklistButton: boolean - onServerSelection: ({ url }: { url: string }) => void + onServerSelection: ({ + url, + oidc, + }: { + url: string + oidc?: OidcSettings + }) => void onUserLogout?: () => void showServerSelectionButton: boolean } @@ -198,6 +208,10 @@ interface HeaderState { /** False only when both custom logo.svg and favicon.ico fail. */ showLogo: boolean logoUrl: string + /** Optional OIDC config JSON string entered by user */ + oidcConfigInput: string + /** Whether the OIDC config JSON is valid */ + isOidcConfigValid: boolean } /** @@ -213,6 +227,9 @@ class Header extends React.Component { 'slim_server_selection_mode', ) as 'default' | 'custom' | null + const cachedOidcConfig = + window.localStorage.getItem('slim_oidc_config') ?? '' + this.state = { errorObj: [], errorCategory: [], @@ -229,6 +246,8 @@ class Header extends React.Component { : 'default', showLogo: true, logoUrl: `${process.env.PUBLIC_URL}/logo.svg`, + oidcConfigInput: cachedOidcConfig, + isOidcConfigValid: Header.isValidOidcConfig(cachedOidcConfig), } const onErrorHandler = ({ @@ -341,6 +360,86 @@ class Header extends React.Component { return isGcpDicomStorePath(pathNorm) } + /** + * Converts JavaScript object notation to valid JSON by quoting unquoted keys. + * Handles cases like { authority: "value" } -> { "authority": "value" } + */ + static normalizeToJson(str: string): string { + /** Match unquoted keys followed by colon */ + return str.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, '$1"$2"$3') + } + + /** + * Validates OIDC config string (JSON or JS object notation). + * Returns true if empty (optional) or if valid with required fields. + */ + static isValidOidcConfig(jsonStr: string | null | undefined): boolean { + if (jsonStr == null || jsonStr.trim() === '') { + return true + } + try { + const normalized = Header.normalizeToJson(jsonStr.trim()) + const parsed = JSON.parse(normalized) + return ( + typeof parsed === 'object' && + parsed !== null && + typeof parsed.authority === 'string' && + parsed.authority.length > 0 && + typeof parsed.clientId === 'string' && + parsed.clientId.length > 0 && + typeof parsed.scope === 'string' && + parsed.scope.length > 0 + ) + } catch { + return false + } + } + + /** + * Parses OIDC config string (JSON or JS object notation) into OidcSettings. + * Returns undefined if empty or invalid. + */ + static parseOidcConfig( + jsonStr: string | null | undefined, + ): OidcSettings | undefined { + if (jsonStr == null || jsonStr.trim() === '') { + return undefined + } + try { + const normalized = Header.normalizeToJson(jsonStr.trim()) + const parsed = JSON.parse(normalized) + if ( + typeof parsed === 'object' && + parsed !== null && + typeof parsed.authority === 'string' && + typeof parsed.clientId === 'string' && + typeof parsed.scope === 'string' + ) { + return { + authority: parsed.authority, + clientId: parsed.clientId, + scope: parsed.scope, + grantType: parsed.grantType, + authorizationEndpoint: parsed.authorizationEndpoint, + endSessionEndpoint: parsed.endSessionEndpoint, + } + } + } catch { + /** Invalid format */ + } + return undefined + } + + handleOidcConfigInput = ( + event: React.ChangeEvent, + ): void => { + const value = event.currentTarget.value + this.setState({ + oidcConfigInput: value, + isOidcConfigValid: Header.isValidOidcConfig(value), + }) + } + static handleUserMenuButtonClick(e: React.SyntheticEvent): void { e.preventDefault() } @@ -633,6 +732,8 @@ class Header extends React.Component { const cachedServerUrl = window.localStorage .getItem('slim_selected_server') ?.trim() + const cachedOidcConfig = + window.localStorage.getItem('slim_oidc_config') ?? '' this.setState({ serverSelectionMode: cachedServerUrl !== null && @@ -643,6 +744,8 @@ class Header extends React.Component { selectedServerUrl: cachedServerUrl ?? undefined, isServerSelectionModalVisible: false, isServerSelectionDisabled: !this.isValidServerUrl(cachedServerUrl), + oidcConfigInput: cachedOidcConfig, + isOidcConfigValid: Header.isValidOidcConfig(cachedOidcConfig), }) } @@ -657,8 +760,19 @@ class Header extends React.Component { this.state.serverSelectionMode, ) + /** Save OIDC config to localStorage */ + const oidcConfig = Header.parseOidcConfig(this.state.oidcConfigInput) + if (oidcConfig != null) { + window.localStorage.setItem( + 'slim_oidc_config', + this.state.oidcConfigInput.trim(), + ) + } else { + window.localStorage.removeItem('slim_oidc_config') + } + if (this.state.serverSelectionMode === 'default') { - this.props.onServerSelection({ url: '' }) + this.props.onServerSelection({ url: '', oidc: oidcConfig }) this.setState({ isServerSelectionModalVisible: false, isServerSelectionDisabled: false, @@ -672,7 +786,7 @@ class Header extends React.Component { if (url !== null && url !== undefined && url !== '') { if (this.isValidServerUrl(url)) { resolvedUrl = normalizeServerUrl(url) - this.props.onServerSelection({ url: resolvedUrl }) + this.props.onServerSelection({ url: resolvedUrl, oidc: oidcConfig }) closeModal = true } } @@ -875,6 +989,63 @@ class Header extends React.Component { /> )} + +
+ + OIDC Configuration (optional) + + {`Example JSON format: +{ + "authority": "https://accounts.google.com", + "clientId": "your-client-id.apps.googleusercontent.com", + "scope": "email profile openid https://www.googleapis.com/auth/cloud-healthcare", + "grantType": "implicit" +}`} +
+ } + overlayStyle={{ maxWidth: '450px' }} + > + + + +