Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,24 @@
</RequestOperationListRow>
</div>
<VProgressCircular v-else-if="loading" />
<UserGroupForm v-else :model-value="formValue" mode="view" />
<div v-else>
<UserGroupMembersWarning
v-if="isPending"
:user-group-id="props.operation.input.user_group_id"
class="mb-4"
/>
<UserGroupForm :model-value="formValue" mode="view" />
</div>
</template>

<script setup lang="ts">
import { Ref, computed, onBeforeMount, ref } from 'vue';
import UserGroupForm from '~/components/users/UserGroupForm.vue';
import UserGroupMembersWarning from '~/components/users/UserGroupMembersWarning.vue';
import logger from '~/core/logger.core';
import { Request, RemoveUserGroupOperation, UserGroup } from '~/generated/station/station.did';
import { useStationStore } from '~/stores/station.store';
import { variantIs } from '~/utils/helper.utils';
import RequestOperationListRow from '../RequestOperationListRow.vue';

const props = withDefaults(
Expand All @@ -31,6 +40,12 @@ const props = withDefaults(
);

const isListMode = computed(() => props.mode === 'list');
// Members are only removed when the request executes, so the warning is only relevant until then.
const isPending = computed(() =>
(['Created', 'Approved', 'Scheduled', 'Processing'] as const).some(status =>
variantIs(props.request.status, status),
),
);
const formValue: Ref<Partial<UserGroup>> = ref({});
const loading = ref(false);
const station = useStationStore();
Expand Down
108 changes: 108 additions & 0 deletions apps/wallet/src/components/users/UserGroupMembersWarning.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { flushPromises } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { icAgent } from '~/core/ic-agent.core';
import { ListUsersResult, User } from '~/generated/station/station.did';
import { serviceManager } from '~/plugins/services.plugin';
import { StationService } from '~/services/station.service';
import { mount } from '~/test.utils';
import { ExtractOk } from '~/types/helper.types';
import UserGroupMembersWarning from './UserGroupMembersWarning.vue';

vi.mock('~/services/station.service', () => {
const mock: Partial<StationService> = {
withStationId: vi.fn().mockReturnThis(),
listUsers: vi.fn().mockReturnThis(),
};

return {
StationService: vi.fn(() => mock),
};
});

const mockedStationService = new StationService(icAgent.get());
serviceManager.services.station = mockedStationService;

const mockListUsers = (names: string[], total = names.length) =>
vi.spyOn(mockedStationService, 'listUsers').mockResolvedValueOnce({
users: names.map(name => ({ name }) as User),
privileges: [],
next_offset: [],
total: BigInt(total),
} as ExtractOk<ListUsersResult>);

const mountWarning = async () => {
const wrapper = mount(UserGroupMembersWarning, { props: { userGroupId: '1' } });
await flushPromises();

return wrapper.find('[data-test-id="user-group-members-warning"]');
};

describe('UserGroupMembersWarning', () => {
it('lists the members that will be affected', async () => {
mockListUsers(['Alice', 'Bob']);

const warning = await mountWarning();

expect(warning.text()).toContain(
'Removing this group will affect users Alice, Bob who are still members of it.',
);
});

it('uses the singular form for a single member', async () => {
mockListUsers(['Alice']);

const warning = await mountWarning();

expect(warning.text()).toContain(
'Removing this group will affect user Alice who is still a member of it.',
);
});

it('mentions how many members are not listed', async () => {
mockListUsers(['Alice', 'Bob'], 5);

const warning = await mountWarning();

expect(warning.text()).toContain('users Alice, Bob and 3 more who are still members of it.');
});

it('shows a generic warning when no members are listed', async () => {
mockListUsers([]);

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 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 () => {
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'));

const warning = await mountWarning();

expect(warning.text()).toContain(
'Removing this group will also remove it from any users who are still members of it.',
);
});
});
66 changes: 66 additions & 0 deletions apps/wallet/src/components/users/UserGroupMembersWarning.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<template>
<VAlert
v-if="warning"
type="warning"
variant="tonal"
density="compact"
data-test-id="user-group-members-warning"
>
{{ warning }}
</VAlert>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { VAlert } from 'vuetify/components';
import logger from '~/core/logger.core';
import { UUID } from '~/generated/station/station.did';
import { useStationStore } from '~/stores/station.store';

const props = defineProps<{
userGroupId: UUID;
}>();

const MAX_LISTED_MEMBERS = 10;

const i18n = useI18n();
const station = useStationStore();

const memberNames = ref<string[]>([]);
const totalMembers = ref(0);
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, 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');
}

const users = memberNames.value.join(', ');
const remaining = totalMembers.value - memberNames.value.length;

if (remaining > 0) {
return i18n.t('user_groups.members_removal_warning_more', { users, count: remaining });
}

return i18n.t('user_groups.members_removal_warning', { users }, totalMembers.value);
});

onMounted(async () => {
try {
const result = await station.service.listUsers({
groups: [props.userGroupId],
limit: MAX_LISTED_MEMBERS,
});

memberNames.value = result.users.map(user => user.name);
totalMembers.value = Number(result.total);
Comment thread
sea-snake marked this conversation as resolved.
Resolved
membersLoaded.value = true;
} catch (e) {
logger.error('Failed to fetch user group members', e);
}
});
</script>
8 changes: 8 additions & 0 deletions apps/wallet/src/locales/en.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions apps/wallet/src/locales/fr.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions apps/wallet/src/locales/pt.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion apps/wallet/src/pages/UserGroupsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@
:submit="id => station.service.removeUserGroup({ user_group_id: id })"
@failed="useOnFailedOperation"
@submitted="useOnSuccessfulOperation"
/>
>
<p>{{ $t('app.dialog_confirmation_question') }}</p>
<UserGroupMembersWarning :user-group-id="userGroup.id" class="mt-4" />
</ActionBtn>
<OpenUserGroupBtn
:icon="!hasEditPrivilege(userGroup.id) ? mdiEye : mdiPencil"
:user-group-id="userGroup.id"
Expand Down Expand Up @@ -116,6 +119,7 @@ import PageBody from '~/components/layouts/PageBody.vue';
import PageHeader from '~/components/layouts/PageHeader.vue';
import RecentRequests from '~/components/requests/RecentRequests.vue';
import OpenUserGroupBtn from '~/components/users/OpenUserGroupBtn.vue';
import UserGroupMembersWarning from '~/components/users/UserGroupMembersWarning.vue';
import { useFetchList, usePagination } from '~/composables/lists.composable';
import {
useOnFailedOperation,
Expand Down
49 changes: 48 additions & 1 deletion core/station/impl/src/services/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading