From 55f486e13b76c94e949eb98247301112bc93d62b Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 16 Oct 2026 10:49:02 +0200 Subject: [PATCH 01/12] feat(settings): accept scopes when creating an app password Signed-off-by: Peter Ringelmann --- .../lib/Controller/AuthSettingsController.php | 9 +++- .../Controller/AuthSettingsControllerTest.php | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php index ae2f2e3e2056f..f05c3a084e3e2 100644 --- a/apps/settings/lib/Controller/AuthSettingsController.php +++ b/apps/settings/lib/Controller/AuthSettingsController.php @@ -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|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(); } @@ -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) { diff --git a/apps/settings/tests/Controller/AuthSettingsControllerTest.php b/apps/settings/tests/Controller/AuthSettingsControllerTest.php index b4b2201e7a78e..75c1a0a63fa8d 100644 --- a/apps/settings/tests/Controller/AuthSettingsControllerTest.php +++ b/apps/settings/tests/Controller/AuthSettingsControllerTest.php @@ -137,6 +137,50 @@ public function testCreate(): void { $this->assertEquals($expected, $response->getData()); } + private function mockSessionForCreate(): void { + $sessionToken = $this->createMock(IToken::class); + $sessionToken->method('getLoginName')->willReturn('User13'); + $this->serverConfig->method('getSystemValueBool') + ->with('auth_can_create_app_token', true) + ->willReturn(true); + $this->session->method('getId')->willReturn('sessionid'); + $this->tokenProvider->method('getToken')->with('sessionid')->willReturn($sessionToken); + $this->tokenProvider->method('getPassword')->willReturn('123456'); + $this->secureRandom->method('generate')->willReturn('XXXXX'); + } + + public static function dataCreateWithScopes(): array { + $files = [FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_WRITE, FilesTokenScopes::FILES_SHARE]; + $sorted = [FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_SHARE, FilesTokenScopes::FILES_WRITE]; + return [ + 'files' => [$files, [TokenScopes::KEY => $sorted, IToken::SCOPE_FILESYSTEM => true]], + 'calendar' => [['calendar:read'], [TokenScopes::KEY => ['calendar:read'], IToken::SCOPE_FILESYSTEM => false]], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataCreateWithScopes')] + public function testCreateWithScopes(array $scopes, array $expectedScope): void { + $this->mockSessionForCreate(); + $deviceToken = $this->createMock(IToken::class); + $deviceToken->method('jsonSerialize')->willReturn([]); + $this->tokenProvider->expects($this->once()) + ->method('generateToken') + ->with($this->anything(), $this->uid, 'User13', '123456', 'Nexus 4', IToken::PERMANENT_TOKEN, IToken::DO_NOT_REMEMBER, $expectedScope) + ->willReturn($deviceToken); + $this->mockActivityManager(); + + $response = $this->controller->create('Nexus 4', false, $scopes); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + + public function testCreateInvalidScopes(): void { + $this->mockSessionForCreate(); + $this->tokenProvider->expects($this->never())->method('generateToken'); + + $response = $this->controller->create('Nexus 4', false, [FilesTokenScopes::FILES_WRITE]); + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + public function testCreateDisabledBySystemConfig(): void { $name = 'Nexus 4'; From 69683bcce4bd83c7c362c5851fc049f475b172b4 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 16 Oct 2026 10:55:57 +0200 Subject: [PATCH 02/12] feat(settings): map app password scopes to access levels Signed-off-by: Peter Ringelmann --- .../src/authtokens/utils/accessLevel.spec.ts | 52 +++++++++ .../src/authtokens/utils/accessLevel.ts | 104 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 apps/settings/src/authtokens/utils/accessLevel.spec.ts create mode 100644 apps/settings/src/authtokens/utils/accessLevel.ts diff --git a/apps/settings/src/authtokens/utils/accessLevel.spec.ts b/apps/settings/src/authtokens/utils/accessLevel.spec.ts new file mode 100644 index 0000000000000..562e74bc02aea --- /dev/null +++ b/apps/settings/src/authtokens/utils/accessLevel.spec.ts @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Selection } from './accessLevel.ts' + +import { describe, expect, it } from 'vitest' +import { accessLabel, fromScopes, toScopes } from './accessLevel.ts' + +const files = ['files:read', 'files:write', 'files:share'] +const calendarContacts = ['calendar:read', 'calendar:write', 'contacts:read', 'contacts:write'] + +describe('accessLevel', () => { + it.each<[string, Selection]>([ + ['files', { preset: 'files', areas: ['files'] }], + ['calendar and contacts', { preset: 'calendarContacts', areas: ['calendar', 'contacts'] }], + ['custom', { preset: 'custom', areas: ['files', 'calendar'] }], + ])('round-trips %s', (_, selection) => { + const scopes = toScopes(selection, [])! + expect(fromScopes(scopes)).toEqual({ selection, kept: [] }) + }) + + it('reads a token without scopes as all access', () => { + expect(fromScopes(undefined).selection.preset).toBe('all') + expect(toScopes({ preset: 'all', areas: ['files'] }, [])).toBeNull() + }) + + it('matches presets regardless of order', () => { + expect(fromScopes([...calendarContacts].reverse()).selection.preset).toBe('calendarContacts') + }) + + it('keeps scopes the picker can not show and sends them back', () => { + const { selection, kept } = fromScopes([...files, 'app:deck', 'calendar:read']) + + expect(selection).toEqual({ preset: 'custom', areas: ['files'] }) + expect(kept).toEqual(['app:deck', 'calendar:read']) + expect(toScopes({ preset: 'custom', areas: ['contacts'] }, kept)) + .toEqual(['contacts:read', 'contacts:write', 'app:deck', 'calendar:read']) + }) + + it.each([ + [{ filesystem: true }, 'All access'], + [{ filesystem: false }, 'All access without files'], + [{ filesystem: true, scopes: files }, 'Files'], + [{ filesystem: false, scopes: calendarContacts }, 'Calendar and Contacts'], + [{ filesystem: false, scopes: ['app:deck'] }, 'Other access'], + [{ filesystem: false, scopes: [] }, 'No access'], + ])('labels %o as %s', (scope, label) => { + expect(accessLabel(scope)).toBe(label) + }) +}) diff --git a/apps/settings/src/authtokens/utils/accessLevel.ts b/apps/settings/src/authtokens/utils/accessLevel.ts new file mode 100644 index 0000000000000..594901175d4cd --- /dev/null +++ b/apps/settings/src/authtokens/utils/accessLevel.ts @@ -0,0 +1,104 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLanguage, t } from '@nextcloud/l10n' + +export type Area = 'files' | 'calendar' | 'contacts' +export type Preset = 'all' | 'files' | 'calendarContacts' | 'custom' + +export interface Selection { + preset: Preset + /** Ticked areas, only used when the preset is not `all` */ + areas: Area[] +} + +/** Stored scope of an app password, as sent by the server */ +export interface TokenScope { + filesystem?: boolean + scopes?: string[] +} + +/** Scopes each area grants. Must match the scope names the server checks */ +export const AREA_SCOPES: Record = { + files: ['files:read', 'files:write', 'files:share'], + calendar: ['calendar:read', 'calendar:write'], + contacts: ['contacts:read', 'contacts:write'], +} + +export const PRESET_AREAS: Record<'files' | 'calendarContacts', Area[]> = { + files: ['files'], + calendarContacts: ['calendar', 'contacts'], +} + +const AREAS = Object.keys(AREA_SCOPES) as Area[] + +/** + * Read a stored scope into what the picker shows. + * An area counts as ticked only when all its scopes are there, everything else is kept as is. + * + * @param scopes Stored scopes, undefined for all access + */ +export function fromScopes(scopes?: string[]): { selection: Selection, kept: string[] } { + if (scopes === undefined) { + return { selection: { preset: 'all', areas: [] }, kept: [] } + } + + const areas = AREAS.filter((area) => AREA_SCOPES[area].every((scope) => scopes.includes(scope))) + const shown = areas.flatMap((area) => AREA_SCOPES[area]) + const kept = scopes.filter((scope) => !shown.includes(scope)) + + const presets = Object.keys(PRESET_AREAS) as (keyof typeof PRESET_AREAS)[] + const preset = kept.length === 0 ? presets.find((name) => sameAreas(PRESET_AREAS[name], areas)) : undefined + return { selection: { preset: preset ?? 'custom', areas }, kept } +} + +/** + * Turn the picker's selection back into the scopes to send, null for all access + * + * @param selection What the picker shows + * @param kept Scopes the picker can't show, sent back unchanged + */ +export function toScopes(selection: Selection, kept: string[]): string[] | null { + if (selection.preset === 'all') { + return null + } + return [...new Set([...selection.areas.flatMap((area) => AREA_SCOPES[area]), ...kept])] +} + +/** + * Short description of what an app password can reach, for its row + * + * @param scope Stored scope of the app password + */ +export function accessLabel(scope: TokenScope): string { + if (scope.scopes === undefined) { + return scope.filesystem === false + ? t('settings', 'All access without files') + : t('settings', 'All access') + } + + const { selection, kept } = fromScopes(scope.scopes) + const names = { + files: t('settings', 'Files'), + calendar: t('settings', 'Calendar'), + contacts: t('settings', 'Contacts'), + } + const parts = selection.areas.map((area) => names[area]) + if (kept.length > 0) { + parts.push(t('settings', 'Other access')) + } + if (parts.length === 0) { + return t('settings', 'No access') + } + return new Intl.ListFormat(getLanguage(), { type: 'conjunction' }).format(parts) +} + +/** + * @param a First list of areas + * @param b Second list of areas + */ +function sameAreas(a: Area[], b: Area[]): boolean { + return a.length === b.length && a.every((area) => b.includes(area)) +} From 0d6e8f9bdefa487f8109c51524621dc9dd21cd5f Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 16 Oct 2026 10:59:34 +0200 Subject: [PATCH 03/12] feat(settings): set app password scopes from the store Signed-off-by: Peter Ringelmann --- .../src/authtokens/store/authtoken.spec.ts | 40 +++++++++++++++++ .../src/authtokens/store/authtoken.ts | 44 +++++++++++++++++-- .../src/authtokens/utils/accessLevel.ts | 16 +++---- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/apps/settings/src/authtokens/store/authtoken.spec.ts b/apps/settings/src/authtokens/store/authtoken.spec.ts index 9e4387f26397d..722d5606b0991 100644 --- a/apps/settings/src/authtokens/store/authtoken.spec.ts +++ b/apps/settings/src/authtokens/store/authtoken.spec.ts @@ -50,3 +50,43 @@ describe('store:authtoken addToken', () => { expect(store.tokens).toHaveLength(0) }) }) + +// The automock of @nextcloud/axios has no `put`, so those tests assign one +describe('store:authtoken scopes', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('sends the scopes when creating a token', async () => { + const post = vi.spyOn(axios, 'post').mockResolvedValue({ + data: { deviceToken, loginName: 'alice', token: 'secret' }, + }) + + await useAuthTokenStore().addToken('Laptop', ['calendar:read']) + + expect(post.mock.calls[0][1]).toMatchObject({ name: 'Laptop', scopes: ['calendar:read'] }) + }) + + it.each([ + [['calendar:read'], { filesystem: false, scopes: ['calendar:read'] }], + [['files:read', 'files:write'], { filesystem: true, scopes: ['files:read', 'files:write'] }], + [null, { filesystem: true }], + ])('sets the scopes %o', async (scopes, expected) => { + const put = axios.put = vi.fn().mockResolvedValue({ data: [] }) + const token = { ...deviceToken, scope: { filesystem: false, scopes: ['contacts:read'] } } + + expect(await useAuthTokenStore().setTokenScopes(token, scopes)).toBe(true) + expect(put.mock.calls[0][1]).toMatchObject({ scope: { scopes } }) + expect(token.scope).toEqual(expected) + }) + + it('keeps the old scopes when saving fails', async () => { + axios.put = vi.fn().mockRejectedValue(new Error('Request failed')) + const scope = { filesystem: false, scopes: ['contacts:read'] } + const token = { ...deviceToken, scope } + + expect(await useAuthTokenStore().setTokenScopes(token, null)).toBe(false) + expect(token.scope).toBe(scope) + }) +}) diff --git a/apps/settings/src/authtokens/store/authtoken.ts b/apps/settings/src/authtokens/store/authtoken.ts index 131cb60afb7e1..5402cc9eafd18 100644 --- a/apps/settings/src/authtokens/store/authtoken.ts +++ b/apps/settings/src/authtokens/store/authtoken.ts @@ -21,6 +21,14 @@ export enum TokenType { ONETIME_TOKEN = 3, } +export interface ITokenScope { + filesystem: boolean + /** + * Scopes the app password is limited to, missing for all access + */ + scopes?: string[] +} + export interface IToken { id: number canDelete: boolean @@ -32,7 +40,7 @@ export interface IToken { lastActivity: number name: string type: TokenType - scope: Record + scope: ITokenScope } export interface IRevokeAllResponse { @@ -95,13 +103,14 @@ export const useAuthTokenStore = defineStore('auth-token', { * Add a new token * * @param name The token name + * @param scopes Scopes to limit the token to, null for all access */ - async addToken(name: string) { + async addToken(name: string, scopes: string[] | null = null) { logger.debug('Creating a new app token') // Let the failure reach the caller: AuthTokenSetup is the one that // reports it, the same way updateToken leaves reporting to its callers. - const { data } = await axios.post(BASE_URL, { name, oneTime: true }, { confirmPassword: PwdConfirmationMode.Strict }) + const { data } = await axios.post(BASE_URL, { name, oneTime: true, scopes }, { confirmPassword: PwdConfirmationMode.Strict }) this.tokens.push(data.deviceToken) logger.debug('App token created') @@ -208,7 +217,7 @@ export const useAuthTokenStore = defineStore('auth-token', { * @param scope scope to set * @param value value to set */ - async setTokenScope(token: IToken, scope: string, value: boolean) { + async setTokenScope(token: IToken, scope: 'filesystem', value: boolean) { logger.debug('Updating app token scope', { token, scope, value }) const oldVal = token.scope[scope] @@ -226,6 +235,33 @@ export const useAuthTokenStore = defineStore('auth-token', { } return false }, + + /** + * Change what the token can reach + * + * @param token Token to change + * @param scopes Scopes to limit the token to, null for all access + */ + async setTokenScopes(token: IToken, scopes: string[] | null) { + logger.debug('Updating app token scopes', { token, scopes }) + + const oldScope = token.scope + try { + // `scopes: null` has to be sent explicitly, it is what clears the limit + await axios.put(`${BASE_URL}/${token.id}`, { ...token, scope: { scopes } }, { confirmPassword: PwdConfirmationMode.Strict }) + // Same as the server: files access follows files:read, all access includes files + token.scope = scopes === null + ? { filesystem: true } + : { filesystem: scopes.includes('files:read'), scopes } + logger.debug('App token scopes updated') + return true + } catch (error) { + logger.error('Could not update app token scopes', { error }) + showError(t('settings', 'Could not save the access')) + token.scope = oldScope + } + return false + }, }, }) diff --git a/apps/settings/src/authtokens/utils/accessLevel.ts b/apps/settings/src/authtokens/utils/accessLevel.ts index 594901175d4cd..d184c20fbb566 100644 --- a/apps/settings/src/authtokens/utils/accessLevel.ts +++ b/apps/settings/src/authtokens/utils/accessLevel.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { ITokenScope } from '../store/authtoken.ts' + import { getLanguage, t } from '@nextcloud/l10n' export type Area = 'files' | 'calendar' | 'contacts' @@ -14,12 +16,6 @@ export interface Selection { areas: Area[] } -/** Stored scope of an app password, as sent by the server */ -export interface TokenScope { - filesystem?: boolean - scopes?: string[] -} - /** Scopes each area grants. Must match the scope names the server checks */ export const AREA_SCOPES: Record = { files: ['files:read', 'files:write', 'files:share'], @@ -72,11 +68,11 @@ export function toScopes(selection: Selection, kept: string[]): string[] | null * * @param scope Stored scope of the app password */ -export function accessLabel(scope: TokenScope): string { +export function accessLabel(scope: ITokenScope): string { if (scope.scopes === undefined) { - return scope.filesystem === false - ? t('settings', 'All access without files') - : t('settings', 'All access') + return scope.filesystem + ? t('settings', 'All access') + : t('settings', 'All access without files') } const { selection, kept } = fromScopes(scope.scopes) From 2e5a87c8ced756f335ca971aa18b4e5b23f4d962 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 16 Oct 2026 12:45:29 +0200 Subject: [PATCH 04/12] feat(settings): add the app password access picker Signed-off-by: Peter Ringelmann --- .../components/AuthTokenAccessDialog.spec.ts | 94 ++++++++++++++ .../components/AuthTokenAccessDialog.vue | 115 ++++++++++++++++++ .../components/AuthTokenAccessPicker.vue | 106 ++++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts create mode 100644 apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue create mode 100644 apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts new file mode 100644 index 0000000000000..1943f2b3f51dd --- /dev/null +++ b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts @@ -0,0 +1,94 @@ +/** + * 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 { getByRole, getByText } 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: '
', +} + +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 }, + global: { + stubs: { NcDialog }, + plugins: [createTestingPinia({ createSpy: vi.fn })], + }, + }) +} + +describe('AuthTokenAccessDialog', () => { + it('keeps Save disabled until the access changes', async () => { + const wrapper = mountDialog(makeToken({ filesystem: false })) + const save = getByRole(wrapper.element as HTMLElement, 'button', { name: 'Save' }) + expect(save).toBeDisabled() + + await wrapper.find('input[value="files"]').setValue(true) + expect(save).toBeEnabled() + }) + + it('needs at least one area in Custom', async () => { + const wrapper = mountDialog(makeToken({ filesystem: true, scopes: ['calendar:read', 'calendar:write'] })) + const save = getByRole(wrapper.element as HTMLElement, 'button', { name: 'Save' }) + + await wrapper.find('input[type="checkbox"][value="calendar"]').setValue(false) + + expect(save).toBeDisabled() + expect(getByText(wrapper.element as HTMLElement, 'Choose at least one')).toBeTruthy() + }) + + it('saves the new scopes and keeps the ones it can not show', async () => { + const token = makeToken({ filesystem: false, scopes: ['contacts:read', 'contacts:write', 'app:deck'] }) + const wrapper = mountDialog(token) + const store = useAuthTokenStore() + vi.mocked(store.setTokenScopes).mockResolvedValue(true) + + expect(wrapper.text()).toContain('access set outside of Settings') + await wrapper.find('input[type="checkbox"][value="calendar"]').setValue(true) + getByRole(wrapper.element as HTMLElement, 'button', { name: 'Save' }).click() + await flushPromises() + + expect(store.setTokenScopes).toHaveBeenCalledWith(token, ['contacts:read', 'contacts:write', 'calendar:read', 'calendar:write', 'app:deck']) + }) + + it('creates a token with the chosen preset', async () => { + const wrapper = mountDialog() + const store = useAuthTokenStore() + const response = { token: 'secret', loginName: 'alice', deviceToken: makeToken({ filesystem: true }) } + vi.mocked(store.addToken).mockResolvedValue(response) + const create = getByRole(wrapper.element as HTMLElement, 'button', { name: 'Create' }) + expect(create).toBeDisabled() + + await wrapper.find('input[type="text"]').setValue('Thunderbird') + await wrapper.find('input[value="calendarContacts"]').setValue(true) + create.click() + await flushPromises() + + expect(store.addToken).toHaveBeenCalledWith('Thunderbird', ['calendar:read', 'calendar:write', 'contacts:read', 'contacts:write']) + expect(wrapper.emitted('created')).toEqual([[response]]) + }) +}) diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue new file mode 100644 index 0000000000000..08039d2175990 --- /dev/null +++ b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue @@ -0,0 +1,115 @@ + + + + + + + diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue new file mode 100644 index 0000000000000..1967675ea8fd1 --- /dev/null +++ b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue @@ -0,0 +1,106 @@ + + + + + + + From cc4204a30c2ce65345abae42b80f2f9a2b42f332 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 16 Oct 2026 13:03:56 +0200 Subject: [PATCH 05/12] refactor(settings): use the app-owned scope names Signed-off-by: Peter Ringelmann --- .../components/AuthTokenAccessDialog.spec.ts | 8 ++++---- apps/settings/src/authtokens/store/authtoken.spec.ts | 12 ++++++------ apps/settings/src/authtokens/store/authtoken.ts | 4 ++-- .../src/authtokens/utils/accessLevel.spec.ts | 10 +++++----- apps/settings/src/authtokens/utils/accessLevel.ts | 6 +++--- .../tests/Controller/AuthSettingsControllerTest.php | 7 ++++--- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts index 1943f2b3f51dd..9e7628755e55d 100644 --- a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts +++ b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts @@ -52,7 +52,7 @@ describe('AuthTokenAccessDialog', () => { }) it('needs at least one area in Custom', async () => { - const wrapper = mountDialog(makeToken({ filesystem: true, scopes: ['calendar:read', 'calendar:write'] })) + const wrapper = mountDialog(makeToken({ filesystem: true, scopes: ['dav:calendar:read', 'dav:calendar:write'] })) const save = getByRole(wrapper.element as HTMLElement, 'button', { name: 'Save' }) await wrapper.find('input[type="checkbox"][value="calendar"]').setValue(false) @@ -62,7 +62,7 @@ describe('AuthTokenAccessDialog', () => { }) it('saves the new scopes and keeps the ones it can not show', async () => { - const token = makeToken({ filesystem: false, scopes: ['contacts:read', 'contacts:write', 'app:deck'] }) + const token = makeToken({ filesystem: false, scopes: ['dav:contacts:read', 'dav:contacts:write', 'app:deck'] }) const wrapper = mountDialog(token) const store = useAuthTokenStore() vi.mocked(store.setTokenScopes).mockResolvedValue(true) @@ -72,7 +72,7 @@ describe('AuthTokenAccessDialog', () => { getByRole(wrapper.element as HTMLElement, 'button', { name: 'Save' }).click() await flushPromises() - expect(store.setTokenScopes).toHaveBeenCalledWith(token, ['contacts:read', 'contacts:write', 'calendar:read', 'calendar:write', 'app:deck']) + expect(store.setTokenScopes).toHaveBeenCalledWith(token, ['dav:contacts:read', 'dav:contacts:write', 'dav:calendar:read', 'dav:calendar:write', 'app:deck']) }) it('creates a token with the chosen preset', async () => { @@ -88,7 +88,7 @@ describe('AuthTokenAccessDialog', () => { create.click() await flushPromises() - expect(store.addToken).toHaveBeenCalledWith('Thunderbird', ['calendar:read', 'calendar:write', 'contacts:read', 'contacts:write']) + expect(store.addToken).toHaveBeenCalledWith('Thunderbird', ['dav:calendar:read', 'dav:calendar:write', 'dav:contacts:read', 'dav:contacts:write']) expect(wrapper.emitted('created')).toEqual([[response]]) }) }) diff --git a/apps/settings/src/authtokens/store/authtoken.spec.ts b/apps/settings/src/authtokens/store/authtoken.spec.ts index 722d5606b0991..c4128e9ddb456 100644 --- a/apps/settings/src/authtokens/store/authtoken.spec.ts +++ b/apps/settings/src/authtokens/store/authtoken.spec.ts @@ -63,18 +63,18 @@ describe('store:authtoken scopes', () => { data: { deviceToken, loginName: 'alice', token: 'secret' }, }) - await useAuthTokenStore().addToken('Laptop', ['calendar:read']) + await useAuthTokenStore().addToken('Laptop', ['dav:calendar:read']) - expect(post.mock.calls[0][1]).toMatchObject({ name: 'Laptop', scopes: ['calendar:read'] }) + expect(post.mock.calls[0][1]).toMatchObject({ name: 'Laptop', scopes: ['dav:calendar:read'] }) }) it.each([ - [['calendar:read'], { filesystem: false, scopes: ['calendar:read'] }], - [['files:read', 'files:write'], { filesystem: true, scopes: ['files:read', 'files:write'] }], + [['dav:calendar:read'], { filesystem: false, scopes: ['dav:calendar:read'] }], + [['files:files:read', 'files:files:write'], { filesystem: true, scopes: ['files:files:read', 'files:files:write'] }], [null, { filesystem: true }], ])('sets the scopes %o', async (scopes, expected) => { const put = axios.put = vi.fn().mockResolvedValue({ data: [] }) - const token = { ...deviceToken, scope: { filesystem: false, scopes: ['contacts:read'] } } + const token = { ...deviceToken, scope: { filesystem: false, scopes: ['dav:contacts:read'] } } expect(await useAuthTokenStore().setTokenScopes(token, scopes)).toBe(true) expect(put.mock.calls[0][1]).toMatchObject({ scope: { scopes } }) @@ -83,7 +83,7 @@ describe('store:authtoken scopes', () => { it('keeps the old scopes when saving fails', async () => { axios.put = vi.fn().mockRejectedValue(new Error('Request failed')) - const scope = { filesystem: false, scopes: ['contacts:read'] } + const scope = { filesystem: false, scopes: ['dav:contacts:read'] } const token = { ...deviceToken, scope } expect(await useAuthTokenStore().setTokenScopes(token, null)).toBe(false) diff --git a/apps/settings/src/authtokens/store/authtoken.ts b/apps/settings/src/authtokens/store/authtoken.ts index 5402cc9eafd18..ea6ad405ea82d 100644 --- a/apps/settings/src/authtokens/store/authtoken.ts +++ b/apps/settings/src/authtokens/store/authtoken.ts @@ -249,10 +249,10 @@ export const useAuthTokenStore = defineStore('auth-token', { try { // `scopes: null` has to be sent explicitly, it is what clears the limit await axios.put(`${BASE_URL}/${token.id}`, { ...token, scope: { scopes } }, { confirmPassword: PwdConfirmationMode.Strict }) - // Same as the server: files access follows files:read, all access includes files + // Same as the server: files access follows files:files:read, all access includes files token.scope = scopes === null ? { filesystem: true } - : { filesystem: scopes.includes('files:read'), scopes } + : { filesystem: scopes.includes('files:files:read'), scopes } logger.debug('App token scopes updated') return true } catch (error) { diff --git a/apps/settings/src/authtokens/utils/accessLevel.spec.ts b/apps/settings/src/authtokens/utils/accessLevel.spec.ts index 562e74bc02aea..9e8c907649dc6 100644 --- a/apps/settings/src/authtokens/utils/accessLevel.spec.ts +++ b/apps/settings/src/authtokens/utils/accessLevel.spec.ts @@ -8,8 +8,8 @@ import type { Selection } from './accessLevel.ts' import { describe, expect, it } from 'vitest' import { accessLabel, fromScopes, toScopes } from './accessLevel.ts' -const files = ['files:read', 'files:write', 'files:share'] -const calendarContacts = ['calendar:read', 'calendar:write', 'contacts:read', 'contacts:write'] +const files = ['files:files:read', 'files:files:write', 'files_sharing:shares:write'] +const calendarContacts = ['dav:calendar:read', 'dav:calendar:write', 'dav:contacts:read', 'dav:contacts:write'] describe('accessLevel', () => { it.each<[string, Selection]>([ @@ -31,12 +31,12 @@ describe('accessLevel', () => { }) it('keeps scopes the picker can not show and sends them back', () => { - const { selection, kept } = fromScopes([...files, 'app:deck', 'calendar:read']) + const { selection, kept } = fromScopes([...files, 'app:deck', 'dav:calendar:read']) expect(selection).toEqual({ preset: 'custom', areas: ['files'] }) - expect(kept).toEqual(['app:deck', 'calendar:read']) + expect(kept).toEqual(['app:deck', 'dav:calendar:read']) expect(toScopes({ preset: 'custom', areas: ['contacts'] }, kept)) - .toEqual(['contacts:read', 'contacts:write', 'app:deck', 'calendar:read']) + .toEqual(['dav:contacts:read', 'dav:contacts:write', 'app:deck', 'dav:calendar:read']) }) it.each([ diff --git a/apps/settings/src/authtokens/utils/accessLevel.ts b/apps/settings/src/authtokens/utils/accessLevel.ts index d184c20fbb566..ba86c47e37492 100644 --- a/apps/settings/src/authtokens/utils/accessLevel.ts +++ b/apps/settings/src/authtokens/utils/accessLevel.ts @@ -18,9 +18,9 @@ export interface Selection { /** Scopes each area grants. Must match the scope names the server checks */ export const AREA_SCOPES: Record = { - files: ['files:read', 'files:write', 'files:share'], - calendar: ['calendar:read', 'calendar:write'], - contacts: ['contacts:read', 'contacts:write'], + files: ['files:files:read', 'files:files:write', 'files_sharing:shares:write'], + calendar: ['dav:calendar:read', 'dav:calendar:write'], + contacts: ['dav:contacts:read', 'dav:contacts:write'], } export const PRESET_AREAS: Record<'files' | 'calendarContacts', Area[]> = { diff --git a/apps/settings/tests/Controller/AuthSettingsControllerTest.php b/apps/settings/tests/Controller/AuthSettingsControllerTest.php index 75c1a0a63fa8d..421980351ba38 100644 --- a/apps/settings/tests/Controller/AuthSettingsControllerTest.php +++ b/apps/settings/tests/Controller/AuthSettingsControllerTest.php @@ -18,6 +18,7 @@ use OC\Authentication\Token\RemoteWipe; use OC\Authentication\Token\TokenScopes; use OCA\Files\TokenScopes as FilesTokenScopes; +use OCA\Files_Sharing\TokenScopes as SharingTokenScopes; use OCA\Settings\Activity\Provider; use OCA\Settings\Controller\AuthSettingsController; use OCP\Activity\IEvent; @@ -150,11 +151,11 @@ private function mockSessionForCreate(): void { } public static function dataCreateWithScopes(): array { - $files = [FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_WRITE, FilesTokenScopes::FILES_SHARE]; - $sorted = [FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_SHARE, FilesTokenScopes::FILES_WRITE]; + $files = [SharingTokenScopes::SHARES_WRITE, FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_WRITE]; + $sorted = [FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_WRITE, SharingTokenScopes::SHARES_WRITE]; return [ 'files' => [$files, [TokenScopes::KEY => $sorted, IToken::SCOPE_FILESYSTEM => true]], - 'calendar' => [['calendar:read'], [TokenScopes::KEY => ['calendar:read'], IToken::SCOPE_FILESYSTEM => false]], + 'calendar' => [['dav:calendar:read'], [TokenScopes::KEY => ['dav:calendar:read'], IToken::SCOPE_FILESYSTEM => false]], ]; } From 4bbde63701a1fdb4aa4a0eeb2de1dd310a61fd78 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 16 Oct 2026 13:36:23 +0200 Subject: [PATCH 06/12] feat(settings): choose app password access when creating and editing Signed-off-by: Peter Ringelmann --- .../authtokens/components/AuthToken.spec.ts | 26 ++++++++ .../src/authtokens/components/AuthToken.vue | 37 ++++++++++- .../components/AuthTokenAccessPicker.vue | 7 +-- .../authtokens/components/AuthTokenSetup.vue | 63 +++---------------- .../e2e/settings/devices-sessions.spec.ts | 10 +++ .../sections/DevicesSessionsSettingsPage.ts | 44 +++++++++++++ 6 files changed, 124 insertions(+), 63 deletions(-) diff --git a/apps/settings/src/authtokens/components/AuthToken.spec.ts b/apps/settings/src/authtokens/components/AuthToken.spec.ts index b1a565e770a39..52209430fe29f 100644 --- a/apps/settings/src/authtokens/components/AuthToken.spec.ts +++ b/apps/settings/src/authtokens/components/AuthToken.spec.ts @@ -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' @@ -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_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: '
' } } + 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) + }) +}) diff --git a/apps/settings/src/authtokens/components/AuthToken.vue b/apps/settings/src/authtokens/components/AuthToken.vue index b12c398c6d44a..1a31852f60ff1 100644 --- a/apps/settings/src/authtokens/components/AuthToken.vue +++ b/apps/settings/src/authtokens/components/AuthToken.vue @@ -6,10 +6,10 @@ diff --git a/tests/playwright/e2e/settings/devices-sessions.spec.ts b/tests/playwright/e2e/settings/devices-sessions.spec.ts index 4f3ec2806a91e..ef7f8e6a23197 100644 --- a/tests/playwright/e2e/settings/devices-sessions.spec.ts +++ b/tests/playwright/e2e/settings/devices-sessions.spec.ts @@ -67,4 +67,14 @@ test.describe('Settings: Devices & sessions', () => { await expect(devicesSessionsPage.tokenRows()).toHaveCount(2) await expect(devicesSessionsPage.tokenRow('Playwright device')).toBeVisible() }) + + test('creates an app password with limited access and changes it', async ({ devicesSessionsPage }) => { + await devicesSessionsPage.open() + + await devicesSessionsPage.createAppPassword('Thunderbird', 'Files only') + await expect(devicesSessionsPage.tokenRow('Thunderbird')).toContainText('Files') + + await devicesSessionsPage.editAccess('Thunderbird', 'Calendar and contacts') + await expect(devicesSessionsPage.tokenRow('Thunderbird')).toContainText('Calendar and Contacts') + }) }) diff --git a/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts b/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts index ab866b53edaca..1a917d6e100ef 100644 --- a/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts +++ b/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts @@ -65,4 +65,48 @@ export class DevicesSessionsSettingsPage { await dialog.getByRole('button', { name: 'Revoke all others' }).click() await awaitPasswordGuardedRequest(this.page, revoked) } + + accessDialog(name: 'Create new app password' | 'Edit access'): Locator { + return this.page.getByRole('dialog', { name }) + } + + /** + * @param name - Name of the new app password + * @param access - Label of the access preset to pick + */ + async createAppPassword(name: string, access: string): Promise { + await this.page.getByRole('button', { name: 'Create new app password' }).click() + const dialog = this.accessDialog('Create new app password') + await dialog.getByRole('textbox', { name: 'App name' }).fill(name) + await dialog.getByRole('radio', { name: access }).check({ force: true }) + + const created = this.page.waitForResponse((r) => r.request().method() === 'POST' + && r.url().includes('/settings/personal/authtokens') + && r.ok()) + await dialog.getByRole('button', { name: 'Create' }).click() + await awaitPasswordGuardedRequest(this.page, created) + + const setup = this.page.getByRole('dialog', { name: 'New app password', exact: true }) + await expect(setup).toBeVisible() + await setup.getByRole('button', { name: 'Close' }).click() + await expect(setup).toBeHidden() + } + + /** + * @param row - Visible name of the app password + * @param access - Label of the access preset to pick + */ + async editAccess(row: string, access: string): Promise { + await this.tokenRow(row).getByRole('button', { name: 'Device settings' }).click() + await this.page.getByRole('menuitem', { name: 'Edit access' }).click() + const dialog = this.accessDialog('Edit access') + await dialog.getByRole('radio', { name: access }).check({ force: true }) + + const saved = this.page.waitForResponse((r) => r.request().method() === 'PUT' + && r.url().includes('/settings/personal/authtokens/') + && r.ok()) + await dialog.getByRole('button', { name: 'Save' }).click() + await awaitPasswordGuardedRequest(this.page, saved) + await expect(dialog).toBeHidden() + } } From f3d0abf0c74ce69ea933830f99c133155ac919ec Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Fri, 16 Oct 2026 13:53:07 +0200 Subject: [PATCH 07/12] feat(settings): add sub-options to the app password access picker Signed-off-by: Peter Ringelmann --- .../components/AuthTokenAccessDialog.spec.ts | 17 ++- .../components/AuthTokenAccessDialog.vue | 2 +- .../components/AuthTokenAccessPicker.vue | 67 +++++++---- .../src/authtokens/utils/accessLevel.spec.ts | 42 +++++-- .../src/authtokens/utils/accessLevel.ts | 107 +++++++++++++----- 5 files changed, 170 insertions(+), 65 deletions(-) diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts index 9e7628755e55d..b15bc87c2fe27 100644 --- a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts +++ b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.spec.ts @@ -6,7 +6,7 @@ import type { IToken } from '../store/authtoken.ts' import { createTestingPinia } from '@pinia/testing' -import { getByRole, getByText } from '@testing-library/vue' +import { fireEvent, getByRole, getByText } 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' @@ -34,6 +34,7 @@ function makeToken(scope: IToken['scope']): IToken { function mountDialog(token?: IToken) { return mount(AuthTokenAccessDialog, { props: { token, open: true }, + attachTo: document.body, global: { stubs: { NcDialog }, plugins: [createTestingPinia({ createSpy: vi.fn })], @@ -51,11 +52,21 @@ describe('AuthTokenAccessDialog', () => { expect(save).toBeEnabled() }) + it('shows a partly granted area as mixed', () => { + const wrapper = mountDialog(makeToken({ filesystem: true, scopes: ['dav:calendar:read', 'dav:contacts:read', 'dav:contacts:write'] })) + const checkbox = (name: string) => getByRole(wrapper.element as HTMLElement, 'checkbox', { name }) as HTMLInputElement + + expect(checkbox('Calendar').indeterminate).toBe(true) + expect(checkbox('Can edit calendars').checked).toBe(false) + expect(checkbox('Contacts').checked).toBe(true) + expect(checkbox('Contacts').indeterminate).toBe(false) + }) + it('needs at least one area in Custom', async () => { const wrapper = mountDialog(makeToken({ filesystem: true, scopes: ['dav:calendar:read', 'dav:calendar:write'] })) const save = getByRole(wrapper.element as HTMLElement, 'button', { name: 'Save' }) - await wrapper.find('input[type="checkbox"][value="calendar"]').setValue(false) + await fireEvent.click(getByRole(wrapper.element as HTMLElement, 'checkbox', { name: 'Calendar' })) expect(save).toBeDisabled() expect(getByText(wrapper.element as HTMLElement, 'Choose at least one')).toBeTruthy() @@ -68,7 +79,7 @@ describe('AuthTokenAccessDialog', () => { vi.mocked(store.setTokenScopes).mockResolvedValue(true) expect(wrapper.text()).toContain('access set outside of Settings') - await wrapper.find('input[type="checkbox"][value="calendar"]').setValue(true) + await fireEvent.click(getByRole(wrapper.element as HTMLElement, 'checkbox', { name: 'Calendar' })) getByRole(wrapper.element as HTMLElement, 'button', { name: 'Save' }).click() await flushPromises() diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue index 08039d2175990..55689d0f1edaf 100644 --- a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue +++ b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue @@ -39,7 +39,7 @@ const selection = ref(initial.selection) const scopes = computed(() => toScopes(selection.value, initial.kept)) const canSave = computed(() => { - if (selection.value.preset === 'custom' && selection.value.areas.length === 0) { + if (selection.value.preset === 'custom' && selection.value.grants.length === 0) { return false } if (props.token) { diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue index 97940b40b92a9..91be8ed5e6685 100644 --- a/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue +++ b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue @@ -4,13 +4,13 @@ --> @@ -115,8 +112,5 @@ function toggle(grant: Grant, on: boolean) { padding-inline-start: calc(8 * var(--default-grid-baseline)); } - &__hint { - color: var(--color-text-maxcontrast); - } } diff --git a/apps/settings/src/authtokens/utils/accessLevel.spec.ts b/apps/settings/src/authtokens/utils/accessLevel.spec.ts index 272ef5bc7ae42..a8dd30d87d006 100644 --- a/apps/settings/src/authtokens/utils/accessLevel.spec.ts +++ b/apps/settings/src/authtokens/utils/accessLevel.spec.ts @@ -67,6 +67,7 @@ describe('accessLevel', () => { [{ filesystem: false, scopes: calendarContacts }, 'Calendar and Contacts'], [{ filesystem: false, scopes: ['dav:calendar:read'] }, 'Calendar (read only)'], [{ filesystem: false, scopes: ['app:deck'] }, 'Other access'], + [{ filesystem: true, scopes: [...files, 'app:deck'] }, 'Files and other access'], [{ filesystem: false, scopes: [] }, 'No access'], ])('labels %o as %s', (scope, label) => { expect(accessLabel(scope)).toBe(label) diff --git a/apps/settings/src/authtokens/utils/accessLevel.ts b/apps/settings/src/authtokens/utils/accessLevel.ts index d24b6ce58ed43..04acb73014cbe 100644 --- a/apps/settings/src/authtokens/utils/accessLevel.ts +++ b/apps/settings/src/authtokens/utils/accessLevel.ts @@ -127,7 +127,7 @@ export function accessLabel(scope: ITokenScope): string { return state === 'none' ? [] : [names[area][state]] }) if (kept.length > 0) { - parts.push(t('settings', 'Other access')) + parts.push(parts.length === 0 ? t('settings', 'Other access') : t('settings', 'other access')) } if (parts.length === 0) { return t('settings', 'No access') From d0d597331fdd29e1f32c2802acd1169ca536958d Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Mon, 19 Oct 2026 13:48:29 +0200 Subject: [PATCH 09/12] refactor(settings): simplify the app password access picker Signed-off-by: Peter Ringelmann --- .../lib/Controller/AuthSettingsController.php | 2 +- .../src/authtokens/components/AuthToken.vue | 3 +- .../components/AuthTokenAccessDialog.vue | 17 ++------ .../components/AuthTokenAccessPicker.vue | 13 +++---- .../src/authtokens/store/authtoken.spec.ts | 11 ++---- .../src/authtokens/store/authtoken.ts | 17 +++----- .../src/authtokens/utils/accessLevel.ts | 39 ++++++++++++------- .../Controller/AuthSettingsControllerTest.php | 12 +++--- .../sections/DevicesSessionsSettingsPage.ts | 2 +- 9 files changed, 53 insertions(+), 63 deletions(-) diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php index f05c3a084e3e2..6224fcccb5034 100644 --- a/apps/settings/lib/Controller/AuthSettingsController.php +++ b/apps/settings/lib/Controller/AuthSettingsController.php @@ -280,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]); } /** diff --git a/apps/settings/src/authtokens/components/AuthToken.vue b/apps/settings/src/authtokens/components/AuthToken.vue index 1a31852f60ff1..3bc64b03d5767 100644 --- a/apps/settings/src/authtokens/components/AuthToken.vue +++ b/apps/settings/src/authtokens/components/AuthToken.vue @@ -61,6 +61,7 @@ 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) { @@ -270,7 +271,7 @@ async function wipe() { {{ tokenLabel }} - {{ accessLabel(token.scope) }} + {{ access }} ({{ t('settings', 'Marked for remote wipe') }}) diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue index 5fb47896fbb39..88a52ec5d2f31 100644 --- a/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue +++ b/apps/settings/src/authtokens/components/AuthTokenAccessDialog.vue @@ -14,7 +14,7 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import AuthTokenAccessPicker from './AuthTokenAccessPicker.vue' import logger from '../../logger.ts' import { useAuthTokenStore } from '../store/authtoken.ts' -import { fromScopes, toScopes } from '../utils/accessLevel.ts' +import { fromScopes, sameItems, toScopes } from '../utils/accessLevel.ts' const open = defineModel('open', { required: true }) @@ -43,7 +43,9 @@ const canSave = computed(() => { return false } if (props.token) { - return !sameScopes(scopes.value, initialScopes) + return scopes.value === null || initialScopes === null + ? scopes.value !== initialScopes + : !sameItems(scopes.value, initialScopes) } return name.value.trim() !== '' }) @@ -77,17 +79,6 @@ async function save(): Promise { return false } } - -/** - * @param a First scopes, null for all access - * @param b Second scopes, null for all access - */ -function sameScopes(a: string[] | null, b: string[] | null): boolean { - if (a === null || b === null) { - return a === b - } - return a.length === b.length && a.every((scope) => b.includes(scope)) -}