From 05a188e3a8bc0b512cb6bd7ae0a65b70f0974571 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Wed, 20 May 2026 14:01:06 +0200 Subject: [PATCH] fix(settings): confirm before revoking app tokens Adds an NcDialog confirmation to the Revoke action; deletion was previously instant on click. When the token is marked for remote wipe, the dialog surfaces a warning and the destructive button switches to "Cancel wipe and revoke" so cancelling an in-flight wipe is an explicit opt-in. Also migrates the existing Wipe confirm from the legacy window.OC.dialogs.confirm helper to NcDialog, matching the new delete dialog. The auth token store actions are now pure API callers; the UI does the gating. Signed-off-by: Peter Ringelmann --- .../settings/src/components/AuthToken.spec.ts | 188 +++++++++++++++++- apps/settings/src/components/AuthToken.vue | 29 ++- .../src/components/AuthTokenDeleteDialog.vue | 119 +++++++++++ .../src/components/AuthTokenWipeDialog.vue | 95 +++++++++ apps/settings/src/store/authtoken.ts | 19 -- 5 files changed, 428 insertions(+), 22 deletions(-) create mode 100644 apps/settings/src/components/AuthTokenDeleteDialog.vue create mode 100644 apps/settings/src/components/AuthTokenWipeDialog.vue diff --git a/apps/settings/src/components/AuthToken.spec.ts b/apps/settings/src/components/AuthToken.spec.ts index 2bfe1b62897..c83155eb9a3 100644 --- a/apps/settings/src/components/AuthToken.spec.ts +++ b/apps/settings/src/components/AuthToken.spec.ts @@ -3,9 +3,195 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { describe, expect, it } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// AuthToken.vue reads window.oc_defaults at module evaluation time. vi.hoisted +// runs before imports, so this guarantees the property is set on the existing +// jsdom window before the SFC is first parsed. +vi.hoisted(() => { + (window as unknown as { oc_defaults: { productName: string } }).oc_defaults = { productName: 'Nextcloud' } +}) + +import type { IToken } from '../store/authtoken.ts' + +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import AuthToken from './AuthToken.vue' +import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue' +import AuthTokenWipeDialog from './AuthTokenWipeDialog.vue' +import { TokenType, useAuthTokenStore } from '../store/authtoken.ts' import { detect } from '../utils/userAgentDetect.ts' +function makeToken(overrides: Partial = {}): IToken { + return { + id: 1, + name: 'Test device', + type: TokenType.PERMANENT_TOKEN, + lastActivity: 1700000000, + canDelete: true, + canRename: true, + scope: { filesystem: true }, + ...overrides, + } +} + +function mountAuthToken(token: IToken) { + return mount(AuthToken, { + // Vue Test Utils v1 (legacy pipeline) uses propsData; v2 also accepts it + propsData: { token }, + mocks: { + t: (_: string, text: string) => text, + }, + stubs: { + NcActions: true, + NcActionButton: true, + NcActionCheckbox: true, + NcButton: true, + NcDateTime: true, + NcIconSvgWrapper: true, + NcTextField: true, + }, + pinia: createTestingPinia({ + createSpy: vi.fn, + initialState: { 'auth-token': { tokens: [token] } }, + }), + }) +} + +function mountDeleteDialog(token: IToken, open = true) { + return mount(AuthTokenDeleteDialog, { + propsData: { token, open }, + mocks: { + t: (_: string, text: string) => text, + }, + stubs: { + NcDialog: { template: '
' }, + }, + }) +} + +describe('AuthToken revoke flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not call deleteToken when the revoke action is triggered (dialog opens first)', async () => { + const token = makeToken() + const wrapper = mountAuthToken(token) + const store = useAuthTokenStore() + + ;(wrapper.vm as unknown as { revoke: () => void }).revoke() + await wrapper.vm.$nextTick() + + const dialog = wrapper.findComponent(AuthTokenDeleteDialog) + expect(dialog.exists()).toBe(true) + expect(dialog.props('open')).toBe(true) + expect(store.deleteToken).not.toHaveBeenCalled() + }) + + it('calls deleteToken only after the dialog emits confirm', async () => { + const token = makeToken() + const wrapper = mountAuthToken(token) + const store = useAuthTokenStore() + + ;(wrapper.vm as unknown as { revoke: () => void }).revoke() + await wrapper.vm.$nextTick() + + const dialog = wrapper.findComponent(AuthTokenDeleteDialog) + dialog.vm.$emit('confirm') + dialog.vm.$emit('update:open', false) + await wrapper.vm.$nextTick() + + expect(store.deleteToken).toHaveBeenCalledTimes(1) + expect(store.deleteToken).toHaveBeenCalledWith(token) + }) + + it('does not call deleteToken when the dialog is dismissed without confirming', async () => { + const token = makeToken() + const wrapper = mountAuthToken(token) + const store = useAuthTokenStore() + + ;(wrapper.vm as unknown as { revoke: () => void }).revoke() + await wrapper.vm.$nextTick() + + const dialog = wrapper.findComponent(AuthTokenDeleteDialog) + dialog.vm.$emit('update:open', false) + await wrapper.vm.$nextTick() + + expect(dialog.props('open')).toBe(false) + expect(store.deleteToken).not.toHaveBeenCalled() + }) + + it('passes the wipe-pending token to the dialog when revoke is triggered', async () => { + const token = makeToken({ type: TokenType.WIPING_TOKEN, canRename: false }) + const wrapper = mountAuthToken(token) + + ;(wrapper.vm as unknown as { revoke: () => void }).revoke() + await wrapper.vm.$nextTick() + + const dialog = wrapper.findComponent(AuthTokenDeleteDialog) + expect(dialog.exists()).toBe(true) + expect(dialog.props('open')).toBe(true) + expect((dialog.props('token') as IToken).type).toBe(TokenType.WIPING_TOKEN) + }) +}) + +describe('AuthToken wipe flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not call wipeToken when the wipe action is triggered (dialog opens first)', async () => { + const token = makeToken() + const wrapper = mountAuthToken(token) + const store = useAuthTokenStore() + + ;(wrapper.vm as unknown as { wipe: () => void }).wipe() + await wrapper.vm.$nextTick() + + const dialog = wrapper.findComponent(AuthTokenWipeDialog) + expect(dialog.exists()).toBe(true) + expect(dialog.props('open')).toBe(true) + expect(store.wipeToken).not.toHaveBeenCalled() + }) + + it('calls wipeToken only after the dialog emits confirm', async () => { + const token = makeToken() + const wrapper = mountAuthToken(token) + const store = useAuthTokenStore() + + ;(wrapper.vm as unknown as { wipe: () => void }).wipe() + await wrapper.vm.$nextTick() + + const dialog = wrapper.findComponent(AuthTokenWipeDialog) + dialog.vm.$emit('confirm') + dialog.vm.$emit('update:open', false) + await wrapper.vm.$nextTick() + + expect(store.wipeToken).toHaveBeenCalledTimes(1) + expect(store.wipeToken).toHaveBeenCalledWith(token) + }) +}) + +describe('AuthTokenDeleteDialog wipe-pending warning', () => { + it('omits the warning for a normal token', () => { + const token = makeToken({ type: TokenType.PERMANENT_TOKEN }) + const wrapper = mountDeleteDialog(token) + expect(wrapper.findComponent(NcNoteCard).exists()).toBe(false) + }) + + it('renders an accessible warning NcNoteCard for a wipe-pending token', () => { + const token = makeToken({ type: TokenType.WIPING_TOKEN }) + const wrapper = mountDeleteDialog(token) + + const noteCard = wrapper.findComponent(NcNoteCard) + expect(noteCard.exists()).toBe(true) + expect(noteCard.props('type')).toBe('warning') + expect(noteCard.text()).toMatch(/wipe/i) + }) +}) + describe('Android Chrome detection', () => { it('modern Android Chrome (no Build/ string, post-2021) should match androidChrome', () => { const ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36' diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/components/AuthToken.vue index 034009e9a8c..0d9662952a8 100644 --- a/apps/settings/src/components/AuthToken.vue +++ b/apps/settings/src/components/AuthToken.vue @@ -59,7 +59,7 @@ @@ -99,6 +109,8 @@ import NcButton from '@nextcloud/vue/components/NcButton' import NcDateTime from '@nextcloud/vue/components/NcDateTime' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcTextField from '@nextcloud/vue/components/NcTextField' +import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue' +import AuthTokenWipeDialog from './AuthTokenWipeDialog.vue' import { TokenType, useAuthTokenStore } from '../store/authtoken.ts' import { detect } from '../utils/userAgentDetect.ts' @@ -124,6 +136,8 @@ const nameMap = { export default defineComponent({ name: 'AuthToken', components: { + AuthTokenDeleteDialog, + AuthTokenWipeDialog, NcActions, NcActionButton, NcActionCheckbox, @@ -151,7 +165,10 @@ export default defineComponent({ renaming: false, newName: '', oldName: '', + deleteDialogOpen: false, + wipeDialogOpen: false, mdiCheck, + TokenType, } }, @@ -277,6 +294,10 @@ export default defineComponent({ revoke() { this.actionOpen = false + this.deleteDialogOpen = true + }, + + confirmDelete() { this.authTokenStore.deleteToken(this.token) }, @@ -287,6 +308,10 @@ export default defineComponent({ wipe() { this.actionOpen = false + this.wipeDialogOpen = true + }, + + confirmWipe() { this.authTokenStore.wipeToken(this.token) }, }, diff --git a/apps/settings/src/components/AuthTokenDeleteDialog.vue b/apps/settings/src/components/AuthTokenDeleteDialog.vue new file mode 100644 index 00000000000..71db7193646 --- /dev/null +++ b/apps/settings/src/components/AuthTokenDeleteDialog.vue @@ -0,0 +1,119 @@ + + + + + + + diff --git a/apps/settings/src/components/AuthTokenWipeDialog.vue b/apps/settings/src/components/AuthTokenWipeDialog.vue new file mode 100644 index 00000000000..6182a0de8ac --- /dev/null +++ b/apps/settings/src/components/AuthTokenWipeDialog.vue @@ -0,0 +1,95 @@ + + + + + + + diff --git a/apps/settings/src/store/authtoken.ts b/apps/settings/src/store/authtoken.ts index 7546dd76608..bd300d999dd 100644 --- a/apps/settings/src/store/authtoken.ts +++ b/apps/settings/src/store/authtoken.ts @@ -14,20 +14,6 @@ import logger from '../logger.ts' const BASE_URL = generateUrl('/settings/personal/authtokens') addPasswordConfirmationInterceptors(axios) -/** - * - */ -function confirm() { - return new Promise((resolve) => { - window.OC.dialogs.confirm( - t('settings', 'Do you really want to wipe your data from this device?'), - t('settings', 'Confirm wipe'), - resolve, - true, - ) - }) -} - export enum TokenType { TEMPORARY_TOKEN = 0, PERMANENT_TOKEN = 1, @@ -134,11 +120,6 @@ export const useAuthTokenStore = defineStore('auth-token', { try { await confirmPassword() - if (!(await confirm())) { - logger.debug('Wipe aborted by user') - return - } - await axios.post(`${BASE_URL}/wipe/${token.id}`) logger.debug('App token marked for wipe', { token })