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
11 changes: 8 additions & 3 deletions apps/settings/lib/Controller/AuthSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ public function __construct(

/**
* @param bool $qrcodeLogin If set to true, the returned token could be (depending on server settings) a onetime password, that can only be used to get the actual app password a single time
* @param list<string>|null $scopes Scopes the app password is limited to, null for all access. Ignored for QR code logins
*/
#[NoSubAdminRequired]
#[NoAdminRequired]
#[PasswordConfirmationRequired(strict: true)]
public function create(string $name = '', bool $qrcodeLogin = false): JSONResponse {
public function create(string $name = '', bool $qrcodeLogin = false, ?array $scopes = null): JSONResponse {
if ($this->checkAppToken()) {
return $this->getServiceNotAvailableResponse();
}
Expand Down Expand Up @@ -111,7 +112,11 @@ public function create(string $name = '', bool $qrcodeLogin = false): JSONRespon
return $this->getServiceNotAvailableResponse();
} else {
$type = IToken::PERMANENT_TOKEN;
$scope = null;
try {
$scope = $scopes === null ? null : $this->tokenScopes->updateScope([], [TokenScopes::KEY => $scopes]);
} catch (\InvalidArgumentException) {
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
}

if (mb_strlen($name) > 128) {
Expand Down Expand Up @@ -275,7 +280,7 @@ public function update(int $id, array $scope, string $name): JSONResponse {
}

$this->tokenProvider->updateToken($token);
return new JSONResponse([]);
return new JSONResponse(['scope' => $newScope]);
}

/**
Expand Down
26 changes: 26 additions & 0 deletions apps/settings/src/authtokens/components/AuthToken.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ vi.mock('@nextcloud/dialogs', () => ({
showConfirmation: showConfirmationMock,
}))

import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import AuthToken from './AuthToken.vue'
import AuthTokenAccessDialog from './AuthTokenAccessDialog.vue'
import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue'
import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue'
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
Expand Down Expand Up @@ -343,3 +345,27 @@ describe('Desktop Firefox regression tests', () => {
})
})
})

describe('AuthToken access', () => {
it('shows what an app password can reach', () => {
const wrapper = mountAuthToken(makeToken({ scope: { filesystem: true, scopes: ['files:files:read', 'files:files:write', 'files:dav:read', 'files:dav:write', 'files_sharing:shares:write'] } }))

expect(wrapper.find('.auth-token__access').text()).toBe('Files')
})

it('keeps the filesystem toggle for tokens without scopes only', () => {
const stubs = { NcActions: { template: '<div><slot /></div>' } }
expect(mountAuthToken(makeToken(), { stubs }).findComponent(NcActionCheckbox).exists()).toBe(true)
expect(mountAuthToken(makeToken({ scope: { filesystem: false, scopes: ['dav:calendar:read'] } }), { stubs }).findComponent(NcActionCheckbox).exists()).toBe(false)
})

it('opens the access dialog for the token', async () => {
const token = makeToken()
const wrapper = mountAuthToken(token, { stubs: { AuthTokenAccessDialog: true } })

;(wrapper.vm as unknown as { editAccess: () => void }).editAccess()
await nextTick()

expect(wrapper.findComponent(AuthTokenAccessDialog).props('token')).toEqual(token)
})
})
38 changes: 35 additions & 3 deletions apps/settings/src/authtokens/components/AuthToken.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@
<script setup lang="ts">
import type { IToken } from '../store/authtoken.ts'

import { mdiAndroid, mdiAppleIos, mdiAppleSafari, mdiCellphone, mdiCheck, mdiFirefox, mdiGoogleChrome, mdiKeyOutline, mdiMicrosoftEdge, mdiMonitor, mdiPencilOutline, mdiTablet, mdiTrashCanOutline, mdiWeb } from '@mdi/js'
import { mdiAndroid, mdiAppleIos, mdiAppleSafari, mdiCellphone, mdiCheck, mdiFirefox, mdiGoogleChrome, mdiKeyOutline, mdiMicrosoftEdge, mdiMonitor, mdiPencilOutline, mdiShieldEditOutline, mdiTablet, mdiTrashCanOutline, mdiWeb } from '@mdi/js'
import { showConfirmation } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
import NcActions from '@nextcloud/vue/components/NcActions'
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 AuthTokenAccessDialog from './AuthTokenAccessDialog.vue'
import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue'
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
import { accessLabel } from '../utils/accessLevel.ts'
import { detect } from '../utils/userAgentDetect.ts'

const props = defineProps<{
Expand Down Expand Up @@ -56,8 +58,16 @@ const actionOpen = ref(false)
const renaming = ref(false)
const newName = ref('')
const deleteDialogOpen = ref(false)
const accessDialogOpen = ref(false)

const canChangeScope = computed(() => props.token.type === TokenType.PERMANENT_TOKEN)
const access = computed(() => accessLabel(props.token.scope))

watch(accessDialogOpen, (open) => {
if (!open) {
focusActions()
}
})

/**
* Object ob the current user agent used by the token
Expand Down Expand Up @@ -156,6 +166,13 @@ function updateFileSystemScope(state: boolean) {
authTokenStore.setTokenScope(props.token, 'filesystem', state)
}

/**
* Open the dialog to change what this token can reach
*/
function editAccess() {
accessDialogOpen.value = true
}

/**
* Show the rename form for this token
*/
Expand Down Expand Up @@ -253,6 +270,7 @@ async function wipe() {
</NcButton>
</form>
<span v-else>{{ tokenLabel }}</span>
<span v-if="canChangeScope" class="auth-token__access">{{ access }}</span>
<span v-if="wiping" class="wiping-warning">({{ t('settings', 'Marked for remote wipe') }})</span>
</div>
</td>
Expand All @@ -269,9 +287,18 @@ async function wipe() {
v-model:open="actionOpen"
:title="t('settings', 'Device settings')"
:aria-label="t('settings', 'Device settings')">
<NcActionButton
v-if="canChangeScope"
closeAfterClick
@click.stop.prevent="editAccess">
<template #icon>
<NcIconSvgWrapper :path="mdiShieldEditOutline" />
</template>
{{ t('settings', 'Edit access') }}
</NcActionButton>
<!-- TODO: add text/longtext with some description -->
<NcActionCheckbox
v-if="canChangeScope"
v-if="canChangeScope && token.scope.scopes === undefined"
:modelValue="token.scope.filesystem"
@update:modelValue="updateFileSystemScope">
{{ t('settings', 'Allow filesystem access') }}
Expand Down Expand Up @@ -317,6 +344,10 @@ async function wipe() {
</template>
</NcActions>
</td>
<AuthTokenAccessDialog
v-if="accessDialogOpen"
v-model:open="accessDialogOpen"
:token="token" />
<AuthTokenDeleteDialog
v-if="deleteDialogOpen"
v-model:open="deleteDialogOpen"
Expand Down Expand Up @@ -364,6 +395,7 @@ async function wipe() {
padding-inline-start: 10px;
}

&__access,
.wiping-warning {
color: var(--color-text-maxcontrast);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* 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 { fireEvent, getByRole } from '@testing-library/vue'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AuthTokenAccessDialog from './AuthTokenAccessDialog.vue'
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'

enableAutoUnmount(afterEach)

const NcDialog = {
props: ['buttons'],
template: '<form @submit.prevent><slot /><button v-for="button in buttons" :key="button.label" :type="button.type ?? \'button\'" @click="button.callback?.()">{{ button.label }}</button></form>',
}

function makeToken(scope: IToken['scope']): IToken {
return {
id: 1,
name: 'Phone',
type: TokenType.PERMANENT_TOKEN,
lastActivity: 1700000000,
canDelete: true,
canRename: true,
scope,
}
}

function mountDialog(token?: IToken) {
return mount(AuthTokenAccessDialog, {
props: { token, open: true },
attachTo: document.body,
global: {
stubs: { NcDialog },
plugins: [createTestingPinia({ createSpy: vi.fn })],
},
})
}

describe('AuthTokenAccessDialog', () => {
const button = (wrapper: ReturnType<typeof mountDialog>, name: string) => getByRole(wrapper.element as HTMLElement, 'button', { name })
const checkbox = (wrapper: ReturnType<typeof mountDialog>, name: string) => getByRole(wrapper.element as HTMLElement, 'checkbox', { name }) as HTMLInputElement

it('saves nothing when the access is unchanged', async () => {
const dialog = mountDialog(makeToken({ filesystem: false }))
const store = useAuthTokenStore()

await fireEvent.click(button(dialog, 'Save'))
await flushPromises()

expect(store.setTokenScopes).not.toHaveBeenCalled()
})

it('shows a partly granted area as mixed', () => {
const dialog = mountDialog(makeToken({ filesystem: true, scopes: ['dav:calendar:read', 'dav:contacts:read', 'dav:contacts:write'] }))

expect(checkbox(dialog, 'Calendar').indeterminate).toBe(true)
expect(checkbox(dialog, 'Can edit calendars').checked).toBe(false)
expect(checkbox(dialog, 'Contacts').checked).toBe(true)
expect(checkbox(dialog, 'Contacts').indeterminate).toBe(false)
})

it('needs at least one area in Custom', async () => {
const dialog = mountDialog(makeToken({ filesystem: true, scopes: ['dav:calendar:read', 'dav:calendar:write'] }))
const store = useAuthTokenStore()

await fireEvent.click(checkbox(dialog, 'Calendar'))
await fireEvent.click(button(dialog, 'Save'))
await flushPromises()

expect(checkbox(dialog, 'Files').validity.valid).toBe(false)
expect(store.setTokenScopes).not.toHaveBeenCalled()
})

it('saves the new scopes and keeps the ones it can not show', async () => {
const token = makeToken({ filesystem: false, scopes: ['dav:contacts:read', 'dav:contacts:write', 'app:deck'] })
const dialog = mountDialog(token)
const store = useAuthTokenStore()
vi.mocked(store.setTokenScopes).mockResolvedValue(true)

expect(dialog.text()).toContain('access set outside of Settings')
await fireEvent.click(checkbox(dialog, 'Calendar'))
await fireEvent.click(button(dialog, 'Save'))
await flushPromises()

expect(store.setTokenScopes).toHaveBeenCalledWith(token, ['dav:contacts:read', 'dav:contacts:write', 'dav:calendar:read', 'dav:calendar:write', 'app:deck'])
})

it('saves the kept scopes alone when every area is unticked', async () => {
const token = makeToken({ filesystem: false, scopes: ['dav:calendar:read', 'dav:calendar:write', 'app:deck'] })
const dialog = mountDialog(token)
const store = useAuthTokenStore()
vi.mocked(store.setTokenScopes).mockResolvedValue(true)

await fireEvent.click(checkbox(dialog, 'Calendar'))
await fireEvent.click(button(dialog, 'Save'))
await flushPromises()

expect(store.setTokenScopes).toHaveBeenCalledWith(token, ['app:deck'])
})

it('needs a name to create a token', async () => {
const dialog = mountDialog()
const store = useAuthTokenStore()

await fireEvent.click(button(dialog, 'Create'))
await flushPromises()

expect(store.addToken).not.toHaveBeenCalled()
})

it('creates a token with the chosen preset', async () => {
const dialog = mountDialog()
const store = useAuthTokenStore()
const response = { token: 'secret', loginName: 'alice', deviceToken: makeToken({ filesystem: true }) }
vi.mocked(store.addToken).mockResolvedValue(response)

await dialog.find('input[type="text"]').setValue('Thunderbird')
await dialog.find('input[value="calendarContacts"]').setValue(true)
await fireEvent.click(button(dialog, 'Create'))
await flushPromises()

expect(store.addToken).toHaveBeenCalledWith('Thunderbird', ['dav:calendar:read', 'dav:calendar:write', 'dav:contacts:read', 'dav:contacts:write'])
expect(dialog.emitted('created')).toEqual([[response]])
})
})
Loading
Loading