diff --git a/crates/ui/e2e/pages/routes.ts b/crates/ui/e2e/pages/routes.ts index f6c2add7f..a990aa4c0 100644 --- a/crates/ui/e2e/pages/routes.ts +++ b/crates/ui/e2e/pages/routes.ts @@ -11,6 +11,7 @@ export const ROUTES = [ "/ui/batch", "/ui/compartments", "/ui/search-parameters", + "/ui/terminology", "/ui/queries", "/ui/history", "/ui/search", diff --git a/crates/ui/src/bulk_export.rs b/crates/ui/src/bulk_export.rs index 7c92bcd21..cae60cef1 100644 --- a/crates/ui/src/bulk_export.rs +++ b/crates/ui/src/bulk_export.rs @@ -260,7 +260,7 @@ pub async fn page( principal: Option>, ) -> Response { let i18n = I18n::new(locale); - let status = current_status(state.version, rv.0, &rt); + let status = current_status(&state, rv.0, &rt); let user_key = settings_user_key(principal.as_deref()); let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await; let active_count = load_jobs(&state, &user_key, &rt.id) @@ -428,7 +428,7 @@ pub async fn active( principal: Option>, ) -> Response { let i18n = I18n::new(locale); - let status = current_status(state.version, rv.0, &rt); + let status = current_status(&state, rv.0, &rt); let user_key = settings_user_key(principal.as_deref()); let jobs = load_jobs(&state, &user_key, &rt.id).await; let mut entries: Vec<(String, ExportJob)> = jobs diff --git a/crates/ui/src/bulk_import.rs b/crates/ui/src/bulk_import.rs index b238a923a..6a16929d2 100644 --- a/crates/ui/src/bulk_import.rs +++ b/crates/ui/src/bulk_import.rs @@ -255,7 +255,7 @@ pub async fn page( principal: Option>, ) -> Response { let i18n = I18n::new(locale); - let status = current_status(state.version, rv.0, &rt); + let status = current_status(&state, rv.0, &rt); let available = state.settings.is_some(); let user_key = settings_user_key(principal.as_deref()); @@ -368,7 +368,7 @@ pub async fn detail( Path(id): Path, ) -> Response { let i18n = I18n::new(locale); - let status = current_status(state.version, rv.0, &rt); + let status = current_status(&state, rv.0, &rt); let user_key = settings_user_key(principal.as_deref()); let Some(s) = load_one(&state, &user_key, &rt.id, &id).await else { diff --git a/crates/ui/src/editor.rs b/crates/ui/src/editor.rs index 4cbcd35dc..a8478e198 100644 --- a/crates/ui/src/editor.rs +++ b/crates/ui/src/editor.rs @@ -191,7 +191,7 @@ pub async fn page( Query(query): Query, ) -> Response { render(EditorPage { - status: crate::current_status(state.version, rv.0, &rt), + status: crate::current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "editor", resource_type: query.resource_type.unwrap_or_else(|| "Patient".to_string()), diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 35baf5a53..a30101abf 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -302,6 +302,44 @@ pub(crate) struct Status { /// Whether the subscriptions engine is advertised — the sidebar entry and /// the operator page only appear when it is (#580). subscriptions_enabled: bool, + /// The safe navigation state derived from `HFS_TERMINOLOGY_SERVER` (#611). + /// The raw value is never exposed to templates unless it is a valid HTTP(S) + /// base URL. + terminology: TerminologyNavigation, +} + +enum TerminologyNavigation { + Unconfigured, + Invalid, + Valid(String), +} + +impl TerminologyNavigation { + fn from_config(value: Option<&str>) -> Self { + let Some(raw) = value else { + return Self::Unconfigured; + }; + + if raw.is_empty() || raw.trim() != raw { + return Self::Invalid; + } + + let Ok(url) = reqwest::Url::parse(raw) else { + return Self::Invalid; + }; + let valid = matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(); + + if valid { + Self::Valid(raw.to_string()) + } else { + Self::Invalid + } + } } impl Status { @@ -330,6 +368,21 @@ impl Status { self.subscriptions_enabled } + /// A browser-safe terminology destination, when the configured value is a + /// valid absolute HTTP(S) URL (#611). + pub(crate) fn terminology_url(&self) -> Option<&str> { + match &self.terminology { + TerminologyNavigation::Valid(url) => Some(url), + TerminologyNavigation::Unconfigured | TerminologyNavigation::Invalid => None, + } + } + + /// Whether the environment variable exists but cannot be used as a safe + /// browser destination (#611). + pub(crate) fn terminology_invalid(&self) -> bool { + matches!(self.terminology, TerminologyNavigation::Invalid) + } + /// The effective tenant id, for the `hfs-tenant` meta tag browser calls /// read (#344). pub(crate) fn tenant_id(&self) -> &str { @@ -554,6 +607,16 @@ struct ResourcesPage { show_save: bool, } +/// Explains how to configure terminology navigation, or why the configured +/// value cannot be used (#611). +#[derive(Template)] +#[template(path = "pages/terminology.html")] +struct TerminologyPage { + status: Status, + i18n: I18n, + active_page: &'static str, +} + /// Saved FHIR queries page (#234). The shell is server-rendered; the list is /// hydrated client-side from `/_user/settings` by `assets/saved-queries.js`, /// the same per-user document (and fetch pattern) the theme toggle uses. @@ -754,6 +817,7 @@ pub fn mount_with_conformance_source( .route("/ui/queries", get(queries)) .route("/ui/queries/params", get(query_params_catalog)) .route("/ui/search-parameters", get(search_parameters)) + .route("/ui/terminology", get(terminology_page)) .route("/ui/compartments", get(compartments_page)) // Batch/Transaction workspace (#476): upload → preflight → response. .route("/ui/batch", get(batch_page)) @@ -1110,15 +1174,7 @@ async fn index( .filter(|f| !f.is_empty() && f.chars().all(|c| c.is_ascii_alphanumeric())); render( build_index_page( - state.version, - locale, - types, - window, - all_types, - spec_types, - focus, - rv.0, - &rt, + &state, locale, types, window, all_types, spec_types, focus, rv.0, &rt, ) .await, ) @@ -1133,7 +1189,7 @@ async fn search( ) -> Response { let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await; render(SearchPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "search", nl: (*state.nl).clone(), @@ -1152,7 +1208,7 @@ async fn queries( ) -> Response { let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await; render(QueriesPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "queries", resource_types, @@ -1174,7 +1230,7 @@ async fn resources( ) -> Response { let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await; render(ResourcesPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "resources", nl: (*state.nl).clone(), @@ -1186,6 +1242,21 @@ async fn resources( }) } +/// Terminology setup state (#611). The sidebar links here when the environment +/// variable is absent or cannot be used as a safe browser destination. +async fn terminology_page( + State(state): State, + locale: RequestLocale, + rv: RequestVersion, + rt: RequestTenant, +) -> Response { + render(TerminologyPage { + status: current_status(&state, rv.0, &rt), + i18n: I18n::new(locale), + active_page: "terminology", + }) +} + /// Query string for the Resources page: an optional pre-selected type, so the /// nav submenu can deep-link `/ui/resources?type=Observation`. #[derive(Deserialize, Default)] @@ -1269,7 +1340,7 @@ async fn search_parameters( .snapshot(&rt.id, query.fhir_version()) .await; render(SearchParametersPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "search-parameters", view: search_params::build_view(&snapshot, &query), @@ -1301,7 +1372,7 @@ async fn batch_page( rt: RequestTenant, ) -> Response { render(BatchPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "batch", }) @@ -1333,7 +1404,7 @@ async fn compartments_page( .await; match compartments::build_view(&query, &defs) { Some(view) => render(CompartmentsPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "compartments", view, @@ -1342,7 +1413,7 @@ async fn compartments_page( // without an outbound token, #320) — a warning, not a 404. The failed // fetch is not cached, so the next request re-attempts it. None => render(CompartmentsDegradedPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "compartments", }), @@ -1358,14 +1429,14 @@ async fn status( rt: RequestTenant, HxRequest(is_htmx): HxRequest, ) -> Response { - let status = current_status(state.version, rv.0, &rt); + let status = current_status(&state, rv.0, &rt); let i18n = I18n::new(locale); if is_htmx { render(StatusPartial { status, i18n }) } else { render( build_index_page( - state.version, + &state, locale, Vec::new(), DashboardWindow::default(), @@ -1388,7 +1459,7 @@ async fn history_page( rt: RequestTenant, ) -> Response { render(HistoryPage { - status: current_status(state.version, rv.0, &rt), + status: current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "history", }) @@ -1454,7 +1525,7 @@ async fn history_diff(locale: RequestLocale, axum::Form(form): axum::Form, window: DashboardWindow, @@ -1464,7 +1535,7 @@ async fn build_index_page( fhir_version: helios_fhir::FhirVersion, tenant: &RequestTenant, ) -> IndexPage { - let status = current_status(version, fhir_version, tenant); + let status = current_status(state, fhir_version, tenant); let i18n = I18n::new(locale); let live = helios_observability::dashboard::snapshot(window, &tenant.id, &types, all_types).await; @@ -2012,18 +2083,19 @@ fn bucket_floor_utc(ts: DateTime, bucket_seconds: i64) -> DateTime { } pub(crate) fn current_status( - version: &'static str, + state: &WebState, fhir_version: helios_fhir::FhirVersion, tenant: &RequestTenant, ) -> Status { Status { - version, + version: state.version, checked_at: unix_timestamp_seconds(), fhir_version, tenant_id: tenant.id.clone(), tenant_display: tenant.display.clone(), show_tenant_picker: tenant.multi, subscriptions_enabled: helios_observability::subscriptions::enabled(), + terminology: TerminologyNavigation::from_config(state.terminology.as_deref()), } } @@ -2070,6 +2142,7 @@ mod tests { tenant_display: None, show_tenant_picker: true, subscriptions_enabled: false, + terminology: TerminologyNavigation::Unconfigured, }, metrics: dash.metrics, chart: dash.chart, @@ -2216,6 +2289,7 @@ mod tests { tenant_display: None, show_tenant_picker: true, subscriptions_enabled: false, + terminology: TerminologyNavigation::Unconfigured, }, i18n: i18n("en"), } @@ -2273,6 +2347,7 @@ mod tests { tenant_display: None, show_tenant_picker: true, subscriptions_enabled: false, + terminology: TerminologyNavigation::Unconfigured, }, i18n: i18n("en"), active_page: "queries", @@ -2317,6 +2392,7 @@ mod tests { tenant_display: None, show_tenant_picker: true, subscriptions_enabled: false, + terminology: TerminologyNavigation::Unconfigured, }, i18n: i18n("es"), active_page: "queries", diff --git a/crates/ui/src/subscriptions.rs b/crates/ui/src/subscriptions.rs index 0f7cb5462..72e505833 100644 --- a/crates/ui/src/subscriptions.rs +++ b/crates/ui/src/subscriptions.rs @@ -175,7 +175,7 @@ pub async fn page( }; render(SubscriptionsPage { - status: crate::current_status(state.version, rv.0, &rt), + status: crate::current_status(&state, rv.0, &rt), i18n: I18n::new(locale), active_page: "subscriptions", available, diff --git a/crates/ui/src/tenants.rs b/crates/ui/src/tenants.rs index fa1ce7854..61c70c10b 100644 --- a/crates/ui/src/tenants.rs +++ b/crates/ui/src/tenants.rs @@ -327,7 +327,7 @@ pub async fn page( Query(query): Query, ) -> Response { let i18n = I18n::new(locale); - let status = current_status(state.version, rv.0, &rt); + let status = current_status(&state, rv.0, &rt); let Some(storage) = state.tenants.as_ref() else { return render(TenantsPage { diff --git a/crates/ui/templates/layouts/base.html b/crates/ui/templates/layouts/base.html index 70bc121c3..7658d82a2 100644 --- a/crates/ui/templates/layouts/base.html +++ b/crates/ui/templates/layouts/base.html @@ -116,10 +116,18 @@ - + {% if let Some(url) = status.terminology_url() %} + {% include "icons/book.svg" %} {{ i18n.t("nav-terminology") }} - + + {% else %} + + {% include "icons/book.svg" %} + {{ i18n.t("nav-terminology") }} + + {% endif %} {% if status.subscriptions_enabled() %} diff --git a/crates/ui/templates/pages/terminology.html b/crates/ui/templates/pages/terminology.html new file mode 100644 index 000000000..5274caa6a --- /dev/null +++ b/crates/ui/templates/pages/terminology.html @@ -0,0 +1,45 @@ +{% extends "layouts/base.html" %} + +{% block title %}{{ i18n.t("terminology-heading") }} — {{ i18n.t("app-title") }}{% endblock %} + +{% block content %} +
+

{{ i18n.t("terminology-heading") }}

+

{{ i18n.t("terminology-lede") }}

+
+ +{% if let Some(url) = status.terminology_url() %} +
+
+ {% include "icons/book.svg" %} +

{{ i18n.t("terminology-configured-heading") }}

+
+

{{ i18n.t("terminology-configured-body") }}

+
{{ url }}
+
+ {{ i18n.t("terminology-configured-open") }} + +
+{% else if status.terminology_invalid() %} + +{% else %} +
+
+ {% include "icons/book.svg" %} +

{{ i18n.t("terminology-setup-heading") }}

+
+

{{ i18n.t("terminology-setup-body") }}

+
HFS_TERMINOLOGY_SERVER=http://localhost:8090
+

{{ i18n.t("terminology-setup-note") }}

+
+{% endif %} +{% endblock %} diff --git a/crates/ui/tests/router_http.rs b/crates/ui/tests/router_http.rs index 8847d4906..6f39221e6 100644 --- a/crates/ui/tests/router_http.rs +++ b/crates/ui/tests/router_http.rs @@ -774,6 +774,92 @@ fn app_with_terminology(terminology: Option) -> Router { ) } +#[tokio::test] +async fn terminology_navigation_reflects_the_configuration() { + let response = app_with_terminology(None) + .oneshot(Request::get("/ui").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let html = body_text(response).await; + assert!(html.contains(r#"href="/ui/terminology""#)); + + let response = app_with_terminology(None) + .oneshot(Request::get("/ui/terminology").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let html = body_text(response).await; + assert!(html.contains(r#"id="terminology-setup""#)); + assert!(html.contains("HFS_TERMINOLOGY_SERVER=http://localhost:8090")); + assert!(html.contains(r#"href="/ui/terminology" aria-current="page""#)); + + let valid = "https://terminology.example/fhir/"; + let response = app_with_terminology(Some(valid.to_string())) + .oneshot(Request::get("/ui").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let html = body_text(response).await; + assert!(html.contains(&format!(r#"href="{valid}""#))); + assert!(html.contains(r#"target="_blank""#)); + assert!(html.contains(r#"rel="noopener noreferrer""#)); + assert!(html.contains(r#"hx-boost="false""#)); + assert!(html.contains("opens in a new tab")); + assert!(!html.contains(r#"href="/ui/terminology""#)); + + let response = app_with_terminology(Some(valid.to_string())) + .oneshot(Request::get("/ui/queries").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let html = body_text(response).await; + assert!(html.contains(&format!(r#"href="{valid}""#))); + assert!(html.contains(r#"target="_blank""#)); + assert!(html.contains(r#"rel="noopener noreferrer""#)); + assert!(html.contains(r#"hx-boost="false""#)); + + let invalid = "javascript:alert(1)"; + let response = app_with_terminology(Some(invalid.to_string())) + .oneshot(Request::get("/ui").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let html = body_text(response).await; + assert!(html.contains(r#"href="/ui/terminology""#)); + assert!(!html.contains(invalid)); + + let response = app_with_terminology(Some(invalid.to_string())) + .oneshot(Request::get("/ui/terminology").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let html = body_text(response).await; + assert!(html.contains(r#"id="terminology-invalid" role="alert""#)); + assert!(html.contains("absolute HTTP or HTTPS URL")); + assert!(html.contains("HFS_TERMINOLOGY_SERVER=http://localhost:8090")); + assert!(!html.contains(invalid)); + + for invalid in [ + "", + " https://terminology.example/fhir", + "/fhir", + "ftp://terminology.example/fhir", + "https://user:secret@terminology.example/fhir", + "https://terminology.example/fhir?mode=test", + "https://terminology.example/fhir#codes", + ] { + let response = app_with_terminology(Some(invalid.to_string())) + .oneshot(Request::get("/ui").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{invalid}"); + let html = body_text(response).await; + assert!(html.contains(r#"href="/ui/terminology""#), "{invalid}"); + assert!(!html.contains(&format!(r#"href="{invalid}""#)), "{invalid}"); + } +} + /// A loopback stand-in for the terminology server: one canned response for /// `GET /ValueSet/$expand`. async fn mock_terminology(status: StatusCode, body: &'static str) -> String { diff --git a/locales/de/main.ftl b/locales/de/main.ftl index fa7649e00..b371f6eda 100644 --- a/locales/de/main.ftl +++ b/locales/de/main.ftl @@ -17,6 +17,7 @@ app-tagline = Ein schneller, versionsübergreifender FHIR-Server nav-dashboard = Übersicht nav-terminology = Terminologie +nav-terminology-new-window = Terminologie (wird in einem neuen Tab geöffnet) nav-resources = Ressourcen nav-settings = Einstellungen nav-signout = Abmelden @@ -50,6 +51,17 @@ resource-count = { $count -> ## Terminologie durchsuchen +terminology-heading = Terminologieserver +terminology-lede = Verbinden Sie HFS mit einem FHIR-Terminologieserver. +terminology-configured-heading = Terminologieserver konfiguriert +terminology-configured-body = HFS_TERMINOLOGY_SERVER verweist auf eine gültige Server-URL. +terminology-configured-open = Terminologieserver öffnen +terminology-invalid-heading = HFS_TERMINOLOGY_SERVER ist ungültig +terminology-invalid-body = Verwenden Sie eine absolute HTTP- oder HTTPS-URL mit einem Host. Pfade und ein abschließender Schrägstrich sind zulässig. Fügen Sie keine Zugangsdaten, Abfrageparameter oder Fragmente ein. +terminology-invalid-note = Aktualisieren Sie die Umgebungsvariable und starten Sie HFS neu. +terminology-setup-heading = Terminologieserver verbinden +terminology-setup-body = Setzen Sie HFS_TERMINOLOGY_SERVER auf die Basis-URL des FHIR-Terminologieservers, den HFS verwenden soll. +terminology-setup-note = Setzen Sie die Variable in der Umgebung, die HFS startet, und starten Sie den Server danach neu. terminology-search-label = CodeSystems und ValueSets durchsuchen terminology-search-placeholder = z. B. 73211009, „Diabetes“, http://snomed.info/sct terminology-display-language = Anzeigesprache diff --git a/locales/en/main.ftl b/locales/en/main.ftl index de9abb1a1..283ba1e68 100644 --- a/locales/en/main.ftl +++ b/locales/en/main.ftl @@ -19,6 +19,7 @@ app-tagline = A fast, multi-version FHIR server nav-dashboard = Dashboard nav-terminology = Terminology +nav-terminology-new-window = Terminology (opens in a new tab) nav-resources = Resources nav-settings = Settings nav-signout = Sign out @@ -54,6 +55,17 @@ resource-count = { $count -> ## Terminology browsing +terminology-heading = Terminology server +terminology-lede = Connect HFS to a FHIR terminology server. +terminology-configured-heading = Terminology server configured +terminology-configured-body = HFS_TERMINOLOGY_SERVER points to a valid server URL. +terminology-configured-open = Open terminology server +terminology-invalid-heading = HFS_TERMINOLOGY_SERVER is invalid +terminology-invalid-body = Use an absolute HTTP or HTTPS URL with a host. Paths and a trailing slash are allowed. Do not include credentials, a query string, or a fragment. +terminology-invalid-note = Update the environment variable, then restart HFS. +terminology-setup-heading = Connect a terminology server +terminology-setup-body = Set HFS_TERMINOLOGY_SERVER to the base URL of the FHIR terminology server that HFS should use. +terminology-setup-note = Set the variable in the environment that starts HFS, then restart the server. terminology-search-label = Search CodeSystems and ValueSets terminology-search-placeholder = e.g. 73211009, "diabetes", http://snomed.info/sct terminology-display-language = Display language diff --git a/locales/es/main.ftl b/locales/es/main.ftl index 0cf17b448..0b8b6ee22 100644 --- a/locales/es/main.ftl +++ b/locales/es/main.ftl @@ -17,6 +17,7 @@ app-tagline = Un servidor FHIR rápido y multiversión nav-dashboard = Panel nav-terminology = Terminología +nav-terminology-new-window = Terminología (se abre en una pestaña nueva) nav-resources = Recursos nav-settings = Configuración nav-signout = Cerrar sesión @@ -50,6 +51,17 @@ resource-count = { $count -> ## Exploración de terminología +terminology-heading = Servidor de terminología +terminology-lede = Conecta HFS con un servidor de terminología FHIR. +terminology-configured-heading = Servidor de terminología configurado +terminology-configured-body = HFS_TERMINOLOGY_SERVER apunta a una URL válida. +terminology-configured-open = Abrir servidor de terminología +terminology-invalid-heading = HFS_TERMINOLOGY_SERVER no es válida +terminology-invalid-body = Usa una URL HTTP o HTTPS absoluta con un host. Se permiten rutas y una barra final. No incluyas credenciales, parámetros de consulta ni fragmentos. +terminology-invalid-note = Actualiza la variable de entorno y luego reinicia HFS. +terminology-setup-heading = Conectar un servidor de terminología +terminology-setup-body = Define HFS_TERMINOLOGY_SERVER con la URL base del servidor de terminología FHIR que debe usar HFS. +terminology-setup-note = Define la variable en el entorno desde el que se inicia HFS y luego reinicia el servidor. terminology-search-label = Buscar CodeSystems y ValueSets terminology-search-placeholder = p. ej. 73211009, «diabetes», http://snomed.info/sct terminology-display-language = Idioma de visualización