Skip to content

Commit b90a6e5

Browse files
committed
refactor(settings): migrate the app password section to script setup
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com> Assisted-by: ClaudeCode:claude-opus-5-5
1 parent ffe52fc commit b90a6e5

16 files changed

Lines changed: 472 additions & 607 deletions

File tree

‎apps/settings/lib/Settings/Personal/Security/Authtokens.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use OC\Authentication\Token\INamedToken;
1313
use OC\Authentication\Token\IProvider as IAuthTokenProvider;
1414
use OC\Authentication\Token\IToken;
15+
use OCA\Settings\AppInfo\Application;
1516
use OCP\AppFramework\Http\TemplateResponse;
1617
use OCP\AppFramework\Services\IInitialState;
1718
use OCP\Authentication\Exceptions\InvalidTokenException;
@@ -20,6 +21,7 @@
2021
use OCP\IUserSession;
2122
use OCP\Session\Exceptions\SessionNotAvailableException;
2223
use OCP\Settings\ISettings;
24+
use OCP\Util;
2325
use function array_map;
2426

2527
class Authtokens implements ISettings {
@@ -47,6 +49,9 @@ public function getForm(): TemplateResponse {
4749
&& $this->serverConfig->getSystemValueBool('auth_can_create_app_token', true)
4850
);
4951

52+
Util::addStyle(Application::APP_ID, 'personal-security-authtokens');
53+
Util::addScript(Application::APP_ID, 'personal-security-authtokens');
54+
5055
return new TemplateResponse('settings', 'settings/personal/security/authtokens');
5156
}
5257

‎apps/settings/src/authtokens/components/AuthToken.spec.ts‎

Lines changed: 28 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,11 @@
66
import type { IToken } from '../store/authtoken.ts'
77

88
import { createTestingPinia } from '@pinia/testing'
9+
import { getByRole } from '@testing-library/vue'
910
import { enableAutoUnmount, mount } from '@vue/test-utils'
1011
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1112
import { nextTick } from 'vue'
1213

13-
// AuthToken.vue reads window.OC.theme.productName at module evaluation time.
14-
// vi.hoisted runs before imports, so this guarantees the property is set on
15-
// the existing jsdom window before the SFC is first parsed.
16-
vi.hoisted(() => {
17-
(window as unknown as { OC: { theme: { productName: string } } }).OC.theme = { productName: 'Nextcloud' }
18-
})
19-
2014
// Mock @nextcloud/dialogs so the wipe action's showConfirmation call resolves
2115
// synchronously in tests. Hoisted so it's installed before AuthToken.vue imports.
2216
const showConfirmationMock = vi.hoisted(() => vi.fn())
@@ -31,6 +25,9 @@ import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue'
3125
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
3226
import { detect } from '../utils/userAgentDetect.ts'
3327

28+
// AuthToken.vue reads window.OC.theme.productName, which the test window lacks
29+
;(window as unknown as { OC: { theme: { productName: string } } }).OC.theme = { productName: 'Nextcloud' }
30+
3431
enableAutoUnmount(afterEach)
3532

3633
function makeToken(overrides: Partial<IToken> = {}): IToken {
@@ -46,13 +43,11 @@ function makeToken(overrides: Partial<IToken> = {}): IToken {
4643
}
4744
}
4845

49-
function mountAuthToken(token: IToken) {
46+
function mountAuthToken(token: IToken, { stubs = {}, attachTo }: { stubs?: Record<string, object | boolean>, attachTo?: HTMLElement } = {}) {
5047
return mount(AuthToken, {
5148
props: { token },
49+
attachTo,
5250
global: {
53-
mocks: {
54-
t: (_: string, text: string) => text,
55-
},
5651
stubs: {
5752
NcActions: true,
5853
NcActionButton: true,
@@ -61,6 +56,7 @@ function mountAuthToken(token: IToken) {
6156
NcDateTime: true,
6257
NcIconSvgWrapper: true,
6358
NcTextField: true,
59+
...stubs,
6460
},
6561
plugins: [createTestingPinia({
6662
createSpy: vi.fn,
@@ -74,9 +70,6 @@ function mountDeleteDialog(token: IToken, open = true) {
7470
return mount(AuthTokenDeleteDialog, {
7571
props: { token, open },
7672
global: {
77-
mocks: {
78-
t: (_: string, text: string) => text,
79-
},
8073
stubs: {
8174
NcDialog: { template: '<div><slot /></div>' },
8275
},
@@ -153,32 +146,17 @@ describe('AuthToken revoke flow', () => {
153146

154147
describe('AuthToken rename focus', () => {
155148
function mountRenamable(token: IToken) {
156-
return mount(AuthToken, {
157-
props: { token },
149+
return mountAuthToken(token, {
158150
attachTo: document.body,
159-
global: {
160-
mocks: {
161-
t: (_: string, text: string) => text,
162-
},
163-
stubs: {
164-
NcActions: { template: '<div><button>Device settings</button><slot /></div>' },
165-
NcActionButton: true,
166-
NcActionCheckbox: true,
167-
NcButton: true,
168-
NcDateTime: true,
169-
NcIconSvgWrapper: true,
170-
NcTextField: { template: '<input>', methods: { select() {} } },
171-
},
172-
plugins: [createTestingPinia({
173-
createSpy: vi.fn,
174-
initialState: { 'auth-token': { tokens: [token] } },
175-
})],
151+
stubs: {
152+
NcActions: { template: '<div><button>Device settings</button><slot /></div>' },
153+
NcTextField: { template: '<input>', methods: { select() {} } },
176154
},
177155
})
178156
}
179157

180158
function actionsButton(wrapper: ReturnType<typeof mountRenamable>) {
181-
return wrapper.findAll('button').find((button) => button.text() === 'Device settings')!.element
159+
return getByRole(wrapper.element, 'button', { name: 'Device settings' })
182160
}
183161

184162
it('returns focus to the actions button after cancelling with Escape', async () => {
@@ -190,7 +168,7 @@ describe('AuthToken rename focus', () => {
190168
await nextTick()
191169

192170
expect(wrapper.find('form').exists()).toBe(false)
193-
expect(document.activeElement).toBe(actionsButton(wrapper))
171+
expect(actionsButton(wrapper)).toHaveFocus()
194172
})
195173

196174
it('returns focus to the actions button after saving the new name', async () => {
@@ -204,56 +182,41 @@ describe('AuthToken rename focus', () => {
204182
await nextTick()
205183

206184
expect(store.renameToken).toHaveBeenCalledWith(token, token.name)
207-
expect(document.activeElement).toBe(actionsButton(wrapper))
185+
expect(actionsButton(wrapper)).toHaveFocus()
208186
})
209187

210-
// Renaming always asks for the password, and that dialog takes focus until it closes.
211188
it('returns focus to the actions button once the password confirmation closes', async () => {
212189
const token = makeToken()
213190
const wrapper = mountRenamable(token)
214191
const store = useAuthTokenStore()
215192
const dialogField = document.createElement('input')
216193
document.body.appendChild(dialogField)
217-
let closeDialog = () => {}
194+
const dialog = Promise.withResolvers<void>()
218195
vi.mocked(store.renameToken).mockImplementation(async () => {
219196
await new Promise((resolve) => setTimeout(resolve))
220197
dialogField.focus()
221-
await new Promise<void>((resolve) => {
222-
closeDialog = resolve
223-
})
198+
await dialog.promise
224199
dialogField.remove()
225200
return true
226201
})
227202

228203
;(wrapper.vm as unknown as { startRename: () => void }).startRename()
229204
await nextTick()
230205
await wrapper.find('form').trigger('submit')
231-
await vi.waitFor(() => expect(document.activeElement).toBe(dialogField))
232-
closeDialog()
206+
await vi.waitFor(() => expect(dialogField).toHaveFocus(), { interval: 1 })
207+
dialog.resolve()
233208

234-
await vi.waitFor(() => expect(document.activeElement).toBe(actionsButton(wrapper)))
209+
await vi.waitFor(() => expect(actionsButton(wrapper)).toHaveFocus(), { interval: 1 })
235210
})
236211
})
237212

238213
describe('AuthToken action labels', () => {
239214
it('labels each action with its own text', () => {
240-
const token = makeToken()
241-
const wrapper = mount(AuthToken, {
242-
props: { token },
243-
global: {
244-
mocks: {
245-
t: (_: string, text: string) => text,
246-
},
247-
stubs: {
248-
NcActions: { template: '<ul><slot /></ul>' },
249-
NcActionCheckbox: true,
250-
NcDateTime: true,
251-
NcIconSvgWrapper: true,
252-
},
253-
plugins: [createTestingPinia({
254-
createSpy: vi.fn,
255-
initialState: { 'auth-token': { tokens: [token] } },
256-
})],
215+
const wrapper = mountAuthToken(makeToken(), {
216+
stubs: {
217+
NcActions: { template: '<ul><slot /></ul>' },
218+
NcActionButton: false,
219+
NcButton: false,
257220
},
258221
})
259222

@@ -263,26 +226,22 @@ describe('AuthToken action labels', () => {
263226
})
264227

265228
describe('AuthTokenSetupDialog QR code', () => {
266-
// The login name and password are shown as text, so the QR code adds nothing for assistive technology
229+
// The credentials are shown as text, so the QR code is redundant for screen readers
267230
it('hides the QR code from assistive technology', async () => {
268231
const wrapper = mount(AuthTokenSetupDialog, {
269232
props: { token: { token: 'app-password', loginName: 'admin', deviceToken: makeToken() } },
270233
global: {
271-
mocks: {
272-
t: (_: string, text: string) => text,
273-
},
274234
stubs: {
275235
NcDialog: { template: '<div><slot /></div>' },
276236
NcIconSvgWrapper: true,
277237
},
278238
},
279239
})
280240

281-
await wrapper.findAll('button').find((button) => button.text() === 'Show QR code for mobile apps')!.trigger('click')
241+
getByRole(wrapper.element, 'button', { name: 'Show QR code for mobile apps' }).click()
242+
await nextTick()
282243

283-
const qrCode = wrapper.find('canvas')
284-
expect(qrCode.exists()).toBe(true)
285-
expect(qrCode.attributes('aria-hidden')).toBe('true')
244+
expect(wrapper.find('canvas').element).toHaveAttribute('aria-hidden', 'true')
286245
})
287246
})
288247

0 commit comments

Comments
 (0)