Skip to content
Merged
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
3 changes: 0 additions & 3 deletions l10n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,5 @@ msgstr ""
msgid "This action needs authentication, please confirm it by entering your password."
msgstr ""

msgid "Unknown error while checking password"
msgstr ""

msgid "Wrong password"
msgstr ""
93 changes: 93 additions & 0 deletions src/apiError.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: MIT
*/

import { beforeEach, describe, expect, test, vi } from 'vitest'

const isAxiosErrorMock = vi.fn()
const loggerDebugMock = vi.fn()

vi.mock('@nextcloud/axios', () => ({
isAxiosError: isAxiosErrorMock,
}))

vi.mock('./utils/logger.ts', () => ({
logger: {
debug: loggerDebugMock,
},
}))

describe('isConfirmationError', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
window._oc_config = undefined
})

async function importSubject(version?: string) {
window._oc_config = version ? { version } : undefined
return import('./apiError.ts')
}

test('returns false for non axios errors', async () => {
const { isConfirmationError } = await importSubject('33.0.0')
isAxiosErrorMock.mockReturnValue(false)

expect(isConfirmationError(new Error('nope'))).toBe(false)
expect(loggerDebugMock).not.toBeCalled()
})

test('returns false when axios error has no response', async () => {
const { isConfirmationError } = await importSubject('33.0.0')
isAxiosErrorMock.mockReturnValue(true)

expect(isConfirmationError({ response: undefined })).toBe(false)
expect(loggerDebugMock).not.toBeCalled()
})

test('uses header based detection on Nextcloud 32', async () => {
const { isConfirmationError } = await importSubject('32.0.7')
isAxiosErrorMock.mockReturnValue(true)

const error = {
response: {
headers: {
'x-nextcloud-password-confirmation': 'true',
},
status: 403,
},
}

expect(isConfirmationError(error)).toBe(true)
expect(loggerDebugMock).toBeCalledWith('Handle modern confirmation error based on header', { hasConfirmationHeader: true })
})

test('returns false if header is not present on Nextcloud 32', async () => {
const { isConfirmationError } = await importSubject('32.0.7')
isAxiosErrorMock.mockReturnValue(true)

const error = {
response: {
status: 403,
},
}

expect(isConfirmationError(error)).toBe(false)
expect(loggerDebugMock).toBeCalledWith('Handle modern confirmation error based on header', { hasConfirmationHeader: false })
})

test('uses status based detection on Nextcloud 31', async () => {
const { isConfirmationError } = await importSubject('31.0.8')
isAxiosErrorMock.mockReturnValue(true)

const error = {
response: {
status: 403,
},
}

expect(isConfirmationError(error)).toBe(true)
expect(loggerDebugMock).toBeCalledWith('Handle legacy confirmation error based on status code', { status: 403 })
})
})
30 changes: 30 additions & 0 deletions src/apiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: MIT
*/

import { isAxiosError } from '@nextcloud/axios'
import { logger } from './utils/logger.ts'

const [NC_MAJOR_VERSION] = window._oc_config?.version.split('.').map(Number) ?? []

/**
* Check if the given error is a confirmation error,
* which means that the password was incorrect.
*
* @param error - The error to check
*/
export function isConfirmationError(error: unknown): boolean {
if (!isAxiosError(error) || !error.response) {
return false
}

const hasConfirmationHeader = error.response.headers?.['x-nextcloud-password-confirmation'] === 'true'
if (NC_MAJOR_VERSION < 32) {
logger.debug('Handle legacy confirmation error based on status code', { status: error.response.status })
return error.response.status === 403
}

logger.debug('Handle modern confirmation error based on header', { hasConfirmationHeader })
return hasConfirmationHeader
}
36 changes: 15 additions & 21 deletions src/components/PasswordDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
-->

<script setup lang="ts">
import { isAxiosError } from '@nextcloud/axios'
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import { isConfirmationError } from '../apiError.ts'
import { t } from '../utils/l10n.js'
import { logger } from '../utils/logger.js'

Expand All @@ -30,7 +30,7 @@ const passwordInput = useTemplateRef('field')

const password = ref('')
const loading = ref(false)
const hasError = ref<boolean | 403>(false)
const hasError = ref<boolean>(false)

const buttons: DialogButtons = [{
label: t('Confirm'),
Expand All @@ -40,23 +40,18 @@ const buttons: DialogButtons = [{
}]

const helperText = computed(() => {
if (hasError.value !== false) {
if (password.value === '') {
return t('Please enter your password')
}

switch (hasError.value) {
case true:
return t('Unknown error while checking password')
case 403:
return t('Wrong password')
}
if (hasError.value) {
return t('Wrong password')
}

if (loading.value) {
return t('Checking password …') // TRANSLATORS: This is a status message, shown when the system is checking the users password
}

if (password.value === '') {
return t('Please enter your password')
}

return ''
})

Expand All @@ -75,20 +70,19 @@ async function callback(): Promise<boolean> {
try {
await props.validate(password.value)
emit('close', true)
return true
} catch (error) {
if (isAxiosError(error) && error.response?.status === 403) {
hasError.value = 403
} else {
if (isConfirmationError(error)) {
hasError.value = true
logger.error('Exception during password confirmation', { error })
selectPasswordField()
return false
}

logger.error('Exception during password confirmation', { error })
selectPasswordField()
return false
hasError.value = true
emit('close', false)
} finally {
loading.value = false
}
return true
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ declare global {
nc_pageLoad: number
nc_lastLogin: number
backendAllowsPasswordConfirmation: boolean

_oc_config?: {
version: string
}
}

const __TRANSLATIONS__: Array<{ locale: string, translations: { msgid: string, msgid_plural?: string, msgstr: string[] }[] }>
Expand Down
17 changes: 10 additions & 7 deletions src/password-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import PasswordDialogVue from './components/PasswordDialog.vue'
import { isConfirmationError } from './apiError.ts'
import { PwdConfirmationMode } from './globals.ts'
import { isPasswordConfirmationRequired } from './is-required.ts'
import { logger } from './utils/logger.ts'
Expand Down Expand Up @@ -104,7 +105,7 @@ export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void
return config
}

const { promise, resolve } = Promise.withResolvers<InternalAxiosRequestConfig>()
const { promise, resolve, reject } = Promise.withResolvers<InternalAxiosRequestConfig>()
promptPassword(async (password: string) => {
switch (config.confirmPassword) {
case PwdConfirmationMode.Lax:
Expand All @@ -121,7 +122,7 @@ export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void
resolve(config)
return validatePromise.promise
}
})
}).catch(reject)

return promise
})
Expand Down Expand Up @@ -155,11 +156,13 @@ export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void

logger.debug('Password confirmation failed', { error })
validatePromise.reject(error)

// If the password confirmation failed, we trigger another request.
// that will go through the password confirmation flow again.
logger.debug('Triggering new request', { error })
return axios.request(error.config)
if (isConfirmationError(error)) {
// If the password confirmation failed, we trigger another request.
// that will go through the password confirmation flow again.
logger.debug('Triggering new request', { error })
return axios.request(error.config)
}
throw error
},
)
}
Loading