From 046a3e153eb5382a55b534bade823f25d1320151 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:02:06 +0000 Subject: [PATCH 1/3] fix(wallet): consistently use verified calls in the request dialog The request dialog loads the request itself with a verified call, but some of the data rendered inside it was still loaded with query calls: the current values shown in diff views, the next request when "Load next" is enabled, the full CallExternalCanister details, and the labels resolved by autocompletes, named rule chips and address book specifiers. Load these with the verified actor as well. Shared components pick this up through a new `provideVerifiedReads`/`useVerifiedReads` pair, so they keep using query calls everywhere outside of the dialog. `getNamedRule` gains the `verifiedCall` parameter the other getters already have. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WAEJVi9S9JPmbMVGCycebk --- .../request-policies/InteractiveNamedRule.vue | 6 +- .../request-policies/rule/NamedRule.vue | 6 +- .../specifier/AddressBookEntrySpecifier.vue | 11 ++- .../components/requests/RequestDialog.spec.ts | 6 +- .../src/components/requests/RequestDialog.vue | 16 +++-- .../CallExternalCanisterOperation.vue | 11 +-- .../operations/EditNamedRuleOperation.vue | 1 + .../operations/EditPermissionOperation.vue | 9 ++- .../operations/EditRequestPolicyOperation.vue | 5 +- .../operations/EditUserGroupOperation.vue | 5 +- .../requests/operations/EditUserOperation.vue | 2 +- .../operations/ManageSystemInfoOperation.vue | 2 +- .../operations/RemoveNamedRuleOperation.vue | 5 +- .../RemoveRequestPolicyOperation.vue | 5 +- .../composables/autocomplete.composable.ts | 69 ++++++++++++------- .../verified-reads.composable.spec.ts | 69 +++++++++++++++++++ .../composables/verified-reads.composable.ts | 19 +++++ apps/wallet/src/services/station.service.ts | 5 +- 18 files changed, 203 insertions(+), 49 deletions(-) create mode 100644 apps/wallet/src/composables/verified-reads.composable.spec.ts create mode 100644 apps/wallet/src/composables/verified-reads.composable.ts diff --git a/apps/wallet/src/components/request-policies/InteractiveNamedRule.vue b/apps/wallet/src/components/request-policies/InteractiveNamedRule.vue index 3cbbd3c8d..99b803ec5 100644 --- a/apps/wallet/src/components/request-policies/InteractiveNamedRule.vue +++ b/apps/wallet/src/components/request-policies/InteractiveNamedRule.vue @@ -24,6 +24,7 @@ import { computed } from 'vue'; import { toRefs } from 'vue'; import ErrorTooltip from '~/components/error/ErrorTooltip.vue'; import { useRuleToTooltip } from '~/composables/request-policies.composable'; +import { useVerifiedReads } from '~/composables/verified-reads.composable'; import { NamedRule, UUID } from '~/generated/station/station.did'; import { services } from '~/plugins/services.plugin'; import { useAppStore } from '~/stores/app.store'; @@ -38,6 +39,7 @@ const namedRule = ref(null); const loading = ref(true); const error = ref(null); const appStore = useAppStore(); +const verifiedReads = useVerifiedReads(); const rule = computed(() => (namedRule.value ? namedRule.value.rule : null)); const tooltip = useRuleToTooltip(rule); @@ -45,7 +47,9 @@ const tooltip = useRuleToTooltip(rule); onMounted(async () => { loading.value = true; try { - namedRule.value = (await services().station.getNamedRule(props.id.value)).named_rule; + namedRule.value = ( + await services().station.getNamedRule(props.id.value, verifiedReads) + ).named_rule; } catch (e) { appStore.sendErrorNotification(e); error.value = getErrorMessage(e); diff --git a/apps/wallet/src/components/request-policies/rule/NamedRule.vue b/apps/wallet/src/components/request-policies/rule/NamedRule.vue index 5186872fb..6262fee5b 100644 --- a/apps/wallet/src/components/request-policies/rule/NamedRule.vue +++ b/apps/wallet/src/components/request-policies/rule/NamedRule.vue @@ -45,6 +45,7 @@ import { computed } from 'vue'; import { toRefs } from 'vue'; import ErrorTooltip from '~/components/error/ErrorTooltip.vue'; import { useRuleToTooltip } from '~/composables/request-policies.composable'; +import { useVerifiedReads } from '~/composables/verified-reads.composable'; import { NamedRule, UUID } from '~/generated/station/station.did'; import { services } from '~/plugins/services.plugin'; import { useAppStore } from '~/stores/app.store'; @@ -65,6 +66,7 @@ const namedRule = ref(null); const loading = ref(true); const error = ref(null); const appStore = useAppStore(); +const verifiedReads = useVerifiedReads(); const rule = computed(() => (namedRule.value ? namedRule.value.rule : null)); @@ -77,7 +79,9 @@ const emit = defineEmits<{ onMounted(async () => { loading.value = true; try { - namedRule.value = (await services().station.getNamedRule(props.namedRuleId.value)).named_rule; + namedRule.value = ( + await services().station.getNamedRule(props.namedRuleId.value, verifiedReads) + ).named_rule; } catch (e) { appStore.sendErrorNotification(e); error.value = getErrorMessage(e); diff --git a/apps/wallet/src/components/request-policies/specifier/AddressBookEntrySpecifier.vue b/apps/wallet/src/components/request-policies/specifier/AddressBookEntrySpecifier.vue index 7f81d9eba..f6bda20c6 100644 --- a/apps/wallet/src/components/request-policies/specifier/AddressBookEntrySpecifier.vue +++ b/apps/wallet/src/components/request-policies/specifier/AddressBookEntrySpecifier.vue @@ -36,6 +36,7 @@ import { computed, ref, toRefs, watch } from 'vue'; import AddressBookAutocomplete from '~/components/inputs/AddressBookAutocomplete.vue'; import { AddressBookEntry, ResourceIds } from '~/generated/station/station.did'; import { services } from '~/plugins/services.plugin'; +import { useVerifiedReads } from '~/composables/verified-reads.composable'; import { variantIs } from '~/utils/helper.utils'; const input = withDefaults( @@ -54,6 +55,7 @@ const input = withDefaults( const props = toRefs(input); const stationService = services().station; +const verifiedReads = useVerifiedReads(); const emit = defineEmits<{ (event: 'update:modelValue', payload: ResourceIds): void; @@ -96,9 +98,12 @@ watch( const list = variantIs(model.value, 'Ids') ? model.value['Ids'] : []; const allEntries = []; while (list.length > 0) { - const entries = await stationService.listAddressBook({ - ids: list.splice(0, 100), - }); + const entries = await stationService.listAddressBook( + { + ids: list.splice(0, 100), + }, + verifiedReads, + ); allEntries.push(...entries.address_book_entries); } loadedEntries.value = allEntries; diff --git a/apps/wallet/src/components/requests/RequestDialog.spec.ts b/apps/wallet/src/components/requests/RequestDialog.spec.ts index 100239e4f..53715d7ab 100644 --- a/apps/wallet/src/components/requests/RequestDialog.spec.ts +++ b/apps/wallet/src/components/requests/RequestDialog.spec.ts @@ -226,7 +226,11 @@ describe('RequestDialog', () => { await flushPromises(); - expect(services().station.getNextApprovableRequest).toHaveBeenCalled(); + // the next request is rendered from this response, so it is loaded like the initial one + expect(services().station.getNextApprovableRequest).toHaveBeenCalledWith( + expect.anything(), + true, + ); contents = wrapper.findComponent(VCard); diff --git a/apps/wallet/src/components/requests/RequestDialog.vue b/apps/wallet/src/components/requests/RequestDialog.vue index 752d60e59..ed76acd13 100644 --- a/apps/wallet/src/components/requests/RequestDialog.vue +++ b/apps/wallet/src/components/requests/RequestDialog.vue @@ -99,6 +99,7 @@ import RequestDetailView from './RequestDetailView.vue'; import LoadingMessage from '~/components/LoadingMessage.vue'; import { RequestApprovalStatusEnum } from '~/types/requests.types'; import { getRequestUrl } from '~/utils/app.utils'; +import { provideVerifiedReads } from '~/composables/verified-reads.composable'; type DataType = { request: GetRequestResultData['request']; @@ -137,6 +138,9 @@ const skippedRequestIds = ref([]); const requestType = ref(); const loadNext = ref(false); +// Shared components rendered inside this dialog load their data with verified calls too. +provideVerifiedReads(); + watch(props.open, isOpen => { if (isOpen) { currentRequestId.value = props.requestId.value; @@ -221,10 +225,14 @@ const onRequestLoaded = (data: Awaited>): void => }; const loadNextRequest = async (): Promise => { - const nextRequest = await services().station.getNextApprovableRequest({ - types: [requestType.value!], - excludedRequestIds: skippedRequestIds.value, - }); + // The result is rendered directly, so it is loaded with a verified call like in `loadRequest`. + const nextRequest = await services().station.getNextApprovableRequest( + { + types: [requestType.value!], + excludedRequestIds: skippedRequestIds.value, + }, + true, + ); if (nextRequest.length === 0) { return null; diff --git a/apps/wallet/src/components/requests/operations/CallExternalCanisterOperation.vue b/apps/wallet/src/components/requests/operations/CallExternalCanisterOperation.vue index dcb3b46f3..b703dd517 100644 --- a/apps/wallet/src/components/requests/operations/CallExternalCanisterOperation.vue +++ b/apps/wallet/src/components/requests/operations/CallExternalCanisterOperation.vue @@ -57,10 +57,13 @@ const fillReviewContext = ( const loadWithFullInformation = async (): Promise => { try { - const result = await station.service.getRequest({ - request_id: props.request.id, - with_full_info: [true], - }); + const result = await station.service.getRequest( + { + request_id: props.request.id, + with_full_info: [true], + }, + true, + ); if (variantIs(result.request.operation, 'CallExternalCanister')) { const candidIdl = await fetchCanisterIdlFromMetadata( diff --git a/apps/wallet/src/components/requests/operations/EditNamedRuleOperation.vue b/apps/wallet/src/components/requests/operations/EditNamedRuleOperation.vue index 00fb10b39..793080c64 100644 --- a/apps/wallet/src/components/requests/operations/EditNamedRuleOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditNamedRuleOperation.vue @@ -78,6 +78,7 @@ onBeforeMount(async () => { try { const { named_rule } = await station.service.getNamedRule( props.operation.input.named_rule_id, + true, ); if (!formValue.value.description) { diff --git a/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue b/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue index 583ce635d..245f3e12d 100644 --- a/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue @@ -76,9 +76,12 @@ const fetchDetails = async () => { } loading.value = true; - const { permission: result } = await station.service.getPermission({ - resource: props.operation.input.resource, - }); + const { permission: result } = await station.service.getPermission( + { + resource: props.operation.input.resource, + }, + true, + ); if (isDiffMode.value) { // snapshot original for diff diff --git a/apps/wallet/src/components/requests/operations/EditRequestPolicyOperation.vue b/apps/wallet/src/components/requests/operations/EditRequestPolicyOperation.vue index 524aa3f21..8c19718a3 100644 --- a/apps/wallet/src/components/requests/operations/EditRequestPolicyOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditRequestPolicyOperation.vue @@ -77,7 +77,10 @@ const fetchDetails = async () => { } loading.value = true; - const currentEntry = await station.service.getRequestPolicy(props.operation.input.policy_id); + const currentEntry = await station.service.getRequestPolicy( + props.operation.input.policy_id, + true, + ); if (isDiffMode.value) { currentRequestPolicy.value = deepClone(currentEntry.policy); diff --git a/apps/wallet/src/components/requests/operations/EditUserGroupOperation.vue b/apps/wallet/src/components/requests/operations/EditUserGroupOperation.vue index 007841851..9a941002a 100644 --- a/apps/wallet/src/components/requests/operations/EditUserGroupOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditUserGroupOperation.vue @@ -68,7 +68,10 @@ onBeforeMount(async () => { if (isDiffMode.value) { try { currentUserGroup.value = ( - await stationService.getUserGroup({ user_group_id: props.operation.input.user_group_id }) + await stationService.getUserGroup( + { user_group_id: props.operation.input.user_group_id }, + true, + ) ).user_group; } catch (e) { currentUserGroupFailed.value = getErrorMessage(e); diff --git a/apps/wallet/src/components/requests/operations/EditUserOperation.vue b/apps/wallet/src/components/requests/operations/EditUserOperation.vue index 23fe6d0ba..4c1bbeecb 100644 --- a/apps/wallet/src/components/requests/operations/EditUserOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditUserOperation.vue @@ -90,7 +90,7 @@ onBeforeMount(async () => { { user_id: props.operation.input.id, }, - false, + true, ) ).user; } catch (error) { diff --git a/apps/wallet/src/components/requests/operations/ManageSystemInfoOperation.vue b/apps/wallet/src/components/requests/operations/ManageSystemInfoOperation.vue index 681a698fd..e94e5e1c9 100644 --- a/apps/wallet/src/components/requests/operations/ManageSystemInfoOperation.vue +++ b/apps/wallet/src/components/requests/operations/ManageSystemInfoOperation.vue @@ -56,7 +56,7 @@ const appStore = useAppStore(); onMounted(async () => { try { - const { system } = await stationService.systemInfo(); + const { system } = await stationService.systemInfo(!isListMode.value); currentSystemInfo.value = system; } catch (error) { appStore.sendErrorNotification(error); diff --git a/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue b/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue index 40650c2c6..20f23ed1d 100644 --- a/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue +++ b/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue @@ -64,7 +64,10 @@ const fetchDetails = async () => { } loading.value = true; - const { named_rule } = await station.service.getNamedRule(props.operation.input.named_rule_id); + const { named_rule } = await station.service.getNamedRule( + props.operation.input.named_rule_id, + true, + ); formValue.value = named_rule; } catch (e) { diff --git a/apps/wallet/src/components/requests/operations/RemoveRequestPolicyOperation.vue b/apps/wallet/src/components/requests/operations/RemoveRequestPolicyOperation.vue index a96b7a21a..fbedfa478 100644 --- a/apps/wallet/src/components/requests/operations/RemoveRequestPolicyOperation.vue +++ b/apps/wallet/src/components/requests/operations/RemoveRequestPolicyOperation.vue @@ -53,7 +53,10 @@ const fetchDetails = async () => { } loading.value = true; - const currentEntry = await station.service.getRequestPolicy(props.operation.input.policy_id); + const currentEntry = await station.service.getRequestPolicy( + props.operation.input.policy_id, + true, + ); formValue.value = currentEntry.policy; } catch (e) { diff --git a/apps/wallet/src/composables/autocomplete.composable.ts b/apps/wallet/src/composables/autocomplete.composable.ts index 2f3fcee4b..6693f9a08 100644 --- a/apps/wallet/src/composables/autocomplete.composable.ts +++ b/apps/wallet/src/composables/autocomplete.composable.ts @@ -2,6 +2,7 @@ import { Ref, ref } from 'vue'; import { logger } from '~/core/logger.core'; import { UserGroup } from '~/generated/station/station.did'; import { useStationStore } from '~/stores/station.store'; +import { useVerifiedReads } from '~/composables/verified-reads.composable'; import { debounce } from '~/utils/helper.utils'; export const useAutocomplete = (fetchCall: (search: string) => Promise) => { @@ -40,13 +41,17 @@ export const useAutocomplete = (fetchCall: (search: string) => Promise) export const useUserGroupsAutocomplete = () => { const station = useStationStore(); + const verifiedReads = useVerifiedReads(); const autocomplete = useAutocomplete(async term => { - const groups = await station.service.listUserGroups({ - searchTerm: term.trim().length > 0 ? term.trim() : undefined, - limit: 100, - offset: 0, - }); + const groups = await station.service.listUserGroups( + { + searchTerm: term.trim().length > 0 ? term.trim() : undefined, + limit: 100, + offset: 0, + }, + verifiedReads, + ); return groups.user_groups; }); @@ -56,13 +61,17 @@ export const useUserGroupsAutocomplete = () => { export const useUsersAutocomplete = () => { const station = useStationStore(); + const verifiedReads = useVerifiedReads(); const autocomplete = useAutocomplete(async term => { - const users = await station.service.listUsers({ - searchTerm: term.trim().length > 0 ? term.trim() : undefined, - limit: 100, - offset: 0, - }); + const users = await station.service.listUsers( + { + searchTerm: term.trim().length > 0 ? term.trim() : undefined, + limit: 100, + offset: 0, + }, + verifiedReads, + ); return users.users; }); @@ -72,13 +81,17 @@ export const useUsersAutocomplete = () => { export const useAccountsAutocomplete = () => { const station = useStationStore(); + const verifiedReads = useVerifiedReads(); const autocomplete = useAutocomplete(async term => { - const accounts = await station.service.listAccounts({ - searchTerm: term.trim().length > 0 ? term.trim() : undefined, - limit: 100, - offset: 0, - }); + const accounts = await station.service.listAccounts( + { + searchTerm: term.trim().length > 0 ? term.trim() : undefined, + limit: 100, + offset: 0, + }, + verifiedReads, + ); return accounts.accounts; }); @@ -88,13 +101,17 @@ export const useAccountsAutocomplete = () => { export const useAddressBookAutocomplete = () => { const station = useStationStore(); + const verifiedReads = useVerifiedReads(); const autocomplete = useAutocomplete(async term => { - const results = await station.service.listAddressBook({ - search_term: term.trim().length > 0 ? term.trim() : undefined, - limit: 10, - offset: 0, - }); + const results = await station.service.listAddressBook( + { + search_term: term.trim().length > 0 ? term.trim() : undefined, + limit: 10, + offset: 0, + }, + verifiedReads, + ); return results.address_book_entries; }); @@ -104,12 +121,16 @@ export const useAddressBookAutocomplete = () => { export const useAssetAutocomplete = () => { const station = useStationStore(); + const verifiedReads = useVerifiedReads(); const autocomplete = useAutocomplete(async () => { - const results = await station.service.listAssets({ - limit: 100, - offset: 0, - }); + const results = await station.service.listAssets( + { + limit: 100, + offset: 0, + }, + verifiedReads, + ); return results.assets; }); diff --git a/apps/wallet/src/composables/verified-reads.composable.spec.ts b/apps/wallet/src/composables/verified-reads.composable.spec.ts new file mode 100644 index 000000000..d7349418c --- /dev/null +++ b/apps/wallet/src/composables/verified-reads.composable.spec.ts @@ -0,0 +1,69 @@ +/* eslint-disable vue/one-component-per-file */ +import { flushPromises } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { services } from '~/plugins/services.plugin'; +import { mount } from '~/test.utils'; +import { useUserGroupsAutocomplete } from './autocomplete.composable'; +import { provideVerifiedReads, useVerifiedReads } from './verified-reads.composable'; + +const Reader = defineComponent({ + setup() { + const verifiedReads = useVerifiedReads(); + const autocomplete = useUserGroupsAutocomplete(); + + autocomplete.searchItems(); + + return () => h('div', { 'data-test-id': 'verified' }, String(verifiedReads)); + }, +}); + +const ReviewScope = defineComponent({ + setup() { + provideVerifiedReads(); + + return () => h(Reader); + }, +}); + +describe('Verified reads composable', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses query calls outside of a review scope', async () => { + vi.spyOn(services().station, 'listUserGroups').mockResolvedValue({ + user_groups: [], + next_offset: [], + total: BigInt(0), + privileges: [], + }); + + const wrapper = mount(Reader); + await flushPromises(); + + expect(wrapper.find('[data-test-id="verified"]').text()).toBe('false'); + // the autocomplete search is debounced + await vi.waitFor(() => + expect(services().station.listUserGroups).toHaveBeenCalledWith(expect.anything(), false), + ); + }); + + it('uses verified calls for lookups made inside a review scope', async () => { + vi.spyOn(services().station, 'listUserGroups').mockResolvedValue({ + user_groups: [], + next_offset: [], + total: BigInt(0), + privileges: [], + }); + + const wrapper = mount(ReviewScope); + await flushPromises(); + + expect(wrapper.find('[data-test-id="verified"]').text()).toBe('true'); + // the autocomplete search is debounced + await vi.waitFor(() => + expect(services().station.listUserGroups).toHaveBeenCalledWith(expect.anything(), true), + ); + }); +}); diff --git a/apps/wallet/src/composables/verified-reads.composable.ts b/apps/wallet/src/composables/verified-reads.composable.ts new file mode 100644 index 000000000..32114e152 --- /dev/null +++ b/apps/wallet/src/composables/verified-reads.composable.ts @@ -0,0 +1,19 @@ +import { inject, provide } from 'vue'; + +const VERIFIED_READS_KEY = 'verifiedReads'; + +/** + * Marks the current component subtree as part of the request review dialog. + * + * Shared components rendered inside it (autocompletes, named rule labels, etc.) then load their data + * with the verified actor, the same way the request itself is loaded. + */ +export const provideVerifiedReads = (): void => { + provide(VERIFIED_READS_KEY, true); +}; + +/** + * Returns true inside a subtree marked with `provideVerifiedReads`, meant to be passed as the + * `verifiedCall` argument of station service reads. Must be called from a component setup. + */ +export const useVerifiedReads = (): boolean => inject(VERIFIED_READS_KEY, false); diff --git a/apps/wallet/src/services/station.service.ts b/apps/wallet/src/services/station.service.ts index bc1187a38..660231f3c 100644 --- a/apps/wallet/src/services/station.service.ts +++ b/apps/wallet/src/services/station.service.ts @@ -1587,8 +1587,9 @@ export class StationService { return result.Ok.request; } - async getNamedRule(id: UUID): Promise> { - const result = await this.actor.get_named_rule({ named_rule_id: id }); + async getNamedRule(id: UUID, verifiedCall = false): Promise> { + const actor = verifiedCall ? this.verified_actor : this.actor; + const result = await actor.get_named_rule({ named_rule_id: id }); if (variantIs(result, 'Err')) { throw result.Err; From fa1f763f42d95477d00657614b3bd99467a62732 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:13:39 +0000 Subject: [PATCH 2/3] fix(wallet): verify rule summaries in the request dialog Rule tooltips and summaries resolve user, group and named rule names through `populateRule`, which always used query calls. Pass the dialog's verified reads flag through it, so these lookups match the rest of the dialog and keep using query calls elsewhere. Also keep the RemoveNamedRule request list rows on query calls; only its detail view needs the verified read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WAEJVi9S9JPmbMVGCycebk --- .../operations/RemoveNamedRuleOperation.vue | 2 +- .../request-policies.composable.ts | 33 ++++++++---- .../verified-reads.composable.spec.ts | 52 ++++++++++++++++++- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue b/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue index 20f23ed1d..a7cf899f1 100644 --- a/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue +++ b/apps/wallet/src/components/requests/operations/RemoveNamedRuleOperation.vue @@ -66,7 +66,7 @@ const fetchDetails = async () => { loading.value = true; const { named_rule } = await station.service.getNamedRule( props.operation.input.named_rule_id, - true, + !isListMode.value, ); formValue.value = named_rule; diff --git a/apps/wallet/src/composables/request-policies.composable.ts b/apps/wallet/src/composables/request-policies.composable.ts index 833c3b3b9..d6fee6959 100644 --- a/apps/wallet/src/composables/request-policies.composable.ts +++ b/apps/wallet/src/composables/request-policies.composable.ts @@ -16,6 +16,7 @@ import { mapRequestSpecifierToEnum } from '~/mappers/request-specifiers.mapper'; import { services } from '~/plugins/services.plugin'; import { StationService } from '~/services/station.service'; import { useStationStore } from '~/stores/station.store'; +import { useVerifiedReads } from '~/composables/verified-reads.composable'; import { SelectItem } from '~/types/helper.types'; import { RequestPolicyRuleEnum, RequestPolicyRuleUserSpecifierEnum } from '~/types/station.types'; import { unreachable, variantIs } from '~/utils/helper.utils'; @@ -166,12 +167,13 @@ export type PopulatedRule = async function populateUserSpecifier( userSpecifier: UserSpecifier, station: StationService, + verifiedCall: boolean, ): Promise { if (variantIs(userSpecifier, 'Id')) { const users = await Promise.all( userSpecifier.Id.map(id => station - .getUser({ user_id: id }) + .getUser({ user_id: id }, verifiedCall) .catch(_ => ({ user: { id, @@ -193,7 +195,7 @@ async function populateUserSpecifier( const groups = await Promise.all( userSpecifier.Group.map(group_id => station - .getUserGroup({ user_group_id: group_id }) + .getUserGroup({ user_group_id: group_id }, verifiedCall) .catch(_ => ({ user_group: { name: group_id, @@ -219,6 +221,7 @@ async function populateUserSpecifier( async function populateRule( rule: RequestPolicyRule, station: StationService, + verifiedCall: boolean, ): Promise { if (variantIs(rule, 'AutoApproved')) { return { @@ -228,13 +231,17 @@ async function populateRule( return { kind: RequestPolicyRuleEnum.Quorum, n: rule.Quorum.min_approved, - approvers: await populateUserSpecifier(rule.Quorum.approvers, station), + approvers: await populateUserSpecifier(rule.Quorum.approvers, station, verifiedCall), }; } else if (variantIs(rule, 'QuorumPercentage')) { return { kind: RequestPolicyRuleEnum.QuorumPercentage, n: rule.QuorumPercentage.min_approved, - approvers: await populateUserSpecifier(rule.QuorumPercentage.approvers, station), + approvers: await populateUserSpecifier( + rule.QuorumPercentage.approvers, + station, + verifiedCall, + ), }; } else if (variantIs(rule, 'AllowListedByMetadata')) { return { @@ -249,20 +256,20 @@ async function populateRule( } else if (variantIs(rule, 'AllOf')) { return { kind: RequestPolicyRuleEnum.AllOf, - rules: await Promise.all(rule.AllOf.map(r => populateRule(r, station))), + rules: await Promise.all(rule.AllOf.map(r => populateRule(r, station, verifiedCall))), }; } else if (variantIs(rule, 'AnyOf')) { return { kind: RequestPolicyRuleEnum.AnyOf, - rules: await Promise.all(rule.AnyOf.map(r => populateRule(r, station))), + rules: await Promise.all(rule.AnyOf.map(r => populateRule(r, station, verifiedCall))), }; } else if (variantIs(rule, 'Not')) { return { kind: RequestPolicyRuleEnum.Not, - rule: await populateRule(rule.Not, station), + rule: await populateRule(rule.Not, station, verifiedCall), }; } else if (variantIs(rule, 'NamedRule')) { - const { named_rule } = await station.getNamedRule(rule.NamedRule).catch(_ => ({ + const { named_rule } = await station.getNamedRule(rule.NamedRule, verifiedCall).catch(_ => ({ named_rule: { name: undefined, id: rule.NamedRule, @@ -274,7 +281,9 @@ async function populateRule( kind: 'NamedRule', name: named_rule.name, id: named_rule.id, - rule: named_rule.rule ? await populateRule(named_rule.rule, station) : undefined, + rule: named_rule.rule + ? await populateRule(named_rule.rule, station, verifiedCall) + : undefined, }; } else { return unreachable(rule); @@ -404,6 +413,7 @@ export function populatedRuleToTooltip( export function useRuleToTooltip(rule: Ref): Ref { const station = services().station; + const verifiedReads = useVerifiedReads(); const i18n = useI18n(); const tooltip = ref(null); @@ -415,7 +425,7 @@ export function useRuleToTooltip(rule: Ref): Ref): { complexRuleSummary: Ref; } { const station = services().station; + const verifiedReads = useVerifiedReads(); const populatedRule = ref(null); const complexRuleSummary = ref(null); const i18n = useI18n(); @@ -453,7 +464,7 @@ export function usePopulatedRule(rule: Ref): { populatedRule.value = null; return; } - populatedRule.value = await populateRule(rule, station); + populatedRule.value = await populateRule(rule, station, verifiedReads); const isComplex = isRuleComplex(populatedRule.value); complexRuleSummary.value = isComplex ? populatedRuleToTooltip(populatedRule.value, i18n) diff --git a/apps/wallet/src/composables/verified-reads.composable.spec.ts b/apps/wallet/src/composables/verified-reads.composable.spec.ts index d7349418c..96e825f8d 100644 --- a/apps/wallet/src/composables/verified-reads.composable.spec.ts +++ b/apps/wallet/src/composables/verified-reads.composable.spec.ts @@ -1,10 +1,12 @@ /* eslint-disable vue/one-component-per-file */ import { flushPromises } from '@vue/test-utils'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { defineComponent, h } from 'vue'; +import { defineComponent, h, ref } from 'vue'; +import { RequestPolicyRule } from '~/generated/station/station.did'; import { services } from '~/plugins/services.plugin'; import { mount } from '~/test.utils'; import { useUserGroupsAutocomplete } from './autocomplete.composable'; +import { useRuleToTooltip } from './request-policies.composable'; import { provideVerifiedReads, useVerifiedReads } from './verified-reads.composable'; const Reader = defineComponent({ @@ -26,6 +28,30 @@ const ReviewScope = defineComponent({ }, }); +const TooltipReader = defineComponent({ + setup() { + useRuleToTooltip( + ref({ Quorum: { min_approved: 1, approvers: { Group: ['group-1'] } } }), + ); + + return () => h('div'); + }, +}); + +const TooltipReviewScope = defineComponent({ + setup() { + provideVerifiedReads(); + + return () => h(TooltipReader); + }, +}); + +const mockUserGroup = () => + vi.spyOn(services().station, 'getUserGroup').mockResolvedValue({ + user_group: { id: 'group-1', name: 'Group 1' }, + privileges: { id: 'group-1', can_edit: false, can_delete: false }, + }); + describe('Verified reads composable', () => { afterEach(() => { vi.restoreAllMocks(); @@ -66,4 +92,28 @@ describe('Verified reads composable', () => { expect(services().station.listUserGroups).toHaveBeenCalledWith(expect.anything(), true), ); }); + + it('uses query calls for rule tooltips outside of a review scope', async () => { + mockUserGroup(); + + mount(TooltipReader); + await flushPromises(); + + expect(services().station.getUserGroup).toHaveBeenCalledWith( + { user_group_id: 'group-1' }, + false, + ); + }); + + it('uses verified calls for rule tooltips inside a review scope', async () => { + mockUserGroup(); + + mount(TooltipReviewScope); + await flushPromises(); + + expect(services().station.getUserGroup).toHaveBeenCalledWith( + { user_group_id: 'group-1' }, + true, + ); + }); }); From 4cd84acf52684a651d48f35c72cde50a5b174571 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:15:25 +0000 Subject: [PATCH 3/3] fix(wallet): verify group member tooltips in the request dialog `InteractiveUserGroup` lists a group's members on hover and is rendered inside the request dialog through rule summaries. Pass the dialog's verified reads flag to its `listUsers` call, like the other shared components, so it keeps using query calls elsewhere. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WAEJVi9S9JPmbMVGCycebk --- .../components/users/InteractiveUserGroup.vue | 15 ++++++---- .../verified-reads.composable.spec.ts | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/wallet/src/components/users/InteractiveUserGroup.vue b/apps/wallet/src/components/users/InteractiveUserGroup.vue index f0b3a6498..5bb8da48d 100644 --- a/apps/wallet/src/components/users/InteractiveUserGroup.vue +++ b/apps/wallet/src/components/users/InteractiveUserGroup.vue @@ -23,6 +23,7 @@