Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/settings/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

['name' => 'AuthSettings#create', 'url' => '/settings/personal/authtokens', 'verb' => 'POST' , 'root' => ''],
['name' => 'AuthSettings#update', 'url' => '/settings/personal/authtokens/{id}', 'verb' => 'PUT' , 'root' => ''],
['name' => 'AuthSettings#destroyAll', 'url' => '/settings/personal/authtokens', 'verb' => 'DELETE' , 'root' => ''],
['name' => 'AuthSettings#destroy', 'url' => '/settings/personal/authtokens/{id}', 'verb' => 'DELETE' , 'root' => ''],
['name' => 'AuthSettings#wipe', 'url' => '/settings/personal/authtokens/wipe/{id}', 'verb' => 'POST' , 'root' => ''],

Expand Down
9 changes: 9 additions & 0 deletions apps/settings/lib/Activity/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Provider implements IProvider {
public const APP_TOKEN_CREATED = 'app_token_created';
public const APP_TOKEN_DELETED = 'app_token_deleted';
public const APP_TOKEN_DELETED_WIPE_CANCELLED = 'app_token_deleted_wipe_cancelled';
public const APP_TOKEN_DELETED_ALL = 'app_token_deleted_all';
public const APP_TOKEN_RENAMED = 'app_token_renamed';
public const APP_TOKEN_FILESYSTEM_GRANTED = 'app_token_filesystem_granted';
public const APP_TOKEN_FILESYSTEM_REVOKED = 'app_token_filesystem_revoked';
Expand Down Expand Up @@ -90,6 +91,13 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null):
$subject = $this->l->t('You deleted app password "{token}"');
} elseif ($event->getSubject() === self::APP_TOKEN_DELETED_WIPE_CANCELLED) {
$subject = $this->l->t('You deleted app password "{token}" and cancelled its pending remote wipe');
} elseif ($event->getSubject() === self::APP_TOKEN_DELETED_ALL) {
$count = (int)($event->getSubjectParameters()['count'] ?? 0);
$subject = $this->l->n(
'You revoked %n other session',
'You revoked %n other sessions',
$count,
);
} elseif ($event->getSubject() === self::APP_TOKEN_RENAMED) {
$subject = $this->l->t('You renamed app password "{token}" to "{newToken}"');
} elseif ($event->getSubject() === self::APP_TOKEN_FILESYSTEM_GRANTED) {
Expand Down Expand Up @@ -121,6 +129,7 @@ protected function getParameters(IEvent $event): array {
case self::PASSWORD_RESET_SELF:
case self::EMAIL_CHANGED_SELF:
case self::EMAIL_CHANGED:
case self::APP_TOKEN_DELETED_ALL:
return [];
case self::PASSWORD_CHANGED_BY:
case self::EMAIL_CHANGED_BY:
Expand Down
52 changes: 49 additions & 3 deletions apps/settings/lib/Controller/AuthSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,46 @@ public function destroy(int $id): JSONResponse {
return new JSONResponse([]);
}

/**
* Wipe-pending tokens are kept: revoking one cancels its pending wipe, so that
* stays a per-token decision.
*/
#[NoSubAdminRequired]
#[NoAdminRequired]
#[PasswordConfirmationRequired(strict: true)]
public function destroyAll(): JSONResponse {
if ($this->checkAppToken()) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}

if ($this->userSession->getImpersonatingUserID() !== null) {
return $this->getServiceNotAvailableResponse();
}

try {
$currentTokenId = $this->tokenProvider->getToken($this->session->getId())->getId();
} catch (SessionNotAvailableException|InvalidTokenException) {
return $this->getServiceNotAvailableResponse();
}

$revoked = [];
foreach ($this->tokenProvider->getTokenByUser($this->userId) as $token) {
if ($token->getId() === $currentTokenId || $token->getType() === IToken::WIPE_TOKEN) {
continue;
}

$this->tokenProvider->invalidateTokenById($this->userId, $token->getId());
$revoked[] = $token->getId();
}

if ($revoked !== []) {
// One aggregate entry rather than one per token, so a bulk revoke does not bury the feed.
$this->publishActivity(Provider::APP_TOKEN_DELETED_ALL, null, ['count' => count($revoked)]);
}

return new JSONResponse(['revoked' => $revoked]);
}

#[NoSubAdminRequired]
#[NoAdminRequired]
#[PasswordConfirmationRequired(strict: true)]
Expand Down Expand Up @@ -222,14 +262,20 @@ public function update(int $id, array $scope, string $name): JSONResponse {
return new JSONResponse([]);
}

private function publishActivity(string $subject, int $id, array $parameters = []): void {
/**
* @param int|null $id Token the event is about, or null for events that span several tokens
*/
private function publishActivity(string $subject, ?int $id, array $parameters = []): void {
$event = $this->activityManager->generateEvent();
$event->setApp('settings')
->setType('security')
->setAffectedUser($this->userId)
->setAuthor($this->userId)
->setSubject($subject, $parameters)
->setObject('app_token', $id, 'App Password');
->setSubject($subject, $parameters);

if ($id !== null) {
$event->setObject('app_token', $id, 'App Password');
}

try {
$this->activityManager->publish($event);
Expand Down
211 changes: 211 additions & 0 deletions apps/settings/src/components/AuthTokenRevokeAll.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IToken } from '../store/authtoken.ts'

import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'

// AuthToken.vue, pulled in transitively, reads window.OC.theme.productName at module
// evaluation time. vi.hoisted runs before imports, so it is set before the SFC is parsed.
vi.hoisted(() => {
(window as unknown as { OC: { theme: { productName: string } } }).OC.theme = { productName: 'Nextcloud' }
})

vi.mock('@nextcloud/initial-state', () => ({
loadState: vi.fn((_app: string, key: string) => (key === 'app_tokens' ? [] : true)),
}))

import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import AuthTokenRevokeAllDialog from './AuthTokenRevokeAllDialog.vue'
import AuthTokenSection from './AuthTokenSection.vue'
import { TokenType, useAuthTokenStore } from '../store/authtoken.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,
}
}

// Renders the `buttons` prop as real buttons, so the callbacks are exercised by clicking
// rather than by reaching into the component instance.
const NcDialogStub = {
props: ['buttons'],
template: '<div><slot /><button v-for="(button, index) in buttons" :key="index" @click="button.callback()">{{ button.label }}</button></div>',
}

const NcButtonStub = {
template: '<button @click="$emit(\'click\')"><slot /></button>',
}

function mountSection(tokens: IToken[]) {
return mount(AuthTokenSection, {
mocks: {
t: (_: string, text: string) => text,
},
stubs: {
AuthTokenList: true,
AuthTokenSetup: true,
NcSettingsSection: { template: '<div><slot /></div>' },
NcButton: NcButtonStub,
NcDialog: NcDialogStub,
},
pinia: createTestingPinia({
createSpy: vi.fn,
initialState: { 'auth-token': { tokens } },
}),
})
}

function mountDialog(props: { count: number, wipePendingCount: number, open?: boolean }) {
return mount(AuthTokenRevokeAllDialog, {
propsData: { open: true, ...props },
mocks: {
t: (_: string, text: string) => text,
},
stubs: {
NcDialog: NcDialogStub,
},
})
}

describe('AuthTokenSection revoke-all button', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('hides the button when only the current session exists', () => {
const wrapper = mountSection([makeToken({ id: 1, current: true })])

expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.findComponent(AuthTokenRevokeAllDialog).exists()).toBe(false)
})

it('hides the button when the only other token is wipe-pending', () => {
const wrapper = mountSection([
makeToken({ id: 1, current: true }),
makeToken({ id: 2, type: TokenType.WIPING_TOKEN }),
])

expect(wrapper.find('button').exists()).toBe(false)
})

it('shows the button and opens the dialog without revoking anything yet', async () => {
const wrapper = mountSection([
makeToken({ id: 1, current: true }),
makeToken({ id: 2 }),
])
const store = useAuthTokenStore()

const button = wrapper.find('button')
expect(button.exists()).toBe(true)

await button.trigger('click')

const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
expect(dialog.exists()).toBe(true)
expect(dialog.props('open')).toBe(true)
expect(store.deleteAllOtherTokens).not.toHaveBeenCalled()
})

// One-time tokens are hidden from the table but still revoked, so they count.
it('counts every other token except the wipe-pending ones', async () => {
const wrapper = mountSection([
makeToken({ id: 1, current: true }),
makeToken({ id: 2 }),
makeToken({ id: 3 }),
makeToken({ id: 4, type: TokenType.WIPING_TOKEN }),
makeToken({ id: 5, type: TokenType.ONETIME_TOKEN }),
])

await wrapper.find('button').trigger('click')

const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
expect(dialog.props('count')).toBe(3)
expect(dialog.props('wipePendingCount')).toBe(1)
})

it('revokes only after the dialog emits confirm', async () => {
const wrapper = mountSection([
makeToken({ id: 1, current: true }),
makeToken({ id: 2 }),
])
const store = useAuthTokenStore()

await wrapper.find('button').trigger('click')

const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
dialog.vm.$emit('confirm')
dialog.vm.$emit('update:open', false)
await wrapper.vm.$nextTick()

expect(store.deleteAllOtherTokens).toHaveBeenCalledTimes(1)
})

it('does not revoke when the dialog is dismissed', async () => {
const wrapper = mountSection([
makeToken({ id: 1, current: true }),
makeToken({ id: 2 }),
])
const store = useAuthTokenStore()

await wrapper.find('button').trigger('click')

const dialog = wrapper.findComponent(AuthTokenRevokeAllDialog)
dialog.vm.$emit('update:open', false)
await wrapper.vm.$nextTick()

expect(wrapper.findComponent(AuthTokenRevokeAllDialog).exists()).toBe(false)
expect(store.deleteAllOtherTokens).not.toHaveBeenCalled()
})
})

describe('AuthTokenRevokeAllDialog', () => {
it('omits the wipe note when nothing is pending a wipe', () => {
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })
expect(wrapper.findComponent(NcNoteCard).exists()).toBe(false)
})

it('warns that wipe-pending devices keep access', () => {
const wrapper = mountDialog({ count: 3, wipePendingCount: 2 })

const noteCard = wrapper.findComponent(NcNoteCard)
expect(noteCard.exists()).toBe(true)
expect(noteCard.props('type')).toBe('warning')
expect(noteCard.text()).toMatch(/wipe/i)
})

it('tells the user the current session is kept', () => {
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })
expect(wrapper.text()).toMatch(/stay signed in here/i)
})

it('emits confirm and closes when the destructive button is used', async () => {
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })

const buttons = wrapper.findAll('button')
await buttons.at(buttons.length - 1).trigger('click')

expect(wrapper.emitted('confirm')).toHaveLength(1)
expect(wrapper.emitted('update:open')).toEqual([[false]])
})

it('closes without confirming when cancelled', async () => {
const wrapper = mountDialog({ count: 3, wipePendingCount: 0 })

await wrapper.findAll('button').at(0).trigger('click')

expect(wrapper.emitted('confirm')).toBeFalsy()
expect(wrapper.emitted('update:open')).toEqual([[false]])
})
})
74 changes: 74 additions & 0 deletions apps/settings/src/components/AuthTokenRevokeAllDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import type { IDialogButton } from '@nextcloud/dialogs'

import { translatePlural as n, translate as t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'

defineProps<{
/** Number of sessions and app passwords that will be revoked */
count: number
/** Number of devices keeping access because their remote wipe is still pending */
wipePendingCount: number
/** Whether the dialog is open */
open: boolean
}>()

const emit = defineEmits<{
'update:open': [open: boolean]
confirm: []
}>()

const buttons = computed<IDialogButton[]>(() => [
{
label: t('settings', 'Cancel'),
variant: 'tertiary',
callback: () => emit('update:open', false),
},
{
label: t('settings', 'Revoke all others'),
variant: 'error',
callback: () => {
emit('confirm')
emit('update:open', false)
},
},
])
</script>

<template>
<NcDialog
:open="open"
:name="t('settings', 'Revoke all other sessions?')"
:buttons="buttons"
size="normal"
@update:open="emit('update:open', $event)">
<NcNoteCard v-if="wipePendingCount > 0" type="warning">
{{ n('settings',
'%n device keeps access because its remote wipe has not finished. Revoke it on its own to cancel the wipe.',
'%n devices keep access because their remote wipe has not finished. Revoke them on their own to cancel the wipe.',
wipePendingCount) }}
</NcNoteCard>
<p class="auth-token-revoke-all-dialog__body">
{{ n('settings',
'This signs out %n other device or app. You stay signed in here.',
'This signs out %n other devices and apps. You stay signed in here.',
count) }}
</p>
<p class="auth-token-revoke-all-dialog__body">
{{ t('settings', 'Sync clients and connected services have to sign in again. This cannot be undone.') }}
</p>
</NcDialog>
</template>

<style lang="scss" scoped>
.auth-token-revoke-all-dialog__body {
margin-block-start: calc(var(--default-grid-baseline) * 2);
}
</style>
Loading
Loading