diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 13b49598d887..73f22df57cb4 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -5452,6 +5452,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/users/current/profile_updates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return and clear recent profile updates applied from external auth. */ + get: operations["get_profile_updates_api_users_current_profile_updates_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/current/recalculate_disk_usage": { parameters: { query?: never; @@ -42961,6 +42978,49 @@ export interface operations { }; }; }; + get_profile_updates_api_users_current_profile_updates_get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string[]; + }; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; recalculate_disk_usage_api_users_current_recalculate_disk_usage_put: { parameters: { query?: never; diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts b/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts index 444ad7837651..26521609e7f8 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts @@ -50,16 +50,20 @@ const TYPE = 1; const toastMock = vi.fn((message, type: "success" | "info") => { return { message, type }; }); -vi.mock("@/composables/toast", () => ({ - Toast: { - success: vi.fn().mockImplementation((message) => { +vi.mock("@/composables/toast", () => { + const toast = { + success: vi.fn().mockImplementation((message: string) => { toastMock(message, "success"); }), - info: vi.fn().mockImplementation((message) => { + info: vi.fn().mockImplementation((message: string) => { toastMock(message, "info"); }), - }, -})); + }; + return { + Toast: toast, + useToast: () => toast, + }; +}); // Mock "@/utils/clipboard" const writeText = vi.fn(); diff --git a/client/src/stores/userStore.ts b/client/src/stores/userStore.ts index bc6ecb77e728..95ada2fdba35 100644 --- a/client/src/stores/userStore.ts +++ b/client/src/stores/userStore.ts @@ -3,11 +3,14 @@ import { computed, ref } from "vue"; import { type AnyUser, isAdminUser, isAnonymousUser, isRegisteredUser, type RegisteredUser } from "@/api"; import { useHashedUserId } from "@/composables/hashedUserId"; +import { useToast } from "@/composables/toast"; import { useUserLocalStorageFromHashId } from "@/composables/userLocalStorageFromHashedId"; import { useHistoryStore } from "@/stores/historyStore"; import { addFavoriteToolQuery, getCurrentUser, + getProfileUpdates, + type ProfileUpdatesResponse, removeFavoriteToolQuery, setCurrentThemeQuery, } from "@/stores/users/queries"; @@ -30,6 +33,7 @@ export const useUserStore = defineStore("userStore", () => { const currentUser = ref(null); const currentPreferences = ref(null); const { hashedUserId } = useHashedUserId(currentUser); + const toast = useToast(); const currentListViewPreferences = useUserLocalStorageFromHashId( "user-store-list-view-preferences", @@ -89,6 +93,24 @@ export const useUserStore = defineStore("userStore", () => { if (isRegisteredUser(user)) { currentUser.value = user; currentPreferences.value = processUserPreferences(user); + // One-time profile update notice from backend (cleared server-side). + const updates: ProfileUpdatesResponse = await getProfileUpdates(); + if (updates?.updates && updates.updates.length > 0) { + const labels: Record = { + email: "email address", + username: "public name", + fullname: "full name", + }; + const fieldList = updates.updates + .map((field) => labels[field] || field) + .filter((field, idx, arr) => field && arr.indexOf(field) === idx); + if (fieldList.length > 0) { + toast.info( + `Your profile was updated from your identity provider: ${fieldList.join(", ")}.`, + "Profile updated", + ); + } + } } else if (isAnonymousUser(user)) { currentUser.value = user; } else if (user === null) { diff --git a/client/src/stores/users/queries.ts b/client/src/stores/users/queries.ts index 5cce60926400..4b26577d4dab 100644 --- a/client/src/stores/users/queries.ts +++ b/client/src/stores/users/queries.ts @@ -1,4 +1,7 @@ +import axios from "axios"; + import { GalaxyApi } from "@/api"; +import { getAppRoot } from "@/onload/loadConfig"; import { rethrowSimple } from "@/utils/simple-error"; export async function getCurrentUser() { @@ -12,6 +15,18 @@ export async function getCurrentUser() { return data; } +export type ProfileUpdatesResponse = { updates: string[] }; + +export async function getProfileUpdates(): Promise { + try { + const { data } = await axios.get(`${getAppRoot()}api/users/current/profile_updates`); + return data as ProfileUpdatesResponse; + } catch (e) { + // If the endpoint is unavailable (e.g. in tests without a mock), fall back silently. + return { updates: [] }; + } +} + export async function addFavoriteToolQuery(userId: string, toolId: string) { const { data, error } = await GalaxyApi().PUT("/api/users/{user_id}/favorites/{object_type}", { params: { path: { user_id: userId, object_type: "tools" } }, diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index 5037ba86c2a7..ba61d6777732 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -21,7 +21,9 @@ from sqlalchemy import func from sqlalchemy.exc import IntegrityError +from galaxy import exceptions as galaxy_exceptions from galaxy.exceptions import MalformedContents +from galaxy.managers import users as user_managers from galaxy.model import ( PSAAssociation, PSACode, @@ -127,6 +129,7 @@ "social_core.pipeline.social_auth.load_extra_data", # Update the user record with any changed info from the auth service. "social_core.pipeline.user.user_details", + "galaxy.authnz.psa_authnz.sync_user_profile", "galaxy.authnz.psa_authnz.decode_access_token", ) @@ -317,7 +320,8 @@ def callback(self, state_token, authz_code, trans, login_redirect_url): # Always set LOGIN_REDIRECT_URL to the base URL for pipeline steps # We'll adjust the final redirect based on fixed_delegated_auth after do_complete self.config[setting_name("LOGIN_REDIRECT_URL")] = login_redirect_url - + # Make Galaxy app/trans available to downstream pipeline steps that need to apply Galaxy-specific invariants. + self.config["GALAXY_TRANS"] = trans strategy = Strategy(trans.request, trans.session, Storage, self.config) strategy.session_set(f"{BACKENDS_NAME[self.config['provider']]}_state", state_token) backend = self._load_backend(strategy, self.config["redirect_uri"]) @@ -737,6 +741,51 @@ def verify(strategy=None, response=None, details=None, **kwargs): raise Exception(f"`{provider}` is an unsupported secondary authorization provider, contact admin.") +def sync_user_profile(strategy=None, details=None, user=None, **kwargs): + """ + Apply Galaxy-specific invariants (email + private role, validated public name) after PSA updates the user model. + """ + if not strategy or not user: + return + trans = strategy.config.get("GALAXY_TRANS") + if not trans: + log.debug("OIDC sync_user_profile skipped: no Galaxy transaction available.") + return + if trans.app.config.enable_account_interface: + log.debug("OIDC sync_user_profile skipped: account interface enabled.") + return + manager = getattr(trans.app, "user_manager", None) or user_managers.UserManager(trans.app) + updates: list[str] = [] + # Update email and keep private role in sync + if details and details.get("email"): + try: + manager.update_email(trans, user, details["email"], commit=False, send_activation_email=False) + updates.append("email") + except galaxy_exceptions.MessageException as exc: + log.warning("OIDC email sync skipped for user %s: %s", user.id, exc) + # Update public name with Galaxy validation + if details and details.get("username"): + try: + manager.update_username(trans, user, details["username"], commit=False) + updates.append("username") + except galaxy_exceptions.MessageException as exc: + log.warning("OIDC username sync skipped for user %s: %s", user.id, exc) + if updates: + trans.sa_session.add(user) + trans.sa_session.commit() + existing = [] + try: + existing_raw = user.preferences.get("profile_updates") + if existing_raw: + existing = json.loads(existing_raw) + except Exception: + existing = [] + merged = sorted(set(existing + updates)) + user.preferences["profile_updates"] = json.dumps(merged) + trans.sa_session.add(user) + trans.sa_session.commit() + + def allowed_to_disconnect( name=None, user=None, user_storage=None, strategy=None, backend=None, request=None, details=None, **kwargs ): diff --git a/lib/galaxy/managers/users.py b/lib/galaxy/managers/users.py index 79119941b8e5..071443eb948b 100644 --- a/lib/galaxy/managers/users.py +++ b/lib/galaxy/managers/users.py @@ -154,6 +154,49 @@ def create(self, email=None, username=None, password=None, **kwargs): raise exceptions.Conflict(str(db_err)) return user + def update_email( + self, trans, user: User, new_email: str, *, commit: bool = True, send_activation_email: bool = True + ) -> None: + """ + Update a user's email address, keeping the private role in sync and honoring activation settings. + Raises RequestParameterInvalidException on validation errors. + """ + message = validate_email(trans, new_email, user) + if message: + raise exceptions.RequestParameterInvalidException(message) + if user.email == new_email: + return + private_role = trans.app.security_agent.get_private_user_role(user) + private_role.name = new_email + private_role.description = f"Private role for {new_email}" + user.email = new_email + session = self.session() + session.add_all([user, private_role]) + if trans.app.config.user_activation_on: + user.active = False + if send_activation_email and not self.send_activation_email(trans, user.email, user.username): + error_message = "Unable to send activation email, please contact your local Galaxy administrator." + if trans.app.config.error_email_to is not None: + error_message += f" Contact: {trans.app.config.error_email_to}" + raise exceptions.InternalServerError(error_message) + if commit: + session.commit() + + def update_username(self, trans, user: User, new_username: str, *, commit: bool = True) -> None: + """ + Update a user's public name after validating it. Raises RequestParameterInvalidException on validation errors. + """ + message = validate_publicname(trans, new_username, user) + if message: + raise exceptions.RequestParameterInvalidException(message) + if user.username == new_username: + return + user.username = new_username + session = self.session() + session.add(user) + if commit: + session.commit() + def delete(self, user, flush=True): """Mark the given user deleted.""" if not self.app.config.allow_user_deletion: diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index c9ceb8cf2efb..8ee17a593405 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -708,6 +708,26 @@ def delete( raise exceptions.InsufficientPermissionsException("You may only delete your own account.") return self.service.user_to_detailed_model(user_to_update) + @router.get( + "/api/users/current/profile_updates", + name="get_profile_updates", + summary="Return and clear recent profile updates applied from external auth.", + ) + def profile_updates(self, trans: ProvidesUserContext = DependsOnTrans) -> dict[str, list[str]]: + updates: list[str] = [] + if trans.user and trans.user.preferences: + raw_updates = trans.user.preferences.get("profile_updates") + if raw_updates: + try: + updates = json.loads(raw_updates) + except Exception: + updates = [] + # clear after reading + del trans.user.preferences["profile_updates"] + trans.sa_session.add(trans.user) + trans.sa_session.commit() + return {"updates": updates} + @router.post( "/api/users/{user_id}/send_activation_email", name="send_activation_email", @@ -946,36 +966,13 @@ def set_information(self, trans, id, payload=None, **kwd): # Update email if "email" in payload: email = payload.get("email") - message = validate_email(trans, email, user) - if message: - raise exceptions.RequestParameterInvalidException(message) - if user.email != email: - # Update user email and user's private role name which must match - private_role = trans.app.security_agent.get_private_user_role(user) - private_role.name = email - private_role.description = f"Private role for {email}" - user.email = email - trans.sa_session.add(user) - trans.sa_session.add(private_role) - trans.sa_session.commit() - if trans.app.config.user_activation_on: - # Deactivate the user if email was changed and activation is on. - user.active = False - if self.user_manager.send_activation_email(trans, user.email, user.username): - message = "The login information has been updated with the changes.
Verification email has been sent to your new email address. Please verify it by clicking the activation link in the email.
Please check your spam/trash folder in case you cannot find the message." - else: - message = "Unable to send activation email, please contact your local Galaxy administrator." - if trans.app.config.error_email_to is not None: - message += f" Contact: {trans.app.config.error_email_to}" - raise exceptions.InternalServerError(message) + self.user_manager.update_email( + trans, user, email, commit=False, send_activation_email=True # commit at the end of the handler + ) # Update public name if "username" in payload: username = payload.get("username") - message = validate_publicname(trans, username, user) - if message: - raise exceptions.RequestParameterInvalidException(message) - if user.username != username: - user.username = username + self.user_manager.update_username(trans, user, username, commit=False) # Update user custom form if user_info_form_id := payload.get("info|form_id"): prefix = "info|" diff --git a/test/unit/authnz/test_psa_authnz.py b/test/unit/authnz/test_psa_authnz.py index c62632748863..fce0339185a4 100644 --- a/test/unit/authnz/test_psa_authnz.py +++ b/test/unit/authnz/test_psa_authnz.py @@ -1,4 +1,5 @@ import base64 +import json import secrets import uuid from collections import defaultdict @@ -36,6 +37,7 @@ AUTH_PIPELINE, decode_access_token, PSAAuthnz, + sync_user_profile, ) @@ -367,3 +369,40 @@ def test_oidc_config_custom_auth_pipeline_and_extra(mock_oidc_config_file, mock_ app_config=mock_app.config, ) assert psa_authnz.config["SOCIAL_AUTH_PIPELINE"] == custom_auth_pipeline + tuple(custom_auth_pipeline_extra) + + +def test_sync_user_profile_skips_when_account_interface_enabled(): + manager = MagicMock() + session = MagicMock() + app_config = SimpleNamespace(enable_account_interface=True) + app = SimpleNamespace(config=app_config, user_manager=manager) + trans = SimpleNamespace(app=app, sa_session=session) + strategy = SimpleNamespace(config={"GALAXY_TRANS": trans}) + user = SimpleNamespace(id=1, preferences={}) + details = {"email": "new@example.com", "username": "newname"} + + sync_user_profile(strategy=strategy, details=details, user=user) + + manager.update_email.assert_not_called() + manager.update_username.assert_not_called() + session.commit.assert_not_called() + + +def test_sync_user_profile_updates_when_account_interface_disabled(): + manager = MagicMock() + session = MagicMock() + app_config = SimpleNamespace(enable_account_interface=False) + app = SimpleNamespace(config=app_config, user_manager=manager) + trans = SimpleNamespace(app=app, sa_session=session) + strategy = SimpleNamespace(config={"GALAXY_TRANS": trans}) + user = SimpleNamespace(id=2, preferences={}) + details = {"email": "new@example.com", "username": "newname"} + + sync_user_profile(strategy=strategy, details=details, user=user) + + manager.update_email.assert_called_once_with( + trans, user, "new@example.com", commit=False, send_activation_email=False + ) + manager.update_username.assert_called_once_with(trans, user, "newname", commit=False) + assert json.loads(user.preferences["profile_updates"]) == ["email", "username"] + assert session.commit.call_count == 2