From faaa1d048cfdacbe9d0d4eb4be509c38a0e233d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:26:09 +0000 Subject: [PATCH 1/4] fix(station): remove a deleted user group from its members Removing a user group now also removes it from the users that are still members of it, including inactive users. On upgrade, the station also drops the ids of user groups that no longer exist from user records, so user records stay consistent with the existing user groups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011s2DjdDkqmvLbmPneUM22j --- core/station/impl/src/services/system.rs | 49 +++++++++++++- core/station/impl/src/services/user_group.rs | 69 +++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/core/station/impl/src/services/system.rs b/core/station/impl/src/services/system.rs index 623deeec1..1f69247f0 100644 --- a/core/station/impl/src/services/system.rs +++ b/core/station/impl/src/services/system.rs @@ -514,6 +514,24 @@ impl SystemService { NAMED_RULE_REPOSITORY.build_cache(); } + /// Removes the ids of user groups that no longer exist from the user records. + /// + /// Must only be called after the cache is initialized. + fn remove_missing_user_groups_from_users(&self) { + for mut user in USER_REPOSITORY.list() { + let groups_count = user.groups.len(); + + user.groups + .retain(|group_id| USER_GROUP_REPOSITORY.get(group_id).is_some()); + + if user.groups.len() != groups_count { + user.last_modification_timestamp = next_time(); + + USER_REPOSITORY.insert(user.to_key(), user); + } + } + } + /// Initializes the canister with the given owners and settings. /// /// Must only be called within a canister init call. @@ -606,6 +624,8 @@ impl SystemService { // initializes the cache of the canister data, must happen during the same call as the upgrade self.init_cache(); + self.remove_missing_user_groups_from_users(); + // recompute all metrics to make sure they are up to date, only gauges are recomputed // since they are the only ones that can change over time. recompute_metrics(); @@ -1286,7 +1306,10 @@ mod tests { use super::*; use crate::{ core::validation::disable_mock_resource_validation, - models::request_test_utils::mock_request, + models::{ + request_test_utils::mock_request, user_group_test_utils::add_group, + user_test_utils::mock_user, + }, services::system::init_canister_sync_handlers::{ set_initial_accounts, set_initial_assets, set_initial_named_rules, set_initial_request_policies, set_initial_user_groups, set_initial_users, @@ -1358,6 +1381,30 @@ mod tests { assert!(system_info.get_change_canister_request().is_none()); } + #[tokio::test] + async fn canister_upgrade_removes_missing_user_groups_from_users() { + let group = add_group("Test"); + let missing_group_id = [255; 16]; + + let mut user = mock_user(); + user.groups = vec![group.id, missing_group_id]; + USER_REPOSITORY.insert(user.to_key(), user.clone()); + + write_system_info(SystemInfo::new( + Principal::management_canister(), + Vec::new(), + )); + + SYSTEM_SERVICE + .upgrade_canister(None) + .await + .expect("Failed to upgrade canister"); + + let user = USER_REPOSITORY.get(&user.to_key()).unwrap(); + + assert_eq!(user.groups, vec![group.id]); + } + #[tokio::test] async fn test_initial_named_rules_with_correct_dependencies() { let id_1 = Uuid::new_v4().hyphenated().to_string(); diff --git a/core/station/impl/src/services/user_group.rs b/core/station/impl/src/services/user_group.rs index 5cf3d5de0..119116a08 100644 --- a/core/station/impl/src/services/user_group.rs +++ b/core/station/impl/src/services/user_group.rs @@ -10,7 +10,9 @@ use crate::models::resource::{Resource, ResourceAction, ResourceId}; use crate::models::{ AddUserGroupOperationInput, EditUserGroupOperationInput, UserGroup, UserGroupCallerPrivileges, }; -use crate::repositories::{UseGroupWhereClause, UserGroupRepository}; +use crate::repositories::{ + UseGroupWhereClause, UserGroupRepository, UserRepository, UserWhereClause, +}; use lazy_static::lazy_static; use orbit_essentials::api::ServiceResult; use orbit_essentials::model::ModelValidator; @@ -29,6 +31,7 @@ lazy_static! { pub struct UserGroupService { system_service: Arc, user_group_repository: Arc, + user_repository: Arc, } impl UserGroupService { @@ -155,6 +158,21 @@ impl UserGroupService { } } + // Removes the group from all of its members, so no user record is left referencing a + // group that no longer exists. + let members = self.user_repository.find_where(UserWhereClause { + search_term: None, + statuses: None, + groups: Some(vec![user_group.id]), + }); + + for mut user in members { + user.groups.retain(|group_id| *group_id != user_group.id); + user.last_modification_timestamp = next_time(); + + self.user_repository.insert(user.to_key(), user); + } + self.user_group_repository.remove(&user_group.id); Ok(()) @@ -165,8 +183,10 @@ impl UserGroupService { mod test { use crate::{ core::write_system_info, - models::{AddUserGroupOperationInput, SystemInfo}, + models::{user_test_utils::mock_user, AddUserGroupOperationInput, SystemInfo, UserStatus}, + repositories::USER_REPOSITORY, }; + use orbit_essentials::repository::Repository; use super::USER_GROUP_SERVICE; @@ -198,4 +218,49 @@ mod test { assert_eq!(error.code, "CANNOT_DELETE_DISASTER_RECOVERY_COMMITTEE"); } + + #[tokio::test] + async fn test_deleting_user_group_removes_it_from_members() { + let user_group = USER_GROUP_SERVICE + .create(AddUserGroupOperationInput { + name: "Test".to_string(), + }) + .await + .expect("Failed to create user group"); + let other_group = USER_GROUP_SERVICE + .create(AddUserGroupOperationInput { + name: "Other".to_string(), + }) + .await + .expect("Failed to create user group"); + + write_system_info(SystemInfo::default()); + + // Inactive members are cleaned up as well. + let mut active_member = mock_user(); + active_member.groups = vec![user_group.id, other_group.id]; + USER_REPOSITORY.insert(active_member.to_key(), active_member.to_owned()); + + let mut inactive_member = mock_user(); + inactive_member.groups = vec![user_group.id]; + inactive_member.status = UserStatus::Inactive; + USER_REPOSITORY.insert(inactive_member.to_key(), inactive_member.to_owned()); + + USER_GROUP_SERVICE + .remove(&user_group.id) + .await + .expect("Failed to remove user group"); + + let active_member = USER_REPOSITORY.get(&active_member.to_key()).unwrap(); + let inactive_member = USER_REPOSITORY.get(&inactive_member.to_key()).unwrap(); + + assert_eq!(active_member.groups, vec![other_group.id]); + assert!(inactive_member.groups.is_empty()); + + for status in [UserStatus::Active, UserStatus::Inactive] { + assert!(USER_REPOSITORY + .find_by_group_and_status(&user_group.id, &status) + .is_empty()); + } + } } From bc910b51f4a23ec59cbd776d4a002d0472e554a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:26:09 +0000 Subject: [PATCH 2/4] feat(wallet): show the affected members when removing a user group The remove confirmation and pending remove user group requests now list the users that are still members of the group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011s2DjdDkqmvLbmPneUM22j --- .../operations/RemoveUserGroupOperation.vue | 17 +++- .../users/UserGroupMembersWarning.spec.ts | 86 +++++++++++++++++++ .../users/UserGroupMembersWarning.vue | 68 +++++++++++++++ apps/wallet/src/locales/en.locale.ts | 8 ++ apps/wallet/src/locales/fr.locale.ts | 8 ++ apps/wallet/src/locales/pt.locale.ts | 8 ++ apps/wallet/src/pages/UserGroupsPage.vue | 6 +- 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts create mode 100644 apps/wallet/src/components/users/UserGroupMembersWarning.vue diff --git a/apps/wallet/src/components/requests/operations/RemoveUserGroupOperation.vue b/apps/wallet/src/components/requests/operations/RemoveUserGroupOperation.vue index bc72404e8..c2a771b80 100644 --- a/apps/wallet/src/components/requests/operations/RemoveUserGroupOperation.vue +++ b/apps/wallet/src/components/requests/operations/RemoveUserGroupOperation.vue @@ -8,15 +8,24 @@ - +
+ + +
diff --git a/apps/wallet/src/locales/en.locale.ts b/apps/wallet/src/locales/en.locale.ts index f8b25e398..ebd0af182 100644 --- a/apps/wallet/src/locales/en.locale.ts +++ b/apps/wallet/src/locales/en.locale.ts @@ -894,6 +894,14 @@ export default { external_canisters: 'Canisters', approval_rules: 'Approval Rules', }, + user_groups: { + members_removal_warning: + 'Removing this group will affect user {users} who is still a member of it. | Removing this group will affect users {users} who are still members of it.', + members_removal_warning_more: + 'Removing this group will affect users {users} and {count} more who are still members of it.', + members_removal_warning_unknown: + 'Removing this group will also remove it from any users who are still members of it.', + }, pages: { dashboard: { title: 'Dashboard', diff --git a/apps/wallet/src/locales/fr.locale.ts b/apps/wallet/src/locales/fr.locale.ts index dc05ebf91..20aff362c 100644 --- a/apps/wallet/src/locales/fr.locale.ts +++ b/apps/wallet/src/locales/fr.locale.ts @@ -904,6 +904,14 @@ export default { external_canisters: 'Canisters', approval_rules: 'Règles d approbation', }, + user_groups: { + members_removal_warning: + "La suppression de ce groupe affectera l'usager {users} qui en est encore membre. | La suppression de ce groupe affectera les usagers {users} qui en sont encore membres.", + members_removal_warning_more: + 'La suppression de ce groupe affectera les usagers {users} et {count} autre(s) qui en sont encore membres.', + members_removal_warning_unknown: + 'La suppression de ce groupe le retirera également de tous les usagers qui en sont encore membres.', + }, pages: { dashboard: { title: 'Tableau de Bord', diff --git a/apps/wallet/src/locales/pt.locale.ts b/apps/wallet/src/locales/pt.locale.ts index 896d83671..b703c6d49 100644 --- a/apps/wallet/src/locales/pt.locale.ts +++ b/apps/wallet/src/locales/pt.locale.ts @@ -900,6 +900,14 @@ export default { external_canisters: 'Canisters', approval_rules: 'Regras de aprovação', }, + user_groups: { + members_removal_warning: + 'Remover este grupo afetará o usuário {users}, que ainda é membro dele. | Remover este grupo afetará os usuários {users}, que ainda são membros dele.', + members_removal_warning_more: + 'Remover este grupo afetará os usuários {users} e mais {count}, que ainda são membros dele.', + members_removal_warning_unknown: + 'Remover este grupo também o removerá de todos os usuários que ainda são membros dele.', + }, pages: { dashboard: { title: 'Dashboard', diff --git a/apps/wallet/src/pages/UserGroupsPage.vue b/apps/wallet/src/pages/UserGroupsPage.vue index 797dbc536..1ace00cdc 100644 --- a/apps/wallet/src/pages/UserGroupsPage.vue +++ b/apps/wallet/src/pages/UserGroupsPage.vue @@ -76,7 +76,10 @@ :submit="id => station.service.removeUserGroup({ user_group_id: id })" @failed="useOnFailedOperation" @submitted="useOnSuccessfulOperation" - /> + > +

{{ $t('app.dialog_confirmation_question') }}

+ + Date: Fri, 23 Oct 2026 10:36:13 +0000 Subject: [PATCH 3/4] fix(wallet): show the generic members warning until the members are loaded The confirmation can be submitted before the member lookup finishes, so the generic warning is shown until the members are loaded instead of no warning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011s2DjdDkqmvLbmPneUM22j --- .../components/users/UserGroupMembersWarning.spec.ts | 10 ++++++++++ .../src/components/users/UserGroupMembersWarning.vue | 9 +++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts b/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts index 5b0f8a66f..514582c87 100644 --- a/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts +++ b/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts @@ -74,6 +74,16 @@ describe('UserGroupMembersWarning', () => { expect(warning.exists()).toBe(false); }); + it('shows a generic warning while the members are loading', async () => { + vi.spyOn(mockedStationService, 'listUsers').mockReturnValueOnce(new Promise(() => {})); + + const warning = await mountWarning(); + + expect(warning.text()).toContain( + 'Removing this group will also remove it from any users who are still members of it.', + ); + }); + it('falls back to a generic warning when the members cannot be loaded', async () => { vi.spyOn(mockedStationService, 'listUsers').mockRejectedValueOnce(new Error('Unauthorized')); diff --git a/apps/wallet/src/components/users/UserGroupMembersWarning.vue b/apps/wallet/src/components/users/UserGroupMembersWarning.vue index fb7939160..fb0f29b95 100644 --- a/apps/wallet/src/components/users/UserGroupMembersWarning.vue +++ b/apps/wallet/src/components/users/UserGroupMembersWarning.vue @@ -29,10 +29,12 @@ const station = useStationStore(); const memberNames = ref([]); const totalMembers = ref(0); -const failedToLoadMembers = ref(false); +const membersLoaded = ref(false); const warning = computed(() => { - if (failedToLoadMembers.value) { + // The generic warning is shown until the members are loaded, so it is never hidden while the + // confirmation is already possible. + if (!membersLoaded.value) { return i18n.t('user_groups.members_removal_warning_unknown'); } @@ -59,10 +61,9 @@ onMounted(async () => { memberNames.value = result.users.map(user => user.name); totalMembers.value = Number(result.total); + membersLoaded.value = true; } catch (e) { logger.error('Failed to fetch user group members', e); - - failedToLoadMembers.value = true; } }); From f5baebc23948dc09b60b8e77358098701504d6ca Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 23 Oct 2026 14:21:26 +0200 Subject: [PATCH 4/4] fix(wallet): show the generic members warning when no members are listed Co-Authored-By: Claude Opus 5.5 (1M context) --- .../users/UserGroupMembersWarning.spec.ts | 16 ++++++++++++++-- .../components/users/UserGroupMembersWarning.vue | 9 +++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts b/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts index 514582c87..7a611b81f 100644 --- a/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts +++ b/apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts @@ -66,12 +66,24 @@ describe('UserGroupMembersWarning', () => { expect(warning.text()).toContain('users Alice, Bob and 3 more who are still members of it.'); }); - it('does not warn when the group has no members', async () => { + it('shows a generic warning when no members are listed', async () => { mockListUsers([]); const warning = await mountWarning(); - expect(warning.exists()).toBe(false); + expect(warning.text()).toContain( + 'Removing this group will also remove it from any users who are still members of it.', + ); + }); + + it('shows a generic warning when the members are not visible to the caller', async () => { + mockListUsers([], 3); + + const warning = await mountWarning(); + + expect(warning.text()).toContain( + 'Removing this group will also remove it from any users who are still members of it.', + ); }); it('shows a generic warning while the members are loading', async () => { diff --git a/apps/wallet/src/components/users/UserGroupMembersWarning.vue b/apps/wallet/src/components/users/UserGroupMembersWarning.vue index fb0f29b95..858f7f367 100644 --- a/apps/wallet/src/components/users/UserGroupMembersWarning.vue +++ b/apps/wallet/src/components/users/UserGroupMembersWarning.vue @@ -33,15 +33,12 @@ const membersLoaded = ref(false); const warning = computed(() => { // The generic warning is shown until the members are loaded, so it is never hidden while the - // confirmation is already possible. - if (!membersLoaded.value) { + // confirmation is already possible, and when none are listed, since the caller might not be + // allowed to see them. + if (!membersLoaded.value || memberNames.value.length === 0) { return i18n.t('user_groups.members_removal_warning_unknown'); } - if (totalMembers.value === 0) { - return ''; - } - const users = memberNames.value.join(', '); const remaining = totalMembers.value - memberNames.value.length;