From 09362031eea25308ac4caa47169cf93927ef6b9a Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Fri, 28 Aug 2026 13:59:47 -0300 Subject: [PATCH 01/14] feat: add click-to-zoom for segments Add onClick handler to SegmentItem to zoom to segment's bounding box when the segment label is clicked. This is consistent with the bulk annotation zoom behavior and provides a better UX than auto-zooming on visibility toggle. Changes: - Add onClick prop to SegmentItem and SegmentList components - Add handleSegmentClick method in SlideViewer - Update dicom-microscopy-viewer types with zoomToSegment method --- src/components/SegmentItem.tsx | 22 ++++++++++++++++++++-- src/components/SegmentList.tsx | 2 ++ src/components/SlideViewer.tsx | 5 +++++ types/dicom-microscopy-viewer/index.d.ts | 4 +++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/SegmentItem.tsx b/src/components/SegmentItem.tsx index 81331f86..31d0c26a 100644 --- a/src/components/SegmentItem.tsx +++ b/src/components/SegmentItem.tsx @@ -34,6 +34,7 @@ interface SegmentItemProps { color?: number[] } }) => void + onClick: (segmentUID: string) => void } interface SegmentItemState { @@ -111,6 +112,10 @@ class SegmentItem extends React.Component { } } + handleClick = (): void => { + this.props.onClick(this.props.segment.uid) + } + render(): React.ReactNode { const attributes: Array<{ name: string; value: string }> = [ { @@ -175,6 +180,7 @@ class SegmentItem extends React.Component { metadata, onVisibilityChange, onStyleChange, + onClick, ...otherProps } = this.props return ( @@ -223,14 +229,26 @@ class SegmentItem extends React.Component { )} -
+
+ ) diff --git a/src/components/SegmentList.tsx b/src/components/SegmentList.tsx index 212723f9..a1e55820 100644 --- a/src/components/SegmentList.tsx +++ b/src/components/SegmentList.tsx @@ -35,6 +35,7 @@ interface SegmentListProps { color?: number[] } }) => void + onSegmentClick: (segmentUID: string) => void } /** @@ -75,6 +76,7 @@ class SegmentList extends React.Component< defaultStyle={this.props.defaultSegmentStyles[uid]} onVisibilityChange={this.props.onSegmentVisibilityChange} onStyleChange={this.props.onSegmentStyleChange} + onClick={this.props.onSegmentClick} /> ) }) diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index a95b4b87..1077453f 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -3102,6 +3102,10 @@ class SlideViewer extends React.Component { } } + handleSegmentClick = (segmentUID: string): void => { + this.volumeViewer.zoomToSegment(segmentUID) + } + /** * Handle change of segment style. */ @@ -4288,6 +4292,7 @@ class SlideViewer extends React.Component { visibleSegmentUIDs={this.state.visibleSegmentUIDs} onSegmentVisibilityChange={this.handleSegmentVisibilityChange} onSegmentStyleChange={this.handleSegmentStyleChange} + onSegmentClick={this.handleSegmentClick} /> )} diff --git a/types/dicom-microscopy-viewer/index.d.ts b/types/dicom-microscopy-viewer/index.d.ts index 540d47c9..be81f8ec 100644 --- a/types/dicom-microscopy-viewer/index.d.ts +++ b/types/dicom-microscopy-viewer/index.d.ts @@ -156,9 +156,11 @@ declare module 'dicom-microscopy-viewer' { segmentUID: string, styleOptions?: { opacity?: number - } + }, + shouldZoomIn?: boolean ): void hideSegment (segmentUID: string): void + zoomToSegment (segmentUID: string): void setSegmentStyle ( segmentUID: string, styleOptions: { From a492b947473677b21203582c1a9cbc68414fde03 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Fri, 28 Aug 2026 14:24:01 -0300 Subject: [PATCH 02/14] feat: add optional OIDC configuration in server selection Add the ability to configure OIDC settings through the server selection modal UI. This allows users to connect to servers that require different authentication providers without needing to redeploy the application. Changes: - Add OIDC config textarea input in server selection modal - Add info icon with tooltip showing example JSON format - Validate JSON format and required fields (authority, clientId, scope) - Cache OIDC config in localStorage - Recreate OidcManager when OIDC config is provided - Support optional fields: grantType, authorizationEndpoint, endSessionEndpoint The OIDC configuration is optional - if not provided, the existing config from the deployment is used. If provided, it overwrites the current OIDC settings. --- src/App.tsx | 35 +++++++- src/components/Header.tsx | 166 +++++++++++++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7131bdc1..a031e5d9 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' diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 719dfb42..f7216169 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: this.isValidOidcConfig(cachedOidcConfig), } const onErrorHandler = ({ @@ -341,6 +360,75 @@ class Header extends React.Component { return isGcpDicomStorePath(pathNorm) } + /** + * Validates OIDC config JSON string. + * Returns true if empty (optional) or if valid JSON with required fields. + */ + isValidOidcConfig = (jsonStr: string | null | undefined): boolean => { + if (jsonStr == null || jsonStr.trim() === '') { + return true + } + try { + const parsed = JSON.parse(jsonStr.trim()) + 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 JSON string into OidcSettings object. + * Returns undefined if empty or invalid. + */ + parseOidcConfig = ( + jsonStr: string | null | undefined, + ): OidcSettings | undefined => { + if (jsonStr == null || jsonStr.trim() === '') { + return undefined + } + try { + const parsed = JSON.parse(jsonStr.trim()) + 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 JSON */ + } + return undefined + } + + handleOidcConfigInput = ( + event: React.ChangeEvent, + ): void => { + const value = event.currentTarget.value + this.setState({ + oidcConfigInput: value, + isOidcConfigValid: this.isValidOidcConfig(value), + }) + } + static handleUserMenuButtonClick(e: React.SyntheticEvent): void { e.preventDefault() } @@ -633,6 +721,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 +733,8 @@ class Header extends React.Component { selectedServerUrl: cachedServerUrl ?? undefined, isServerSelectionModalVisible: false, isServerSelectionDisabled: !this.isValidServerUrl(cachedServerUrl), + oidcConfigInput: cachedOidcConfig, + isOidcConfigValid: this.isValidOidcConfig(cachedOidcConfig), }) } @@ -657,8 +749,19 @@ class Header extends React.Component { this.state.serverSelectionMode, ) + /** Save OIDC config to localStorage */ + const oidcConfig = this.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 +775,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 +978,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' }} + > + + + +