From 3744cd5a4ebe8ec06f9c9a8f550f74737829a562 Mon Sep 17 00:00:00 2001 From: tbphp Date: Wed, 29 Jul 2026 01:24:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=85=81=E8=AE=B8=E5=9C=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=A1=B5=E6=A0=B8=E5=AF=B9=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../access-keys/AccessKeyDrawer.test.ts | 6 +- .../features/access-keys/AccessKeyDrawer.vue | 83 ++++++++++++++----- .../access-keys/AccessKeysView.test.ts | 71 ++++++++++++++++ .../features/access-keys/AccessKeysView.vue | 48 +++++++++++ .../access-keys/access-key-edit-operation.ts | 8 ++ web/src/i18n/locales/en-US/access-keys.ts | 4 + web/src/i18n/locales/ja-JP/access-keys.ts | 4 + web/src/i18n/locales/zh-CN/access-keys.ts | 3 + 8 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 web/src/features/access-keys/access-key-edit-operation.ts diff --git a/web/src/features/access-keys/AccessKeyDrawer.test.ts b/web/src/features/access-keys/AccessKeyDrawer.test.ts index ce81c1aa..13160549 100644 --- a/web/src/features/access-keys/AccessKeyDrawer.test.ts +++ b/web/src/features/access-keys/AccessKeyDrawer.test.ts @@ -797,9 +797,7 @@ describe('AccessKeyDrawer', () => { const check = element('[data-test="access-key-save"]') expect(check.textContent).toContain('Check result') - expect(element('.app-drawer__close').disabled).toBe(true) - requestDrawerClose('close') - expect(wrapper.emitted('update:open') ?? []).not.toContainEqual([false]) + expect(element('.app-drawer__close').disabled).toBe(false) check.click() await flushPromises() @@ -828,7 +826,7 @@ describe('AccessKeyDrawer', () => { expect(element('[data-test="access-key-save"]').textContent).toContain( 'Check result', ) - expect(element('.app-drawer__close').disabled).toBe(true) + expect(element('.app-drawer__close').disabled).toBe(false) } wrapper.unmount() }, diff --git a/web/src/features/access-keys/AccessKeyDrawer.vue b/web/src/features/access-keys/AccessKeyDrawer.vue index 09105446..8f79e485 100644 --- a/web/src/features/access-keys/AccessKeyDrawer.vue +++ b/web/src/features/access-keys/AccessKeyDrawer.vue @@ -11,7 +11,6 @@ import { revealAccessKey, updateAccessKey, type CreateAccessKeyRequest, - type UpdateAccessKeyRequest, } from '@/api/control/access-keys' import type { AccessKeyDto, AccessProtocol, GroupSummary } from '@/api/control/types' import { RequestCancelledError } from '@/api/errors' @@ -26,6 +25,7 @@ import SecretValue from '@/components/ui/SecretValue.vue' import { accessKeyProtocolOptions, buildAccessKeyModelOptions } from './access-key-options' import type { PendingAccessKeyCreateOperation } from './access-key-create-operation' +import type { PendingAccessKeyEditOperation } from './access-key-edit-operation' import { materializeAccessKeyFilters, validateAccessKeyScope, @@ -45,11 +45,6 @@ import { } from './access-key-patch' import { useEphemeralSecret } from './use-ephemeral-secret' -interface PendingAccessKeyEditReconciliation { - base: AccessKeyDto - patch: UpdateAccessKeyRequest -} - const props = withDefaults( defineProps<{ open: boolean @@ -57,12 +52,14 @@ const props = withDefaults( groups: GroupSummary[] groupCatalogState?: GroupCatalogState createOperation?: PendingAccessKeyCreateOperation | null + editOperation?: PendingAccessKeyEditOperation | null }>(), - { createOperation: null, groupCatalogState: 'ready' }, + { createOperation: null, editOperation: null, groupCatalogState: 'ready' }, ) const emit = defineEmits<{ 'update:open': [open: boolean] 'update:createOperation': [operation: PendingAccessKeyCreateOperation | null] + 'update:editOperation': [operation: PendingAccessKeyEditOperation | null] }>() const client = useApiClient() const queryClient = useQueryClient() @@ -74,10 +71,11 @@ const result = ref(null) const operationID = ref('') const createPayload = ref(null) const createOperationRetained = ref(false) +const editOperationRetained = ref(false) const pending = ref(false) const failed = ref(false) const mutationState = ref<'idle' | 'indeterminate' | 'reconciling'>('idle') -const editReconciliation = ref(null) +const editReconciliation = ref(null) const editNotApplied = ref(false) const revealPending = ref(false) const revealFailed = ref(false) @@ -97,7 +95,7 @@ const createOperationActive = computed( const formLocked = computed( () => pending.value || createOperationActive.value || editReconciliation.value !== null, ) -const closeBlocked = computed(() => pending.value || editReconciliation.value !== null) +const closeBlocked = computed(() => pending.value) const resultSecret = computed(() => result.value ? ephemeralSecret.read(`access-key:${result.value.id}`) : null, ) @@ -110,7 +108,8 @@ const unsavedDirty = computed( () => dirty.value && !createCompleted.value && - !(createOperationActive.value && createOperationRetained.value), + !(createOperationActive.value && createOperationRetained.value) && + !(editReconciliation.value && editOperationRetained.value), ) const groupCatalog = computed(() => ({ state: props.groupCatalogState, @@ -195,6 +194,7 @@ function clearLocalState(): void { operationID.value = '' createPayload.value = null createOperationRetained.value = false + editOperationRetained.value = false pending.value = false revealPending.value = false revealFailed.value = false @@ -206,21 +206,34 @@ function clearLocalState(): void { } async function resetForOpen(): Promise { - const carriedOperation = props.accessKey ? null : props.createOperation - base.value = props.accessKey - draft.value = carriedOperation - ? createAccessKeyDraftFromCreateInput(carriedOperation.payload) - : createAccessKeyDraft(props.accessKey) + const carriedCreateOperation = props.accessKey ? null : props.createOperation + const carriedEditOperation = + props.accessKey && props.editOperation?.base.id === props.accessKey.id + ? props.editOperation + : null + base.value = carriedEditOperation?.base ?? props.accessKey + draft.value = carriedCreateOperation + ? createAccessKeyDraftFromCreateInput(carriedCreateOperation.payload) + : carriedEditOperation + ? createAccessKeyDraft({ + ...carriedEditOperation.base, + ...carriedEditOperation.patch, + filters: carriedEditOperation.patch.filters ?? carriedEditOperation.base.filters, + }) + : createAccessKeyDraft(props.accessKey) result.value = null operationID.value = props.accessKey ? '' - : (carriedOperation?.idempotencyKey ?? crypto.randomUUID()) - createPayload.value = carriedOperation ? cloneCreatePayload(carriedOperation.payload) : null - createOperationRetained.value = carriedOperation !== null + : (carriedCreateOperation?.idempotencyKey ?? crypto.randomUUID()) + createPayload.value = carriedCreateOperation + ? cloneCreatePayload(carriedCreateOperation.payload) + : null + createOperationRetained.value = carriedCreateOperation !== null + editOperationRetained.value = carriedEditOperation !== null ephemeralSecret.clear() failed.value = false - mutationState.value = carriedOperation?.state ?? 'idle' - editReconciliation.value = null + mutationState.value = carriedCreateOperation?.state ?? carriedEditOperation?.state ?? 'idle' + editReconciliation.value = carriedEditOperation editNotApplied.value = false modelInput.value = '' await nextTick() @@ -371,6 +384,8 @@ async function save(): Promise { draft.value = createAccessKeyDraft(saved) result.value = null editReconciliation.value = null + editOperationRetained.value = false + emit('update:editOperation', null) } else { const saved = await createAccessKey( client, @@ -441,10 +456,14 @@ async function save(): Promise { updateBody && (outcome.kind === 'indeterminate' || outcome.kind === 'reconciling') ) { - editReconciliation.value = { + const operation: PendingAccessKeyEditOperation = { base: currentBase, patch: updateBody, + state: outcome.kind, } + editReconciliation.value = operation + editOperationRetained.value = true + emit('update:editOperation', operation) } mutationState.value = outcome.kind === 'reconciling' @@ -479,6 +498,8 @@ async function reconcileEdit(): Promise { const latest = accessKeys.find((accessKey) => accessKey.id === attempt.base.id) if (!latest) { editReconciliation.value = null + editOperationRetained.value = false + emit('update:editOperation', null) mutationState.value = 'idle' failed.value = true return @@ -487,6 +508,8 @@ async function reconcileEdit(): Promise { base.value = latest draft.value = createAccessKeyDraft(latest) editReconciliation.value = null + editOperationRetained.value = false + emit('update:editOperation', null) mutationState.value = 'idle' return } @@ -496,19 +519,33 @@ async function reconcileEdit(): Promise { ) { base.value = latest editReconciliation.value = null + editOperationRetained.value = false + emit('update:editOperation', null) mutationState.value = 'idle' failed.value = true editNotApplied.value = true return } - mutationState.value = 'indeterminate' + const operation: PendingAccessKeyEditOperation = { + ...attempt, + state: 'indeterminate', + } + editReconciliation.value = operation + emit('update:editOperation', operation) + mutationState.value = operation.state } catch (error: unknown) { if ( controller === activeController && editReconciliation.value === attempt && !(error instanceof RequestCancelledError) ) { - mutationState.value = 'indeterminate' + const operation: PendingAccessKeyEditOperation = { + ...attempt, + state: 'indeterminate', + } + editReconciliation.value = operation + emit('update:editOperation', operation) + mutationState.value = operation.state } } finally { if (controller === activeController) { diff --git a/web/src/features/access-keys/AccessKeysView.test.ts b/web/src/features/access-keys/AccessKeysView.test.ts index 5b6e07d2..e569bf3d 100644 --- a/web/src/features/access-keys/AccessKeysView.test.ts +++ b/web/src/features/access-keys/AccessKeysView.test.ts @@ -283,6 +283,77 @@ describe('AccessKeysView', () => { wrapper.unmount() }) + it('keeps an unknown edit operation at page scope after the drawer closes', async () => { + const applied: AccessKeyDto = { ...keys[0], name: 'renamed-client' } + const secondary: AccessKeyDto = { + ...keys[0], + id: 10, + name: 'secondary-client', + masked_key: 'sk-gl-••••••••beef', + } + let listCalls = 0 + const requestMock = vi.fn(async (path: string, options?: ApiRequestOptions) => { + if (path === '/api/groups' && options?.method === 'GET') return groups + if (path === '/api/access-keys' && options?.method === 'GET') { + listCalls += 1 + return listCalls === 1 ? [keys[0], secondary] : [applied, secondary] + } + if (path === '/api/access-keys/9' && options?.method === 'PUT') throw new NetworkError() + throw new Error(`unexpected ${path}`) + }) + const confirm = vi.fn(() => false) + vi.stubGlobal('confirm', confirm) + const { wrapper } = await mountView(requestMock as ApiClient['request']) + + await wrapper.get('[data-test="access-key-edit-9"]').trigger('click') + await flushPromises() + const name = document.querySelector('[data-test="access-key-name"]') + if (!name) throw new Error('missing edit name') + name.value = 'renamed-client' + name.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + documentButton('[data-test="access-key-save"]').click() + await flushPromises() + + const close = documentButton('.app-drawer__close') + expect(close.disabled).toBe(false) + close.click() + await flushPromises() + + expect(confirm).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(wrapper.get('[data-test="access-key-edit-operation-notice"]').text()).toContain( + 'renamed-client', + ) + expect(wrapper.get('[data-test="access-key-edit-operation-notice"]').text()).toContain( + 'unknown outcome', + ) + + await wrapper.get('[data-test="access-key-edit-10"]').trigger('click') + await flushPromises() + expect(document.querySelector('[data-test="access-key-name"]')?.value).toBe( + 'renamed-client', + ) + documentButton('.app-drawer__close').click() + await flushPromises() + + await wrapper.get('[data-test="access-key-edit-operation-check"]').trigger('click') + await flushPromises() + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + documentButton('[data-test="access-key-save"]').click() + await flushPromises() + + expect( + requestMock.mock.calls.filter( + ([path, options]) => path === '/api/access-keys/9' && options?.method === 'PUT', + ), + ).toHaveLength(1) + expect(listCalls).toBe(2) + expect(wrapper.find('[data-test="access-key-edit-operation-notice"]').exists()).toBe(false) + wrapper.unmount() + vi.unstubAllGlobals() + }) + it('does not focus detached content when deleted is followed by immediate unmount', async () => { const request = vi.fn(async (path: string, options?: ApiRequestOptions) => { if (path === '/api/access-keys' && options?.method === 'GET') return keys diff --git a/web/src/features/access-keys/AccessKeysView.vue b/web/src/features/access-keys/AccessKeysView.vue index 0be9b728..9dee0120 100644 --- a/web/src/features/access-keys/AccessKeysView.vue +++ b/web/src/features/access-keys/AccessKeysView.vue @@ -19,6 +19,7 @@ import QueryFeedback from '@/components/ui/QueryFeedback.vue' import AccessKeyDrawer from './AccessKeyDrawer.vue' import AccessKeyTable from './AccessKeyTable.vue' import type { PendingAccessKeyCreateOperation } from './access-key-create-operation' +import type { PendingAccessKeyEditOperation } from './access-key-edit-operation' const client = useApiClient() const queryClient = useQueryClient() @@ -26,6 +27,7 @@ const { t } = useI18n() const drawerOpen = ref(false) const selected = ref(null) const createOperation = ref(null) +const editOperation = ref(null) const viewRoot = ref(null) const deletionAnnouncement = ref('') let restoreFocus: HTMLElement | null = null @@ -54,6 +56,14 @@ const operationNoticeKey = computed(() => ? 'accessKeys.operation.reconciling' : 'accessKeys.operation.indeterminate', ) +const editOperationNoticeKey = computed(() => + editOperation.value?.state === 'reconciling' + ? 'accessKeys.operation.editReconciling' + : 'accessKeys.operation.editIndeterminate', +) +const editOperationName = computed( + () => editOperation.value?.patch.name ?? editOperation.value?.base.name ?? '', +) function createKey(): void { selected.value = null @@ -62,6 +72,10 @@ function createKey(): void { } function editKey(accessKey: AccessKeyDto, trigger: HTMLElement): void { + if (editOperation.value && editOperation.value.base.id !== accessKey.id) { + checkEditOperation() + return + } selected.value = accessKey restoreFocus = trigger drawerOpen.value = true @@ -71,6 +85,10 @@ function setCreateOperation(operation: PendingAccessKeyCreateOperation | null): createOperation.value = operation } +function setEditOperation(operation: PendingAccessKeyEditOperation | null): void { + editOperation.value = operation +} + function checkCreateOperation(): void { if (!createOperation.value) return selected.value = null @@ -78,6 +96,16 @@ function checkCreateOperation(): void { drawerOpen.value = true } +function checkEditOperation(): void { + const operation = editOperation.value + if (!operation) return + selected.value = + accessKeysQuery.data.value?.find((accessKey) => accessKey.id === operation.base.id) ?? + operation.base + restoreFocus = null + drawerOpen.value = true +} + async function setDrawerOpen(open: boolean): Promise { drawerOpen.value = open if (!open) { @@ -119,7 +147,9 @@ async function focusCreateAfterDelete(name: string): Promise { :groups="groupsQuery.data.value ?? []" :group-catalog-state="groupCatalogState" :create-operation="createOperation" + :edit-operation="selected?.id === editOperation?.base.id ? editOperation : null" @update:create-operation="setCreateOperation" + @update:edit-operation="setEditOperation" @update:open="setDrawerOpen" >