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 @@ -5174,6 +5174,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 @@ -40966,6 +40983,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
47 changes: 47 additions & 0 deletions lib/galaxy/authnz/psa_authnz.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
)
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 @@ -104,6 +106,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 @@ -248,6 +251,8 @@ def authenticate(self, trans, idphint=None):
def callback(self, state_token, authz_code, trans, login_redirect_url):
on_the_fly_config(trans.sa_session)
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 @@ -458,6 +463,48 @@ 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
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
53 changes: 53 additions & 0 deletions lib/galaxy/managers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,59 @@ 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
) -> Optional[str]:
"""
Update a user's email address, keeping the private role in sync and honoring activation settings.
Returns an optional informational message (activation email sent notice), or None if unchanged.
Raises RequestParameterInvalidException on validation errors.
"""
message = validate_email(trans, new_email, user)
if message:
raise exceptions.RequestParameterInvalidException(message)
if user.email == new_email:
return None
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:
if self.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)
if commit:
session.commit()
return message
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The returned message is not used anywhere? In fact, the original code also appears to be ignoring the message it's storing, so it looks like the whole thing is unused.


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 @@ -713,6 +713,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