Skip to content

Commit 6b5e751

Browse files
committed
feat: use status header on recent Nextcloud versions to detect confirmation error
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent b42d001 commit 6b5e751

5 files changed

Lines changed: 152 additions & 28 deletions

File tree

src/apiError.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { beforeEach, describe, expect, test, vi } from 'vitest'
7+
8+
const isAxiosErrorMock = vi.fn()
9+
const loggerDebugMock = vi.fn()
10+
11+
vi.mock('@nextcloud/axios', () => ({
12+
isAxiosError: isAxiosErrorMock,
13+
}))
14+
15+
vi.mock('./utils/logger.ts', () => ({
16+
logger: {
17+
debug: loggerDebugMock,
18+
},
19+
}))
20+
21+
describe('isConfirmationError', () => {
22+
beforeEach(() => {
23+
vi.resetModules()
24+
vi.clearAllMocks()
25+
window._oc_config = undefined
26+
})
27+
28+
async function importSubject(version?: string) {
29+
window._oc_config = version ? { version } : undefined
30+
return import('./apiError.ts')
31+
}
32+
33+
test('returns false for non axios errors', async () => {
34+
const { isConfirmationError } = await importSubject('33.0.0')
35+
isAxiosErrorMock.mockReturnValue(false)
36+
37+
expect(isConfirmationError(new Error('nope'))).toBe(false)
38+
expect(loggerDebugMock).not.toBeCalled()
39+
})
40+
41+
test('returns false when axios error has no response', async () => {
42+
const { isConfirmationError } = await importSubject('33.0.0')
43+
isAxiosErrorMock.mockReturnValue(true)
44+
45+
expect(isConfirmationError({ response: undefined })).toBe(false)
46+
expect(loggerDebugMock).not.toBeCalled()
47+
})
48+
49+
test('uses header based detection on Nextcloud 32', async () => {
50+
const { isConfirmationError } = await importSubject('32.0.7')
51+
isAxiosErrorMock.mockReturnValue(true)
52+
53+
const error = {
54+
response: {
55+
headers: {
56+
'x-nextcloud-password-confirmation': 'true',
57+
},
58+
status: 403,
59+
},
60+
}
61+
62+
expect(isConfirmationError(error)).toBe(true)
63+
expect(loggerDebugMock).toBeCalledWith('Handle modern confirmation error based on header', { hasConfirmationHeader: true })
64+
})
65+
66+
test('returns false if header is not present on Nextcloud 32', async () => {
67+
const { isConfirmationError } = await importSubject('32.0.7')
68+
isAxiosErrorMock.mockReturnValue(true)
69+
70+
const error = {
71+
response: {
72+
status: 403,
73+
},
74+
}
75+
76+
expect(isConfirmationError(error)).toBe(false)
77+
expect(loggerDebugMock).toBeCalledWith('Handle modern confirmation error based on header', { hasConfirmationHeader: false })
78+
})
79+
80+
test('uses status based detection on Nextcloud 31', async () => {
81+
const { isConfirmationError } = await importSubject('31.0.8')
82+
isAxiosErrorMock.mockReturnValue(true)
83+
84+
const error = {
85+
response: {
86+
status: 403,
87+
},
88+
}
89+
90+
expect(isConfirmationError(error)).toBe(true)
91+
expect(loggerDebugMock).toBeCalledWith('Handle legacy confirmation error based on status code', { status: 403 })
92+
})
93+
})

src/apiError.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* SPDX-File-CopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { isAxiosError } from '@nextcloud/axios'
7+
import { logger } from './utils/logger.ts'
8+
9+
const [NC_MAJOR_VERSION] = window._oc_config?.version.split('.').map(Number) ?? []
10+
11+
/**
12+
* Check if the given error is a confirmation error,
13+
* which means that the password was incorrect.
14+
*
15+
* @param error - The error to check
16+
*/
17+
export function isConfirmationError(error: unknown): boolean {
18+
if (!isAxiosError(error) || !error.response) {
19+
return false
20+
}
21+
22+
const hasConfirmationHeader = error.response.headers?.['x-nextcloud-password-confirmation'] === 'true'
23+
if (NC_MAJOR_VERSION < 32) {
24+
logger.debug('Handle legacy confirmation error based on status code', { status: error.response.status })
25+
return error.response.status === 403
26+
}
27+
28+
logger.debug('Handle modern confirmation error based on header', { hasConfirmationHeader })
29+
return hasConfirmationHeader
30+
}

src/components/PasswordDialog.vue

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
-->
55

66
<script setup lang="ts">
7-
import { isAxiosError } from '@nextcloud/axios'
87
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
98
import NcDialog from '@nextcloud/vue/components/NcDialog'
109
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
10+
import { isConfirmationError } from '../apiError.ts'
1111
import { t } from '../utils/l10n.js'
1212
import { logger } from '../utils/logger.js'
1313
@@ -30,7 +30,7 @@ const passwordInput = useTemplateRef('field')
3030
3131
const password = ref('')
3232
const loading = ref(false)
33-
const hasError = ref<boolean | 403>(false)
33+
const hasError = ref<boolean>(false)
3434
3535
const buttons: DialogButtons = [{
3636
label: t('Confirm'),
@@ -40,23 +40,18 @@ const buttons: DialogButtons = [{
4040
}]
4141
4242
const helperText = computed(() => {
43-
if (hasError.value !== false) {
44-
if (password.value === '') {
45-
return t('Please enter your password')
46-
}
47-
48-
switch (hasError.value) {
49-
case true:
50-
return t('Unknown error while checking password')
51-
case 403:
52-
return t('Wrong password')
53-
}
43+
if (hasError.value) {
44+
return t('Wrong password')
5445
}
5546
5647
if (loading.value) {
5748
return t('Checking password …') // TRANSLATORS: This is a status message, shown when the system is checking the users password
5849
}
5950
51+
if (password.value === '') {
52+
return t('Please enter your password')
53+
}
54+
6055
return ''
6156
})
6257
@@ -75,20 +70,19 @@ async function callback(): Promise<boolean> {
7570
try {
7671
await props.validate(password.value)
7772
emit('close', true)
78-
return true
7973
} catch (error) {
80-
if (isAxiosError(error) && error.response?.status === 403) {
81-
hasError.value = 403
82-
} else {
74+
if (isConfirmationError(error)) {
8375
hasError.value = true
76+
logger.error('Exception during password confirmation', { error })
77+
selectPasswordField()
78+
return false
8479
}
85-
86-
logger.error('Exception during password confirmation', { error })
87-
selectPasswordField()
88-
return false
80+
hasError.value = true
81+
emit('close', false)
8982
} finally {
9083
loading.value = false
9184
}
85+
return true
9286
}
9387
9488
/**

src/env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ declare global {
88
nc_pageLoad: number
99
nc_lastLogin: number
1010
backendAllowsPasswordConfirmation: boolean
11+
12+
_oc_config?: {
13+
version: string
14+
}
1115
}
1216

1317
const __TRANSLATIONS__: Array<{ locale: string, translations: { msgid: string, msgid_plural?: string, msgstr: string[] }[] }>

src/password-confirmation.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import axios from '@nextcloud/axios'
1010
import { generateUrl } from '@nextcloud/router'
1111
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
1212
import PasswordDialogVue from './components/PasswordDialog.vue'
13+
import { isConfirmationError } from './apiError.ts'
1314
import { PwdConfirmationMode } from './globals.ts'
1415
import { isPasswordConfirmationRequired } from './is-required.ts'
1516
import { logger } from './utils/logger.ts'
@@ -104,7 +105,7 @@ export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void
104105
return config
105106
}
106107

107-
const { promise, resolve } = Promise.withResolvers<InternalAxiosRequestConfig>()
108+
const { promise, resolve, reject } = Promise.withResolvers<InternalAxiosRequestConfig>()
108109
promptPassword(async (password: string) => {
109110
switch (config.confirmPassword) {
110111
case PwdConfirmationMode.Lax:
@@ -121,7 +122,7 @@ export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void
121122
resolve(config)
122123
return validatePromise.promise
123124
}
124-
})
125+
}).catch(reject)
125126

126127
return promise
127128
})
@@ -155,11 +156,13 @@ export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void
155156

156157
logger.debug('Password confirmation failed', { error })
157158
validatePromise.reject(error)
158-
159-
// If the password confirmation failed, we trigger another request.
160-
// that will go through the password confirmation flow again.
161-
logger.debug('Triggering new request', { error })
162-
return axios.request(error.config)
159+
if (isConfirmationError(error)) {
160+
// If the password confirmation failed, we trigger another request.
161+
// that will go through the password confirmation flow again.
162+
logger.debug('Triggering new request', { error })
163+
return axios.request(error.config)
164+
}
165+
throw error
163166
},
164167
)
165168
}

0 commit comments

Comments
 (0)