diff --git a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts index bf3de124478..a3ef9ae5417 100644 --- a/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts +++ b/client/src/components/User/ExternalIdentities/ExternalIDHelper.ts @@ -3,26 +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; - } ->; +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; - } ->; +/** 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 { @@ -95,6 +87,26 @@ export function isOnlyOneOIDCProviderConfigured(config: OIDCConfig): boolean { return Object.keys(config).length === 1; } +export function getSingleOidcConfig(config: OIDCConfig): OIDCConfigEntry | null { + const providers = Object.keys(config); + if (providers.length !== 1) { + return null; + } + const idp = providers[0]; + if (idp === undefined) { + throw new Error("OIDC provider key is undefined."); + } + 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 { const providers = Object.keys(config); diff --git a/client/src/components/User/UserOidcProfile.test.ts b/client/src/components/User/UserOidcProfile.test.ts new file mode 100644 index 00000000000..dada59dbbf1 --- /dev/null +++ b/client/src/components/User/UserOidcProfile.test.ts @@ -0,0 +1,70 @@ +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 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 = { + oidc: { + provider: { + label: "Example Provider", + profile_url: PROFILE_URL, + }, + }, +}; + +vi.mock("@/composables/config", () => ({ + useConfig: vi.fn(() => ({ + config: ref(MOCK_CONFIG), + isConfigLoaded: ref(true), + })), +})); + +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, + }, + }); +} + +describe("UserOidcProfile", () => { + it("shows the profile link from config", async () => { + const wrapper = mountProfile(); + await wrapper.vm.$nextTick(); + + const profileButton = wrapper.findComponent(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); + }); +}); diff --git a/client/src/components/User/UserOidcProfile.vue b/client/src/components/User/UserOidcProfile.vue new file mode 100644 index 00000000000..4f1fbb6be26 --- /dev/null +++ b/client/src/components/User/UserOidcProfile.vue @@ -0,0 +1,115 @@ + + + diff --git a/client/src/components/User/UserPreferences.test.ts b/client/src/components/User/UserPreferences.test.ts new file mode 100644 index 00000000000..d7b1d9dda80 --- /dev/null +++ b/client/src/components/User/UserPreferences.test.ts @@ -0,0 +1,201 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { shallowMount } from "@vue/test-utils"; +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"; + +vi.mock("@/composables/config", () => ({ + useConfig: vi.fn(), +})); + +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("@/composables/confirmDialog", () => ({ + useConfirmDialog: () => ({ + confirm: vi.fn(), + }), +})); + +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: {}, + })), + 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); + }); + + 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/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index ede9aa55c77..41bf063c9bb 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, @@ -18,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"; @@ -59,6 +61,16 @@ const activePreferences = computed(() => { const enabledPreferences = Object.entries(userPreferencesEntries).filter(([, value]) => !value.disabled); return Object.fromEntries(enabledPreferences); }); +// 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.enable_account_interface && hasSingleOidcProfile(oidcConfig); + } else { + return false; + } +}); const hasLogout = computed(() => { if (isConfigLoaded.value) { const Galaxy = getGalaxyInstance(); @@ -169,6 +181,14 @@ onMounted(async () => {
+ + ({ + 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); + }); +}); 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"), diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index 7b90b758e82..2c748744d86 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"; @@ -81,6 +82,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"; @@ -614,6 +616,17 @@ export function getRouter(Galaxy) { component: CredentialsManagement, redirect: redirectAnon(), }, + { + path: "user/oidc-profile", + component: UserOidcProfile, + redirect: + redirectIf( + !Galaxy.config.enable_oidc || + Galaxy.config.enable_account_interface || + !hasSingleOidcProfile(Galaxy.config.oidc), + "/user", + ) || redirectAnon(), + }, { path: "user/external_ids", component: ExternalIdentities, diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 5f3e1356db1..6ea6d206d65 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -131,6 +131,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.") @@ -174,6 +176,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 09e98bcfc7e..896e8a050c7 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: diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 0cd485d8120..786d4c32e13 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -820,7 +820,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: + 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( { "id": "email_input", @@ -836,7 +841,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 allow_profile_edit: inputs.append( { "id": "name_input",