Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions client/src/stores/userStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,6 +33,7 @@ export const useUserStore = defineStore("userStore", () => {
const currentUser = ref<AnyUser>(null);
const currentPreferences = ref<Preferences | null>(null);
const { hashedUserId } = useHashedUserId(currentUser);
const toast = useToast();

const currentListViewPreferences = useUserLocalStorageFromHashId<UserListViewPreferences>(
"user-store-list-view-preferences",
Expand Down Expand Up @@ -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<string, string> = {
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) {
Expand Down
15 changes: 15 additions & 0 deletions client/src/stores/users/queries.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -12,6 +15,18 @@ export async function getCurrentUser() {
return data;
}

export type ProfileUpdatesResponse = { updates: string[] };

export async function getProfileUpdates(): Promise<ProfileUpdatesResponse> {
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" } },
Expand Down
51 changes: 50 additions & 1 deletion lib/galaxy/authnz/psa_authnz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
)

Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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
):
Expand Down
43 changes: 43 additions & 0 deletions lib/galaxy/managers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 24 additions & 27 deletions lib/galaxy/webapps/galaxy/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.<br>Verification email has been sent to your new email address. Please verify it by clicking the activation link in the email.<br>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|"
Expand Down
Loading
Loading