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 <peter.ringelmann@nextcloud.com>
This commit is contained in:
Peter Ringelmann 2026-05-20 14:01:06 +02:00 committed by Peter R.
parent 0a4d4bc8ab
commit 05a188e3a8
5 changed files with 428 additions and 22 deletions

View file

@ -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> = {}): 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: '<div><slot /></div>' },
},
})
}
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'

View file

@ -59,7 +59,7 @@
<!-- revoke & wipe -->
<template v-if="token.canDelete">
<template v-if="token.type !== 2">
<template v-if="token.type !== TokenType.WIPING_TOKEN">
<NcActionButton
icon="icon-delete"
@click.stop.prevent="revoke">
@ -73,7 +73,7 @@
</NcActionButton>
</template>
<NcActionButton
v-else-if="token.type === 2"
v-else
icon="icon-delete"
:name="t('settings', 'Revoke')"
@click.stop.prevent="revoke">
@ -82,6 +82,16 @@
</template>
</NcActions>
</td>
<AuthTokenDeleteDialog
:token="token"
:open="deleteDialogOpen"
@update:open="deleteDialogOpen = $event"
@confirm="confirmDelete" />
<AuthTokenWipeDialog
:token="token"
:open="wipeDialogOpen"
@update:open="wipeDialogOpen = $event"
@confirm="confirmWipe" />
</tr>
</template>
@ -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)
},
},

View file

@ -0,0 +1,119 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog
:open="open"
:name="dialogTitle"
:buttons="buttons"
size="normal"
@update:open="onUpdateOpen">
<NcNoteCard v-if="wiping" type="warning">
<p>
{{ t('settings', 'The remote wipe for this device has not finished yet. Revoking the app password now will cancel the pending wipe and the device will keep its access to previously synced data.') }}
</p>
</NcNoteCard>
<p class="auth-token-delete-dialog__body">
{{ bodyText }}
</p>
</NcDialog>
</template>
<script lang="ts">
import type { IDialogButton } from '@nextcloud/dialogs'
import type { PropType } from 'vue'
import type { IToken } from '../store/authtoken.ts'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import { TokenType } from '../store/authtoken.ts'
export default defineComponent({
name: 'AuthTokenDeleteDialog',
components: {
NcDialog,
NcNoteCard,
},
props: {
token: {
type: Object as PropType<IToken>,
required: true,
},
open: {
type: Boolean,
required: true,
},
},
emits: {
'update:open': (open: boolean) => typeof open === 'boolean',
confirm: () => true,
},
computed: {
wiping(): boolean {
return this.token.type === TokenType.WIPING_TOKEN
},
dialogTitle(): string {
return this.wiping
? t('settings', 'Cancel pending remote wipe and revoke app password?')
: t('settings', 'Revoke app password?')
},
bodyText(): string {
if (this.wiping) {
return t('settings', 'Continuing will cancel the pending remote wipe and permanently revoke this app password. The device will retain any data it has already synced.')
}
return t('settings', 'This will permanently revoke the app password. The connected app or device will lose access on its next sync.')
},
destructiveLabel(): string {
return this.wiping
? t('settings', 'Cancel wipe and revoke')
: t('settings', 'Revoke')
},
buttons(): IDialogButton[] {
return [
{
label: t('settings', 'Cancel'),
// @ts-expect-error 'value' is missing from upstream types
type: 'tertiary',
callback: () => {
this.$emit('update:open', false)
},
},
{
label: this.destructiveLabel,
type: 'error',
callback: () => {
this.$emit('confirm')
this.$emit('update:open', false)
},
},
]
},
},
methods: {
t,
onUpdateOpen(value: boolean): void {
this.$emit('update:open', value)
},
},
})
</script>
<style lang="scss" scoped>
.auth-token-delete-dialog__body {
margin-block-start: calc(var(--default-grid-baseline) * 2);
}
</style>

View file

@ -0,0 +1,95 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog
:open="open"
:name="t('settings', 'Wipe device?')"
:buttons="buttons"
size="normal"
@update:open="onUpdateOpen">
<NcNoteCard type="warning">
<p>
{{ t('settings', 'This will mark the device for remote wipe. The next time it connects, all synced data will be removed.') }}
</p>
</NcNoteCard>
<p class="auth-token-wipe-dialog__body">
{{ t('settings', 'Do you really want to wipe your data from "{name}"?', { name: token.name }) }}
</p>
</NcDialog>
</template>
<script lang="ts">
import type { IDialogButton } from '@nextcloud/dialogs'
import type { PropType } from 'vue'
import type { IToken } from '../store/authtoken.ts'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
export default defineComponent({
name: 'AuthTokenWipeDialog',
components: {
NcDialog,
NcNoteCard,
},
props: {
token: {
type: Object as PropType<IToken>,
required: true,
},
open: {
type: Boolean,
required: true,
},
},
emits: {
'update:open': (open: boolean) => typeof open === 'boolean',
confirm: () => true,
},
computed: {
buttons(): IDialogButton[] {
return [
{
label: t('settings', 'Cancel'),
// @ts-expect-error 'value' is missing from upstream types
type: 'tertiary',
callback: () => {
this.$emit('update:open', false)
},
},
{
label: t('settings', 'Wipe device'),
type: 'error',
callback: () => {
this.$emit('confirm')
this.$emit('update:open', false)
},
},
]
},
},
methods: {
t,
onUpdateOpen(value: boolean): void {
this.$emit('update:open', value)
},
},
})
</script>
<style lang="scss" scoped>
.auth-token-wipe-dialog__body {
margin-block-start: calc(var(--default-grid-baseline) * 2);
}
</style>

View file

@ -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 })