mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
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:
parent
0a4d4bc8ab
commit
05a188e3a8
5 changed files with 428 additions and 22 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
119
apps/settings/src/components/AuthTokenDeleteDialog.vue
Normal file
119
apps/settings/src/components/AuthTokenDeleteDialog.vue
Normal 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>
|
||||
95
apps/settings/src/components/AuthTokenWipeDialog.vue
Normal file
95
apps/settings/src/components/AuthTokenWipeDialog.vue
Normal 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>
|
||||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue