From 7016c3f5f4482d8636bb2dcd177c929bae56afd7 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 14 Nov 2025 10:42:40 +1100 Subject: [PATCH 01/33] show an OIDC profile widget on user preferences page based on config --- .../src/components/User/UserPreferences.vue | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index ede9aa55c77..a5361afb2ed 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -11,6 +11,7 @@ import { faKey, faLock, faPalette, + faPerson, faRadiation, faSignOut, faUsers, @@ -59,6 +60,13 @@ const activePreferences = computed(() => { const enabledPreferences = Object.entries(userPreferencesEntries).filter(([, value]) => !value.disabled); return Object.fromEntries(enabledPreferences); }); +const showOidcProfile = computed(() => { + if (isConfigLoaded.value) { + return config.value.enable_oidc && config.value.oidc_profile_url; + } else { + return false; + } +}); const hasLogout = computed(() => { if (isConfigLoaded.value) { const Galaxy = getGalaxyInstance(); @@ -88,7 +96,7 @@ async function makeDataPrivate() { "of your new data in these histories is created as private. Any " + "datasets within that are currently shared will need " + "to be re-shared or published. Are you sure you " + - "want to do this?", + "want to do this?" ), { title: "Do you want to make all data private?", @@ -96,7 +104,7 @@ async function makeDataPrivate() { cancelTitle: "No, do not make data private", cancelVariant: "outline-primary", centered: true, - }, + } ); if (confirmed) { axios.post(withPrefix(`/history/make_private?all_histories=true`)).then(() => { @@ -169,6 +177,14 @@ onMounted(async () => {
+ + Date: Fri, 14 Nov 2025 10:49:22 +1100 Subject: [PATCH 02/33] update logic in user preferences model - shouldn't allow editing email/password when local accounts are disabled --- client/src/components/User/UserPreferencesModel.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/client/src/components/User/UserPreferencesModel.ts b/client/src/components/User/UserPreferencesModel.ts index 85c743b11ac..ebcca6fa3f2 100644 --- a/client/src/components/User/UserPreferencesModel.ts +++ b/client/src/components/User/UserPreferencesModel.ts @@ -34,9 +34,12 @@ export const getUserPreferencesModel: (user_id?: string) => UserPreferencesModel title: localize("Manage Information"), id: "edit-preferences-information", description: - isConfigLoaded.value && config.value.enable_account_interface && !config.value.use_remote_user + isConfigLoaded.value && + config.value.enable_account_interface && + !config.value.use_remote_user && + !config.value.disable_local_accounts ? localize("Edit your email, addresses and custom parameters or change your public name.") - : localize("Edit your custom parameters."), + : localize("Edit your addresses and custom parameters."), url: `/api/users/${user_id}/information/inputs`, icon: faUser, redirect: "/user", @@ -49,7 +52,11 @@ export const getUserPreferencesModel: (user_id?: string) => UserPreferencesModel url: `/api/users/${user_id}/password/inputs`, submitTitle: "Save Password", redirect: "/user", - disabled: isConfigLoaded.value && (config.value.use_remote_user || !config.value.enable_account_interface), + disabled: + isConfigLoaded.value && + (config.value.use_remote_user || + config.value.disable_local_accounts || + !config.value.enable_account_interface), }, toolbox_filters: { title: localize("Manage Toolbox Filters"), From 8155fad628d57e46bfbf56af049f52c7fa6d6f40 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 14 Nov 2025 11:16:14 +1100 Subject: [PATCH 03/33] add UserOidcProfile component for displaying profile info, with link to external profile --- .../src/components/User/UserOidcProfile.vue | 83 +++++++++++++++++++ client/src/entry/analysis/router.js | 6 ++ 2 files changed, 89 insertions(+) create mode 100644 client/src/components/User/UserOidcProfile.vue diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue new file mode 100644 index 00000000000..d591d42d33c --- /dev/null +++ b/client/src/components/User/UserOidcProfile.vue @@ -0,0 +1,83 @@ + + + diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index 7162712a4ec..c348d190d6f 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -76,6 +76,7 @@ import CustomBuilds from "@/components/User/CustomBuilds.vue"; import HistoryStorageOverview from "@/components/User/DiskUsage/Visualizations/HistoryStorageOverview.vue"; import NotificationsPreferences from "@/components/User/Notifications/NotificationsPreferences.vue"; import UserDatasetPermissions from "@/components/User/UserDatasetPermissions.vue"; +import UserOidcProfile from "@/components/User/UserOidcProfile.vue"; import UserPreferences from "@/components/User/UserPreferences.vue"; import UserPreferencesForm from "@/components/User/UserPreferencesForm.vue"; import DisplayApplication from "@/components/Visualizations/DisplayApplication.vue"; @@ -620,6 +621,11 @@ export function getRouter(Galaxy) { component: CredentialsManagement, redirect: redirectAnon(), }, + { + path: "user/oidc-profile", + component: UserOidcProfile, + redirect: redirectAnon(), + }, { path: "user/external_ids", component: ExternalIdentities, From 4e03dfa939d57fc174fe77c423860da6c1bca4d1 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 14 Nov 2025 11:23:30 +1100 Subject: [PATCH 04/33] add oidc_profile_url to config schema --- lib/galaxy/config/schemas/config_schema.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index c285c46f656..a5bf2378b01 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -2829,7 +2829,7 @@ mapping: deprecated_alias: allow_user_creation desc: | Allow unregistered users to create new local (non-OIDC) accounts (otherwise, they will have to - be created by an admin). This option will be overridden to false in case disable_local_accounts + be created by an admin). This option will be overridden to false in case disable_local_accounts is set to true. disable_local_accounts: @@ -2837,8 +2837,8 @@ mapping: default: false required: true desc: | - Disable local accounts. If this option is set to true, at least one OIDC provider needs - to be configured and will serve as the account provider. If this option is set to true, + Disable local accounts. If this option is set to true, at least one OIDC provider needs + to be configured and will serve as the account provider. If this option is set to true, allow_local_account creation will be overridden with false. allow_user_deletion: @@ -3084,6 +3084,15 @@ mapping: desc: | Enables and disables OpenID Connect (OIDC) support. + oidc_profile_url: + type: str + required: false + desc: | + If specified, user profile information (email, username, password) will be managed + by the OIDC provider. A widget will show the current email and username in the user menu, + with a link to the OIDC provider's user profile page to edit them. + Use with enable_account_interface=false. + oidc_config_file: type: str default: oidc_config.xml From d86d3a4e72387558c38b72ba6c2a3eb80d939c84 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 14 Nov 2025 11:23:42 +1100 Subject: [PATCH 05/33] expose OIDC profile URL in config API --- lib/galaxy/managers/configuration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/galaxy/managers/configuration.py b/lib/galaxy/managers/configuration.py index 30081f54778..f7615fb03db 100644 --- a/lib/galaxy/managers/configuration.py +++ b/lib/galaxy/managers/configuration.py @@ -148,6 +148,7 @@ class ConfigSerializer(base.ModelSerializer): "use_remote_user": _defaults_to(None), # schema default is False; or config.single_user "single_user": _config_is_truthy, "enable_oidc": _use_config, + "oidc_profile_url": _use_config, "oidc": _use_config, "prefer_custos_login": _use_config, "enable_quotas": _use_config, From 02c288c39b80c1a1bc0650abd1640044be82369f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 14 Nov 2025 11:37:58 +1100 Subject: [PATCH 06/33] update logic for showing email/username fields based on whether local accounts are enabled --- lib/galaxy/webapps/galaxy/api/users.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 7c89dd51ca2..487cd90eb70 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -823,7 +823,12 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController "username": username, } is_galaxy_app = trans.webapp.name == "galaxy" - if (trans.app.config.enable_account_interface and not trans.app.config.use_remote_user) or not is_galaxy_app: + show_account_interface = ( + trans.app.config.enable_account_interface + and not trans.app.config.use_remote_user + and not trans.app.config.disable_local_accounts + ) + if show_account_interface or not is_galaxy_app: inputs.append( { "id": "email_input", @@ -839,7 +844,7 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController } ) if is_galaxy_app: - if trans.app.config.enable_account_interface and not trans.app.config.use_remote_user: + if show_account_interface: inputs.append( { "id": "name_input", From 07a63c0884271b503611639d391a1ed330bbbdda Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 14 Nov 2025 13:19:44 +1100 Subject: [PATCH 07/33] run client formatter --- client/src/components/User/UserOidcProfile.vue | 2 +- client/src/components/User/UserPreferences.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue index d591d42d33c..6396416cc54 100644 --- a/client/src/components/User/UserOidcProfile.vue +++ b/client/src/components/User/UserOidcProfile.vue @@ -55,7 +55,7 @@ const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value {{ localize( - "Your username is an identifier that will be used to generate addresses for information you share publicly." + "Your username is an identifier that will be used to generate addresses for information you share publicly.", ) }} diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index a5361afb2ed..ce18d3b206c 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -96,7 +96,7 @@ async function makeDataPrivate() { "of your new data in these histories is created as private. Any " + "datasets within that are currently shared will need " + "to be re-shared or published. Are you sure you " + - "want to do this?" + "want to do this?", ), { title: "Do you want to make all data private?", @@ -104,7 +104,7 @@ async function makeDataPrivate() { cancelTitle: "No, do not make data private", cancelVariant: "outline-primary", centered: true, - } + }, ); if (confirmed) { axios.post(withPrefix(`/history/make_private?all_histories=true`)).then(() => { From 201b5fefc8434b1804130ebd9350e37da0462600 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 14 Nov 2025 13:23:46 +1100 Subject: [PATCH 08/33] run make format --- client/src/components/User/UserOidcProfile.vue | 2 +- client/src/components/User/UserPreferences.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue index 6396416cc54..d591d42d33c 100644 --- a/client/src/components/User/UserOidcProfile.vue +++ b/client/src/components/User/UserOidcProfile.vue @@ -55,7 +55,7 @@ const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value {{ localize( - "Your username is an identifier that will be used to generate addresses for information you share publicly.", + "Your username is an identifier that will be used to generate addresses for information you share publicly." ) }} diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index ce18d3b206c..a5361afb2ed 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -96,7 +96,7 @@ async function makeDataPrivate() { "of your new data in these histories is created as private. Any " + "datasets within that are currently shared will need " + "to be re-shared or published. Are you sure you " + - "want to do this?", + "want to do this?" ), { title: "Do you want to make all data private?", @@ -104,7 +104,7 @@ async function makeDataPrivate() { cancelTitle: "No, do not make data private", cancelVariant: "outline-primary", centered: true, - }, + } ); if (confirmed) { axios.post(withPrefix(`/history/make_private?all_histories=true`)).then(() => { From 56b2cb7c1181808b5c8f53ee5e222380501b31cb Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 24 Nov 2025 15:35:33 +1100 Subject: [PATCH 09/33] add profile url and check to ExternalIDHelper file --- .../components/User/ExternalIdentities/ExternalIDHelper.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts index af304b45e14..466caa29cc1 100644 --- a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts +++ b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts @@ -11,6 +11,7 @@ export type OIDCConfig = Record< label?: string; custom_button_text?: string; end_user_registration_endpoint?: string; + profile_url?: string; } >; @@ -21,6 +22,7 @@ export type OIDCConfigWithRegistration = Record< label?: string; custom_button_text?: string; end_user_registration_endpoint: string; + profile_url?: string; } >; @@ -96,6 +98,10 @@ export function isOnlyOneOIDCProviderConfigured(config: OIDCConfig): boolean { return Object.keys(config).length === 1; } +export function hasSingleOidcProfile(config: OIDCConfig): boolean { + return isOnlyOneOIDCProviderConfigured(config) && !!config.profile_url; +} + export async function redirectToSingleProvider(config: OIDCConfig): Promise { const providers = Object.keys(config); From 80d2d51b7923571a406a768ccff0192a65f4f1b8 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 24 Nov 2025 15:38:43 +1100 Subject: [PATCH 10/33] show provider name on button where possible --- client/src/components/User/UserOidcProfile.vue | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue index d591d42d33c..8b9a20f5bb4 100644 --- a/client/src/components/User/UserOidcProfile.vue +++ b/client/src/components/User/UserOidcProfile.vue @@ -28,6 +28,18 @@ const profileUrl = computed(() => { } return config.value.oidc_profile_url || ""; }); +const profileButtonLabel = computed(() => { + const providers = Object.keys(config.value.oidc) || []; + if (providers.length === 0 || providers === null) { + return null; + } + const providerConfig = config.value.oidc[providers[0]!]; + const providerLabel = providerConfig.custom_button_text || providerConfig.label; + if (providerLabel) { + return `Update profile details at ${providerLabel}`; + } + return "Update profile details"; +}); const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value); @@ -74,7 +86,7 @@ const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value :disabled="buttonDisabled" :href="profileUrl" target="_blank"> - {{ localize("Update profile details") }} + {{ localize(profileButtonLabel) }}
From 7e3e76cec08ce1aaedcb80656cc35527b8a75939 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 24 Nov 2025 16:06:09 +1100 Subject: [PATCH 11/33] update logic for showing OIDC user profile --- client/src/components/User/UserPreferences.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index a5361afb2ed..68784370c18 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -19,6 +19,7 @@ import { import { computed, onMounted, ref } from "vue"; import { getGalaxyInstance } from "@/app"; +import { hasSingleOidcProfile, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper"; import { getUserPreferencesModel } from "@/components/User/UserPreferencesModel"; import { useConfig } from "@/composables/config"; import { useConfirmDialog } from "@/composables/confirmDialog"; @@ -60,9 +61,12 @@ const activePreferences = computed(() => { const enabledPreferences = Object.entries(userPreferencesEntries).filter(([, value]) => !value.disabled); return Object.fromEntries(enabledPreferences); }); +// Show the OIDC profile management widget if local accounts disabled and OIDC profile is configured +// through a single provider const showOidcProfile = computed(() => { if (isConfigLoaded.value) { - return config.value.enable_oidc && config.value.oidc_profile_url; + const oidcConfig: OIDCConfig = config.value.oidc; + return config.value.enable_oidc && config.value.disable_local_accounts && hasSingleOidcProfile(oidcConfig); } else { return false; } From dc9bc7b9c89e59b36ebb8af8ef346b715ab4d68d Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 24 Nov 2025 16:09:07 +1100 Subject: [PATCH 12/33] move profile_url config to OIDC backend --- lib/galaxy/authnz/managers.py | 4 ++++ lib/galaxy/authnz/xsd/oidc_backends_config.xsd | 7 +++++++ lib/galaxy/config/schemas/config_schema.yml | 9 --------- lib/galaxy/managers/configuration.py | 1 - 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 8c1913867b0..0cdb598ba65 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -142,6 +142,8 @@ class AuthnzManager: self.app.config.oidc[idp]["end_user_registration_endpoint"] = self.oidc_backends_config[idp][ "end_user_registration_endpoint" ] + if "profile_url" in self.oidc_backends_config[idp]: + self.app.config.oidc[idp]["profile_url"] = self.oidc_backends_config[idp]["profile_url"] if len(self.oidc_backends_config) == 0: raise etree.ParseError("No valid provider configuration parsed.") @@ -185,6 +187,8 @@ class AuthnzManager: rtv["username_key"] = config_xml.find("username_key").text if config_xml.find("end_user_registration_endpoint") is not None: rtv["end_user_registration_endpoint"] = config_xml.find("end_user_registration_endpoint").text + if config_xml.find("profile_url") is not None: + rtv["profile_url"] = config_xml.find("profile_url").text # this is a EGI Check-in specific config if config_xml.find("checkin_env") is not None: diff --git a/lib/galaxy/authnz/xsd/oidc_backends_config.xsd b/lib/galaxy/authnz/xsd/oidc_backends_config.xsd index e0b4be547f2..610380f21fd 100644 --- a/lib/galaxy/authnz/xsd/oidc_backends_config.xsd +++ b/lib/galaxy/authnz/xsd/oidc_backends_config.xsd @@ -170,6 +170,13 @@ + + + + URL for profile management in the OIDC provider. + + + diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index a5bf2378b01..4ad872dd735 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -3084,15 +3084,6 @@ mapping: desc: | Enables and disables OpenID Connect (OIDC) support. - oidc_profile_url: - type: str - required: false - desc: | - If specified, user profile information (email, username, password) will be managed - by the OIDC provider. A widget will show the current email and username in the user menu, - with a link to the OIDC provider's user profile page to edit them. - Use with enable_account_interface=false. - oidc_config_file: type: str default: oidc_config.xml diff --git a/lib/galaxy/managers/configuration.py b/lib/galaxy/managers/configuration.py index f7615fb03db..30081f54778 100644 --- a/lib/galaxy/managers/configuration.py +++ b/lib/galaxy/managers/configuration.py @@ -148,7 +148,6 @@ class ConfigSerializer(base.ModelSerializer): "use_remote_user": _defaults_to(None), # schema default is False; or config.single_user "single_user": _config_is_truthy, "enable_oidc": _use_config, - "oidc_profile_url": _use_config, "oidc": _use_config, "prefer_custos_login": _use_config, "enable_quotas": _use_config, From 017d4b61ed302203e30659eda6d7734a134e167a Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 25 Nov 2025 09:02:41 +1100 Subject: [PATCH 13/33] lint fixes --- client/src/components/User/UserOidcProfile.vue | 2 +- client/src/components/User/UserPreferences.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue index 8b9a20f5bb4..3a05da44c72 100644 --- a/client/src/components/User/UserOidcProfile.vue +++ b/client/src/components/User/UserOidcProfile.vue @@ -67,7 +67,7 @@ const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value {{ localize( - "Your username is an identifier that will be used to generate addresses for information you share publicly." + "Your username is an identifier that will be used to generate addresses for information you share publicly.", ) }} diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index 68784370c18..6172631b0d0 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -100,7 +100,7 @@ async function makeDataPrivate() { "of your new data in these histories is created as private. Any " + "datasets within that are currently shared will need " + "to be re-shared or published. Are you sure you " + - "want to do this?" + "want to do this?", ), { title: "Do you want to make all data private?", @@ -108,7 +108,7 @@ async function makeDataPrivate() { cancelTitle: "No, do not make data private", cancelVariant: "outline-primary", centered: true, - } + }, ); if (confirmed) { axios.post(withPrefix(`/history/make_private?all_histories=true`)).then(() => { From 0e6971257242eced8a694177408c5bd60a9089ea Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 25 Nov 2025 09:14:08 +1100 Subject: [PATCH 14/33] type-checking fixes --- client/src/components/User/UserOidcProfile.vue | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue index 3a05da44c72..617b621236f 100644 --- a/client/src/components/User/UserOidcProfile.vue +++ b/client/src/components/User/UserOidcProfile.vue @@ -4,6 +4,7 @@ import { faUser } from "font-awesome-6"; import { storeToRefs } from "pinia"; import { computed } from "vue"; +import { isRegisteredUser } from "@/api"; import { useConfig } from "@/composables/config"; import { useUserStore } from "@/stores/userStore"; import localize from "@/utils/localization"; @@ -18,8 +19,18 @@ const { currentUser } = storeToRefs(userStore); const breadcrumbItems = computed(() => [{ title: "User Preferences", to: "/user" }, { title: "Manage Profile" }]); -const username = computed(() => currentUser.value?.username || localize("Not available")); -const email = computed(() => currentUser.value?.email || localize("Not available")); +const username = computed(() => { + if (isRegisteredUser(currentUser.value)) { + return currentUser.value.username; + } + return localize("Not available"); +}); +const email = computed(() => { + if (isRegisteredUser(currentUser.value)) { + return currentUser.value.email; + } + return localize("Not available"); +}); const profileUrl = computed(() => { if (!isConfigLoaded.value) { // Need to return a value to ensure the GButton is From 33c2f6a7cf94f9f23a3be27dd80d433cfe56536f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 25 Nov 2025 09:44:16 +1100 Subject: [PATCH 15/33] unit tests for UserOidcProfile --- .../components/User/UserOidcProfile.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 client/src/components/User/UserOidcProfile.test.ts diff --git a/client/src/components/User/UserOidcProfile.test.ts b/client/src/components/User/UserOidcProfile.test.ts new file mode 100644 index 00000000000..a80556475a1 --- /dev/null +++ b/client/src/components/User/UserOidcProfile.test.ts @@ -0,0 +1,94 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getFakeRegisteredUser } from "@tests/test-data"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; + +import { useUserStore } from "@/stores/userStore"; + +import UserOidcProfile from "./UserOidcProfile.vue"; + +const PROFILE_URL = "https://profile.example.com"; +const MOCK_CONFIG = { + oidc_profile_url: PROFILE_URL, + oidc: { + provider: { + label: "Example Provider", + }, + }, +}; + +vi.mock("@/composables/config", () => ({ + useConfig: vi.fn(() => ({ + config: ref(MOCK_CONFIG), + isConfigLoaded: ref(true), + })), +})); + +vi.mock("@/components/Common/BreadcrumbHeading.vue", () => ({ + default: { + name: "BreadcrumbHeading", + props: ["items"], + render() { + return null; + }, + }, +})); + +vi.mock("@/components/BaseComponents/GButton.vue", () => ({ + default: { + name: "GButton", + props: ["href"], + render(h) { + return h( + "a", + { + attrs: { + "data-test": "profile-link", + href: this.href, + }, + }, + this.$slots.default + ); + }, + }, +})); + +const localVue = getLocalVue(); + +function mountProfile(userOverrides = {}) { + const pinia = createTestingPinia({ createSpy: vi.fn }); + const userStore = useUserStore(pinia); + userStore.currentUser = getFakeRegisteredUser(userOverrides); + + return mount(UserOidcProfile, { + localVue, + pinia, + stubs: { + FontAwesomeIcon: true, + }, + }); +} + +describe("UserOidcProfile", () => { + it("shows the profile link from config", async () => { + const wrapper = mountProfile(); + await wrapper.vm.$nextTick(); + + const profileButton = wrapper.findComponent({ name: "GButton" }); + expect(profileButton.exists()).toBe(true); + expect(profileButton.props("href")).toBe(PROFILE_URL); + }); + + it("displays username and email from the user store", async () => { + const username = "oidc_user"; + const email = "oidc_user@example.com"; + const wrapper = mountProfile({ username, email }); + await wrapper.vm.$nextTick(); + + const details = wrapper.findAll("dd"); + expect(details.at(0)!.text()).toBe(email); + expect(details.at(1)!.text()).toBe(username); + }); +}); From 68f4b3799a8d625bafe35b2c95a9c0fa33afa3f4 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 25 Nov 2025 09:48:49 +1100 Subject: [PATCH 16/33] prettier fixes --- client/src/components/User/UserOidcProfile.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/User/UserOidcProfile.test.ts b/client/src/components/User/UserOidcProfile.test.ts index a80556475a1..67c49489ce1 100644 --- a/client/src/components/User/UserOidcProfile.test.ts +++ b/client/src/components/User/UserOidcProfile.test.ts @@ -49,7 +49,7 @@ vi.mock("@/components/BaseComponents/GButton.vue", () => ({ href: this.href, }, }, - this.$slots.default + this.$slots.default, ); }, }, From d52f0a96f8e550ad869e4e840796a0c6f81e258e Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 25 Nov 2025 10:15:46 +1100 Subject: [PATCH 17/33] rework component test --- .../components/User/UserOidcProfile.test.ts | 36 ++++--------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/client/src/components/User/UserOidcProfile.test.ts b/client/src/components/User/UserOidcProfile.test.ts index 67c49489ce1..9a030660a67 100644 --- a/client/src/components/User/UserOidcProfile.test.ts +++ b/client/src/components/User/UserOidcProfile.test.ts @@ -4,10 +4,12 @@ import { getLocalVue } from "@tests/vitest/helpers"; import { mount } from "@vue/test-utils"; import { describe, expect, it, vi } from "vitest"; import { ref } from "vue"; +import VueRouter from "vue-router"; import { useUserStore } from "@/stores/userStore"; import UserOidcProfile from "./UserOidcProfile.vue"; +import GButton from "@/components/BaseComponents/GButton.vue"; const PROFILE_URL = "https://profile.example.com"; const MOCK_CONFIG = { @@ -26,45 +28,19 @@ vi.mock("@/composables/config", () => ({ })), })); -vi.mock("@/components/Common/BreadcrumbHeading.vue", () => ({ - default: { - name: "BreadcrumbHeading", - props: ["items"], - render() { - return null; - }, - }, -})); - -vi.mock("@/components/BaseComponents/GButton.vue", () => ({ - default: { - name: "GButton", - props: ["href"], - render(h) { - return h( - "a", - { - attrs: { - "data-test": "profile-link", - href: this.href, - }, - }, - this.$slots.default, - ); - }, - }, -})); - const localVue = getLocalVue(); +localVue.use(VueRouter); function mountProfile(userOverrides = {}) { const pinia = createTestingPinia({ createSpy: vi.fn }); const userStore = useUserStore(pinia); userStore.currentUser = getFakeRegisteredUser(userOverrides); + const router = new VueRouter(); return mount(UserOidcProfile, { localVue, pinia, + router, stubs: { FontAwesomeIcon: true, }, @@ -76,7 +52,7 @@ describe("UserOidcProfile", () => { const wrapper = mountProfile(); await wrapper.vm.$nextTick(); - const profileButton = wrapper.findComponent({ name: "GButton" }); + const profileButton = wrapper.findComponent(GButton); expect(profileButton.exists()).toBe(true); expect(profileButton.props("href")).toBe(PROFILE_URL); }); From 339db8b06da579eebb0ee5656d383236792a14e0 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 25 Nov 2025 10:37:25 +1100 Subject: [PATCH 18/33] fix logic for showing OIDC profile widget --- client/src/components/User/UserPreferences.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index 6172631b0d0..bc7ddf81b3c 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -63,7 +63,7 @@ const activePreferences = computed(() => { }); // Show the OIDC profile management widget if local accounts disabled and OIDC profile is configured // through a single provider -const showOidcProfile = computed(() => { +const showOidcProfile = computed(() => { if (isConfigLoaded.value) { const oidcConfig: OIDCConfig = config.value.oidc; return config.value.enable_oidc && config.value.disable_local_accounts && hasSingleOidcProfile(oidcConfig); @@ -183,7 +183,7 @@ onMounted(async () => {
Date: Tue, 25 Nov 2025 10:38:43 +1100 Subject: [PATCH 19/33] fix prop order --- client/src/components/User/UserPreferences.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index bc7ddf81b3c..acc25836223 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -182,8 +182,8 @@ onMounted(async () => {
Date: Tue, 25 Nov 2025 10:49:04 +1100 Subject: [PATCH 20/33] add tests of UserPreferences display logic --- .../components/User/UserPreferences.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 client/src/components/User/UserPreferences.test.ts diff --git a/client/src/components/User/UserPreferences.test.ts b/client/src/components/User/UserPreferences.test.ts new file mode 100644 index 00000000000..212eaa2198a --- /dev/null +++ b/client/src/components/User/UserPreferences.test.ts @@ -0,0 +1,108 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { shallowMount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import { computed } from "vue"; +import VueRouter from "vue-router"; + +import { hasSingleOidcProfile } from "@/components/User/ExternalIdentities/ExternalIDHelper"; +import { useConfig } from "@/composables/config"; + +import UserPreferences from "./UserPreferences.vue"; + +vi.mock("@/composables/config", () => ({ + useConfig: vi.fn(), +})); + +vi.mock("@/components/User/ExternalIdentities/ExternalIDHelper", () => ({ + hasSingleOidcProfile: vi.fn(), +})); + +vi.mock("@/app", () => ({ + getGalaxyInstance: () => ({ + session_csrf_token: "mock-token", + }), +})); + +vi.mock("@/components/User/UserPreferencesModel", () => ({ + getUserPreferencesModel: () => ({}), +})); + +vi.mock("@/composables/confirmDialog", () => ({ + useConfirmDialog: () => ({ + confirm: vi.fn(), + }), +})); + +const localVue = getLocalVue(); +localVue.use(VueRouter); + +describe("UserPreferences.vue", () => { + it("shows oidc-profile element when OIDC is enabled and configured correctly", async () => { + vi.mocked(useConfig).mockReturnValue({ + config: computed(() => ({ + enable_oidc: true, + disable_local_accounts: true, + oidc: {}, + themes: [], + })), + isConfigLoaded: computed(() => true), + }); + + vi.mocked(hasSingleOidcProfile).mockReturnValue(true); + + const wrapper = shallowMount(UserPreferences, { + localVue, + router: new VueRouter(), + pinia: createTestingPinia({ createSpy: vi.fn }), + stubs: { + BreadcrumbHeading: true, + UserDetailsElement: true, + UserPreferencesElement: true, + Heading: true, + BAlert: true, + BModal: true, + UserPickTheme: true, + UserBeaconSettings: true, + UserPreferredObjectStore: true, + UserDeletion: true, + }, + }); + + expect(wrapper.find("#oidc-profile").exists()).toBe(true); + }); + + it("does not show oidc-profile element when profile_url is not set", async () => { + vi.mocked(useConfig).mockReturnValue({ + config: computed(() => ({ + enable_oidc: true, + disable_local_accounts: true, + oidc: {}, + themes: [], + })), + isConfigLoaded: computed(() => true), + }); + + vi.mocked(hasSingleOidcProfile).mockReturnValue(false); + + const wrapper = shallowMount(UserPreferences, { + localVue, + router: new VueRouter(), + pinia: createTestingPinia({ createSpy: vi.fn }), + stubs: { + BreadcrumbHeading: true, + UserDetailsElement: true, + UserPreferencesElement: true, + Heading: true, + BAlert: true, + BModal: true, + UserPickTheme: true, + UserBeaconSettings: true, + UserPreferredObjectStore: true, + UserDeletion: true, + }, + }); + + expect(wrapper.find("#oidc-profile").exists()).toBe(false); + }); +}); From 21a741aabe55b93162f5bf7209e981ed095b788f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 25 Nov 2025 11:50:36 +1100 Subject: [PATCH 21/33] test disabling password widget --- .../components/User/UserPreferences.test.ts | 107 ++++++++++++++++-- .../User/UserPreferencesModel.test.ts | 62 ++++++++++ 2 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 client/src/components/User/UserPreferencesModel.test.ts diff --git a/client/src/components/User/UserPreferences.test.ts b/client/src/components/User/UserPreferences.test.ts index 212eaa2198a..d7b1d9dda80 100644 --- a/client/src/components/User/UserPreferences.test.ts +++ b/client/src/components/User/UserPreferences.test.ts @@ -1,11 +1,13 @@ import { createTestingPinia } from "@pinia/testing"; import { getLocalVue } from "@tests/vitest/helpers"; import { shallowMount } from "@vue/test-utils"; -import { describe, expect, it, vi } from "vitest"; +import { faUnlockAlt } from "font-awesome-6"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { computed } from "vue"; import VueRouter from "vue-router"; import { hasSingleOidcProfile } from "@/components/User/ExternalIdentities/ExternalIDHelper"; +import { getUserPreferencesModel } from "@/components/User/UserPreferencesModel"; import { useConfig } from "@/composables/config"; import UserPreferences from "./UserPreferences.vue"; @@ -18,16 +20,16 @@ vi.mock("@/components/User/ExternalIdentities/ExternalIDHelper", () => ({ hasSingleOidcProfile: vi.fn(), })); +vi.mock("@/components/User/UserPreferencesModel", () => ({ + getUserPreferencesModel: vi.fn(), +})); + vi.mock("@/app", () => ({ getGalaxyInstance: () => ({ session_csrf_token: "mock-token", }), })); -vi.mock("@/components/User/UserPreferencesModel", () => ({ - getUserPreferencesModel: () => ({}), -})); - vi.mock("@/composables/confirmDialog", () => ({ useConfirmDialog: () => ({ confirm: vi.fn(), @@ -38,13 +40,33 @@ const localVue = getLocalVue(); localVue.use(VueRouter); describe("UserPreferences.vue", () => { + const mockPreferences = (passwordDisabled: boolean) => { + // @ts-expect-error - getUserPreferencesModel is mocked + vi.mocked(getUserPreferencesModel).mockReturnValue({ + password: { + id: "edit-preferences-password", + title: "Change Password", + description: "Edit your password.", + icon: faUnlockAlt, + disabled: passwordDisabled, + url: "/password", + redirect: "/user", + }, + }); + }; + + beforeEach(() => { + // Reset mocks + mockPreferences(false); + }); + it("shows oidc-profile element when OIDC is enabled and configured correctly", async () => { vi.mocked(useConfig).mockReturnValue({ config: computed(() => ({ enable_oidc: true, disable_local_accounts: true, oidc: {}, - themes: [], + themes: {}, })), isConfigLoaded: computed(() => true), }); @@ -78,7 +100,7 @@ describe("UserPreferences.vue", () => { enable_oidc: true, disable_local_accounts: true, oidc: {}, - themes: [], + themes: {}, })), isConfigLoaded: computed(() => true), }); @@ -105,4 +127,75 @@ describe("UserPreferences.vue", () => { expect(wrapper.find("#oidc-profile").exists()).toBe(false); }); + + it.each([ + [{ enable_account_interface: false }], + [{ enable_account_interface: true, use_remote_user: true }], + [{ enable_account_interface: true, disable_local_accounts: true }], + ])("hides password preference when config is %o", async (configOverrides) => { + // Mock the config returned by useConfig (used by the component for other things) + vi.mocked(useConfig).mockReturnValue({ + config: computed(() => ({ + enable_oidc: false, + disable_local_accounts: false, + oidc: {}, + themes: {}, + ...configOverrides, + })), + isConfigLoaded: computed(() => true), + }); + mockPreferences(true); + + const wrapper = shallowMount(UserPreferences, { + localVue, + router: new VueRouter(), + pinia: createTestingPinia({ createSpy: vi.fn }), + stubs: { + BreadcrumbHeading: true, + UserDetailsElement: true, + UserPreferencesElement: true, + Heading: true, + BAlert: true, + BModal: true, + UserPickTheme: true, + UserBeaconSettings: true, + UserPreferredObjectStore: true, + UserDeletion: true, + }, + }); + + expect(wrapper.find("#edit-preferences-password").exists()).toBe(false); + }); + + it("shows password preference when allowed", async () => { + vi.mocked(useConfig).mockReturnValue({ + config: computed(() => ({ + disable_local_accounts: false, + enable_account_interface: true, + use_remote_user: false, + themes: {}, + })), + isConfigLoaded: computed(() => true), + }); + mockPreferences(false); + const wrapper = shallowMount(UserPreferences, { + localVue, + router: new VueRouter(), + pinia: createTestingPinia({ createSpy: vi.fn }), + stubs: { + BreadcrumbHeading: true, + UserDetailsElement: true, + UserPreferencesElement: true, + Heading: true, + BAlert: true, + BModal: true, + UserPickTheme: true, + UserBeaconSettings: true, + UserPreferredObjectStore: true, + UserDeletion: true, + }, + }); + + expect(wrapper.find("#edit-preferences-password").exists()).toBe(true); + }); }); diff --git a/client/src/components/User/UserPreferencesModel.test.ts b/client/src/components/User/UserPreferencesModel.test.ts new file mode 100644 index 00000000000..062bce6c0f2 --- /dev/null +++ b/client/src/components/User/UserPreferencesModel.test.ts @@ -0,0 +1,62 @@ +import { createTestingPinia } from "@pinia/testing"; +import { setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { computed } from "vue"; + +import { getUserPreferencesModel } from "@/components/User/UserPreferencesModel"; +import { useConfig } from "@/composables/config"; + +vi.mock("@/composables/config", () => ({ + useConfig: vi.fn(), +})); + +const mockConfig = (cfg: any) => + vi.mocked(useConfig).mockReturnValue({ + config: computed(() => cfg), + isConfigLoaded: computed(() => true), + }); + +describe("getUserPreferencesModel", () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ createSpy: vi.fn })); + }); + + it("disables password when account interface is off", () => { + mockConfig({ + enable_account_interface: false, + use_remote_user: false, + disable_local_accounts: false, + has_user_tool_filters: false, + themes: {}, + }); + + const prefs = getUserPreferencesModel("user-id"); + expect(prefs.password.disabled).toBe(true); + }); + + it("disables password when using remote user", () => { + mockConfig({ + enable_account_interface: true, + use_remote_user: true, + disable_local_accounts: false, + has_user_tool_filters: false, + themes: {}, + }); + + const prefs = getUserPreferencesModel("user-id"); + expect(prefs.password.disabled).toBe(true); + }); + + it("enables password when local accounts allowed and interface enabled", () => { + mockConfig({ + enable_account_interface: true, + use_remote_user: false, + disable_local_accounts: false, + has_user_tool_filters: false, + themes: {}, + }); + + const prefs = getUserPreferencesModel("user-id"); + expect(prefs.password.disabled).toBe(false); + }); +}); From f8af6de260918c39e297801553548742a175a0d0 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 27 Nov 2025 14:35:23 +1100 Subject: [PATCH 22/33] rename variable to make clear we're disabling profile editing --- lib/galaxy/webapps/galaxy/api/users.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 487cd90eb70..bd1dee21a8b 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -823,12 +823,12 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController "username": username, } is_galaxy_app = trans.webapp.name == "galaxy" - show_account_interface = ( + allow_profile_edit = ( trans.app.config.enable_account_interface and not trans.app.config.use_remote_user and not trans.app.config.disable_local_accounts ) - if show_account_interface or not is_galaxy_app: + if allow_profile_edit or not is_galaxy_app: inputs.append( { "id": "email_input", @@ -844,7 +844,7 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController } ) if is_galaxy_app: - if show_account_interface: + if allow_profile_edit: inputs.append( { "id": "name_input", From 0075a851f73a37b98d7557d155e2b0e9ada2e31e Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 10 Dec 2025 15:29:49 +1100 Subject: [PATCH 23/33] make profile (email/username editing) dependent on enable_account_interface only --- lib/galaxy/webapps/galaxy/api/users.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index bd1dee21a8b..506872e8eb6 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -823,11 +823,7 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController "username": username, } is_galaxy_app = trans.webapp.name == "galaxy" - allow_profile_edit = ( - trans.app.config.enable_account_interface - and not trans.app.config.use_remote_user - and not trans.app.config.disable_local_accounts - ) + allow_profile_edit = trans.app.config.enable_account_interface if allow_profile_edit or not is_galaxy_app: inputs.append( { From 1f8d40528f0f1adc2129722735da82056eb889af Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 11 Dec 2025 10:39:06 +1100 Subject: [PATCH 24/33] fix check of OIDC profile URL --- .../User/ExternalIdentities/ExternalIDHelper.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts index 466caa29cc1..dcdf6705411 100644 --- a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts +++ b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts @@ -99,7 +99,15 @@ export function isOnlyOneOIDCProviderConfigured(config: OIDCConfig): boolean { } export function hasSingleOidcProfile(config: OIDCConfig): boolean { - return isOnlyOneOIDCProviderConfigured(config) && !!config.profile_url; + const providers = Object.keys(config); + if (providers.length !== 1) { + return false; + } + const idp = providers[0]; + if (idp === undefined) { + throw new Error("OIDC provider key is undefined."); + } + return isOnlyOneOIDCProviderConfigured(config) && !!config[idp]?.profile_url; } export async function redirectToSingleProvider(config: OIDCConfig): Promise { From 764a1fa6755807f3ff359d9c3db523c20f130ad2 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 11 Dec 2025 10:54:51 +1100 Subject: [PATCH 25/33] improve the way we get the provider config from OIDC config --- .../ExternalIdentities/ExternalIDHelper.ts | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts index dcdf6705411..ff8b44a1d2b 100644 --- a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts +++ b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts @@ -3,28 +3,18 @@ import axios from "axios"; import { withPrefix } from "@/utils/redirect"; import { rethrowSimple } from "@/utils/simple-error"; -/** Shape of the OIDC config object coming from Galaxy’s `/api/config`. */ -export type OIDCConfig = Record< - string, - { - icon?: string; - label?: string; - custom_button_text?: string; - end_user_registration_endpoint?: string; - profile_url?: string; - } ->; +export type OIDCConfigEntry = { + icon?: string; + label?: string; + custom_button_text?: string; + end_user_registration_endpoint?: string; + profile_url?: string; +}; -export type OIDCConfigWithRegistration = Record< - string, - { - icon?: string; - label?: string; - custom_button_text?: string; - end_user_registration_endpoint: string; - profile_url?: string; - } ->; +/** Shape of the OIDC config object coming from Galaxy’s `/api/config`. */ +export type OIDCConfig = Record; + +export type OIDCConfigWithRegistration = Record; /** Return the per-IDP config, minus anything the caller wants to hide. */ export function getFilteredOIDCIdps(oidcConfig: OIDCConfig, exclude: string[] = []): OIDCConfig { @@ -98,16 +88,24 @@ export function isOnlyOneOIDCProviderConfigured(config: OIDCConfig): boolean { return Object.keys(config).length === 1; } -export function hasSingleOidcProfile(config: OIDCConfig): boolean { +export function getSingleOidcConfig(config: OIDCConfig): OIDCConfigEntry | null { const providers = Object.keys(config); if (providers.length !== 1) { - return false; + return null; } const idp = providers[0]; if (idp === undefined) { throw new Error("OIDC provider key is undefined."); } - return isOnlyOneOIDCProviderConfigured(config) && !!config[idp]?.profile_url; + return config[idp] || null; +} + +export function hasSingleOidcProfile(config: OIDCConfig): boolean { + if (!isOnlyOneOIDCProviderConfigured(config)) { + return false; + } + const idp_config = getSingleOidcConfig(config); + return !!idp_config?.profile_url; } export async function redirectToSingleProvider(config: OIDCConfig): Promise { From f845b99a1dbce0be5107e5b61707696da6c38c1b Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 11 Dec 2025 11:30:41 +1100 Subject: [PATCH 26/33] rework getting provider details + profile URL --- .../src/components/User/UserOidcProfile.vue | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue index 617b621236f..2fbcd0ea09f 100644 --- a/client/src/components/User/UserOidcProfile.vue +++ b/client/src/components/User/UserOidcProfile.vue @@ -5,6 +5,7 @@ import { storeToRefs } from "pinia"; import { computed } from "vue"; import { isRegisteredUser } from "@/api"; +import { getSingleOidcConfig, type OIDCConfigEntry } from "@/components/User/ExternalIdentities/ExternalIDHelper"; import { useConfig } from "@/composables/config"; import { useUserStore } from "@/stores/userStore"; import localize from "@/utils/localization"; @@ -31,21 +32,29 @@ const email = computed(() => { } return localize("Not available"); }); -const profileUrl = computed(() => { +/** Get the config for the OIDC provider. Note we currently + * assume there is only a single OIDC provider when it is being + * used to manage profile details (username/email/password) + */ +const oidcProviderConfig = computed(() => { if (!isConfigLoaded.value) { + return null; + } + return getSingleOidcConfig(config.value.oidc); +}); +const profileUrl = computed(() => { + if (oidcProviderConfig.value === null) { // Need to return a value to ensure the GButton is // rendered as a link return "#"; } - return config.value.oidc_profile_url || ""; + return oidcProviderConfig.value.profile_url; }); const profileButtonLabel = computed(() => { - const providers = Object.keys(config.value.oidc) || []; - if (providers.length === 0 || providers === null) { - return null; + if (oidcProviderConfig.value === null) { + return "Update profile details"; } - const providerConfig = config.value.oidc[providers[0]!]; - const providerLabel = providerConfig.custom_button_text || providerConfig.label; + const providerLabel = oidcProviderConfig.value.custom_button_text || oidcProviderConfig.value.label; if (providerLabel) { return `Update profile details at ${providerLabel}`; } @@ -78,7 +87,7 @@ const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value {{ localize( - "Your username is an identifier that will be used to generate addresses for information you share publicly.", + "Your username is an identifier that will be used to generate addresses for information you share publicly." ) }} From c9b0147a2c48cfe933cb988b266aa15b336ea241 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 11 Dec 2025 11:43:21 +1100 Subject: [PATCH 27/33] update logic for showing OIDC user profile --- client/src/components/User/UserPreferences.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index acc25836223..04e40f050ad 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -61,12 +61,12 @@ const activePreferences = computed(() => { const enabledPreferences = Object.entries(userPreferencesEntries).filter(([, value]) => !value.disabled); return Object.fromEntries(enabledPreferences); }); -// Show the OIDC profile management widget if local accounts disabled and OIDC profile is configured +// Show the OIDC profile management widget if local account editing is disabled and OIDC profile is configured // through a single provider const showOidcProfile = computed(() => { if (isConfigLoaded.value) { const oidcConfig: OIDCConfig = config.value.oidc; - return config.value.enable_oidc && config.value.disable_local_accounts && hasSingleOidcProfile(oidcConfig); + return config.value.enable_oidc && !config.value.enable_account_interface && hasSingleOidcProfile(oidcConfig); } else { return false; } @@ -100,7 +100,7 @@ async function makeDataPrivate() { "of your new data in these histories is created as private. Any " + "datasets within that are currently shared will need " + "to be re-shared or published. Are you sure you " + - "want to do this?", + "want to do this?" ), { title: "Do you want to make all data private?", @@ -108,7 +108,7 @@ async function makeDataPrivate() { cancelTitle: "No, do not make data private", cancelVariant: "outline-primary", centered: true, - }, + } ); if (confirmed) { axios.post(withPrefix(`/history/make_private?all_histories=true`)).then(() => { From f84689f1e5dfe952b1d43b32e21bb64eb1aa8bb6 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 11 Dec 2025 11:43:47 +1100 Subject: [PATCH 28/33] disable profile editing for remote accounts --- lib/galaxy/webapps/galaxy/api/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 0e28a6602b4..1c17b9038d5 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -820,7 +820,7 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController "username": username, } is_galaxy_app = trans.webapp.name == "galaxy" - allow_profile_edit = trans.app.config.enable_account_interface + allow_profile_edit = trans.app.config.enable_account_interface and not trans.app.config.enable_account_interface if allow_profile_edit or not is_galaxy_app: inputs.append( { From 77c4e7f7df6848eddef09cf96cc0b708e32cf118 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 12 Dec 2025 10:29:01 +1100 Subject: [PATCH 29/33] fix allow_profile_edit logic --- lib/galaxy/webapps/galaxy/api/users.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 1c17b9038d5..786d4c32e13 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -820,7 +820,11 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController "username": username, } is_galaxy_app = trans.webapp.name == "galaxy" - allow_profile_edit = trans.app.config.enable_account_interface and not trans.app.config.enable_account_interface + allow_profile_edit = ( + trans.app.config.enable_account_interface + and not trans.app.config.use_remote_user + and not trans.app.config.disable_local_accounts + ) if allow_profile_edit or not is_galaxy_app: inputs.append( { From aa455dd935d253b90f44fbd27bcedb66aaaca6ce Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 12 Dec 2025 10:36:26 +1100 Subject: [PATCH 30/33] fix test of profile URL display --- client/src/components/User/UserOidcProfile.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/User/UserOidcProfile.test.ts b/client/src/components/User/UserOidcProfile.test.ts index 9a030660a67..dada59dbbf1 100644 --- a/client/src/components/User/UserOidcProfile.test.ts +++ b/client/src/components/User/UserOidcProfile.test.ts @@ -13,10 +13,10 @@ import GButton from "@/components/BaseComponents/GButton.vue"; const PROFILE_URL = "https://profile.example.com"; const MOCK_CONFIG = { - oidc_profile_url: PROFILE_URL, oidc: { provider: { label: "Example Provider", + profile_url: PROFILE_URL, }, }, }; From 1f3d3ebbc57908e07c61bd6baa8a5129f7da0d21 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 12 Dec 2025 10:43:08 +1100 Subject: [PATCH 31/33] run client formatter --- client/src/components/User/UserOidcProfile.vue | 2 +- client/src/components/User/UserPreferences.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue index 2fbcd0ea09f..4f1fbb6be26 100644 --- a/client/src/components/User/UserOidcProfile.vue +++ b/client/src/components/User/UserOidcProfile.vue @@ -87,7 +87,7 @@ const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value {{ localize( - "Your username is an identifier that will be used to generate addresses for information you share publicly." + "Your username is an identifier that will be used to generate addresses for information you share publicly.", ) }} diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index 04e40f050ad..41bf063c9bb 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -100,7 +100,7 @@ async function makeDataPrivate() { "of your new data in these histories is created as private. Any " + "datasets within that are currently shared will need " + "to be re-shared or published. Are you sure you " + - "want to do this?" + "want to do this?", ), { title: "Do you want to make all data private?", @@ -108,7 +108,7 @@ async function makeDataPrivate() { cancelTitle: "No, do not make data private", cancelVariant: "outline-primary", centered: true, - } + }, ); if (confirmed) { axios.post(withPrefix(`/history/make_private?all_histories=true`)).then(() => { From 260377555a85e254ae7fc508edef8a31351b9cf4 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 16 Dec 2025 09:45:22 +1100 Subject: [PATCH 32/33] update redirect logic for user/oidc-profile route --- client/src/entry/analysis/router.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index ed157eadb63..ec8105cd640 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -5,6 +5,7 @@ import { getGalaxyInstance } from "@/app"; import { HistoryExport } from "@/components/HistoryExport/index"; import { APIKey } from "@/components/User/APIKey"; import { ExternalIdentities } from "@/components/User/ExternalIdentities"; +import { hasSingleOidcProfile } from "@/components/User/ExternalIdentities/ExternalIDHelper"; import AdminRoutes from "@/entry/analysis/routes/admin-routes"; import LibraryRoutes from "@/entry/analysis/routes/library-routes"; import StorageRoutes from "@/entry/analysis/routes/storage-routes"; @@ -618,7 +619,13 @@ export function getRouter(Galaxy) { { path: "user/oidc-profile", component: UserOidcProfile, - redirect: redirectAnon(), + redirect: + redirectIf( + !Galaxy.config.enable_oidc || + Galaxy.config.enable_account_interface || + !hasSingleOidcProfile(Galaxy.config.oidc), + "/user" + ) || redirectAnon(), }, { path: "user/external_ids", From d0004f65ec20462b4753a3450149b612670dd4d9 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 16 Dec 2025 10:37:06 +1100 Subject: [PATCH 33/33] format fix --- client/src/entry/analysis/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index ec8105cd640..2c748744d86 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -624,7 +624,7 @@ export function getRouter(Galaxy) { !Galaxy.config.enable_oidc || Galaxy.config.enable_account_interface || !hasSingleOidcProfile(Galaxy.config.oidc), - "/user" + "/user", ) || redirectAnon(), }, {