Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
Signed-off-by: Louis Chemineau <louis@chmn.me>
  • Loading branch information
artonge committed Nov 13, 2024
1 parent cea8fba commit 6707df4
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 120 deletions.
51 changes: 36 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"bugs": "https://github.com/nextcloud-libraries/nextcloud-password-confirmation/issues",
"license": "MIT",
"dependencies": {
"@nextcloud/auth": "^2.4.0",
"@nextcloud/axios": "^2.5.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1"
Expand Down
21 changes: 3 additions & 18 deletions src/components/PasswordDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
@update:open="close">
<!-- Dialog content -->
<p>{{ t('This action needs authentication') }}</p>
<form class="vue-password-confirmation__form" @submit.prevent="confirm">
<form class="vue-password-confirmation__form" @submit.prevent="submit">
<NcPasswordField ref="field"
:value.sync="password"
:label="t('Password')"
Expand Down Expand Up @@ -58,13 +58,6 @@ export default defineComponent({
NcPasswordField,
},

props: {
callback: {
type: Function,
required: true,
},
},

setup() {
// non reactive props
return {
Expand Down Expand Up @@ -99,7 +92,7 @@ export default defineComponent({
methods: {
t,

async confirm(): Promise<void> {
async submit(): Promise<void> {
this.showError = false
this.loading = true

Expand All @@ -108,15 +101,7 @@ export default defineComponent({
return
}

try {
await this.callback(this.password)
this.$emit('confirmed')
} catch (e) {
this.showError = true
this.selectPasswordField()
} finally {
this.loading = false
}
this.$emit('submit', this.password)
},

close(open: boolean): void {
Expand Down
159 changes: 72 additions & 87 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import Vue from 'vue'
import type { ComponentInstance } from 'vue'

import type { AxiosInstance, InternalAxiosRequestConfig } from '@nextcloud/axios'
import type { AxiosInstance } from '@nextcloud/axios'
import axios from '@nextcloud/axios'
import { getCurrentUser } from '@nextcloud/auth'

import PasswordDialogVue from './components/PasswordDialog.vue'
Expand All @@ -14,12 +15,6 @@ import { generateUrl } from '@nextcloud/router'

const PAGE_LOAD_TIME = Date.now()

interface AuthenticatedRequestState {
promise: Promise<void>,
resolve: () => void,
reject: () => void,
}

/**
* Check if password confirmation is required according to the last confirmation time.
* Use as a replacement of deprecated `OC.PasswordConfirmation.requiresPasswordConfirmation()`.
Expand All @@ -44,55 +39,70 @@ export const isPasswordConfirmationRequired = (mode: 'reminder'|'inRequest'): bo
* Confirm password if needed.
* Replacement of deprecated `OC.PasswordConfirmation.requirePasswordConfirmation(callback)`
*
* @return {Promise<void>} Promise that resolves when password is confirmed or not needded.
* @return {Promise<void>} Promise that resolves when password is confirmed or not needed.
* Rejects if password confirmation was cancelled
* or confirmation is already in process.
*/
export const confirmPassword = (): Promise<void> => {
if (!isPasswordConfirmationRequired()) {
export const confirmPassword = async (): Promise<void> => {
if (!isPasswordConfirmationRequired('reminder')) {
return Promise.resolve()
}

return getPasswordDialog()
const password = await getPassword()
return _confirmPassword(password)
}

/**
*
* @param mode
* @param callback
* @return
* @param password

Check warning on line 57 in src/main.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "password" description
*/
function getPasswordDialog(callback: (password: string) => Promise<void>): Promise<void> {
const isDialogMounted = Boolean(document.getElementById(DIALOG_ID))
if (isDialogMounted) {
return Promise.reject(new Error('Password confirmation dialog already mounted'))
}
async function _confirmPassword(password: string) {
const url = generateUrl('/login/confirm')
const { data } = await axios.post(url, { password })
window.nc_lastLogin = data.lastLogin
}

/**
*
*/
function getDialog(): Vue {
const element = document.getElementById(DIALOG_ID)

const mountPoint = document.createElement('div')
mountPoint.setAttribute('id', DIALOG_ID)
if (element !== null) {
return element.__vue__.$root
} else {
const mountPoint = document.createElement('div')
mountPoint.setAttribute('id', DIALOG_ID)

const modals = Array.from(document.querySelectorAll(`.${MODAL_CLASS}`) as NodeListOf<HTMLElement>)
// Filter out hidden modals
.filter((modal) => modal.style.display !== 'none')
const modals = Array.from(document.querySelectorAll(`.${MODAL_CLASS}`) as NodeListOf<HTMLElement>)
// Filter out hidden modals
.filter((modal) => modal.style.display !== 'none')

const isModalMounted = Boolean(modals.length)
const isModalMounted = Boolean(modals.length)

if (isModalMounted) {
const previousModal = modals[modals.length - 1]
previousModal.prepend(mountPoint)
} else {
document.body.appendChild(mountPoint)
}
if (isModalMounted) {
const previousModal = modals[modals.length - 1]
previousModal.prepend(mountPoint)
} else {
document.body.appendChild(mountPoint)
}

const DialogClass = Vue.extend(PasswordDialogVue)
// Mount point element is replaced by the component
const dialog = (new DialogClass({ propsData: { callback } }) as ComponentInstance).$mount(mountPoint)
const DialogClass = Vue.extend(PasswordDialogVue)
// Mount point element is replaced by the component
return (new DialogClass() as ComponentInstance).$mount(mountPoint)
}
}

/**
*
* @param callback

Check warning on line 98 in src/main.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "callback" description
*/
function getPassword(callback: () => void): Promise<string> {
return new Promise((resolve, reject) => {
dialog.$on('confirmed', () => {
dialog.$destroy()
resolve()
})
const dialog = getDialog()

dialog.$on('submit', callback)

dialog.$on('close', () => {
dialog.$destroy()
reject(new Error('Dialog closed'))
Expand All @@ -103,13 +113,10 @@ function getPasswordDialog(callback: (password: string) => Promise<void>): Promi
/**
* Add axios interceptors to an axios instance that will ask for
* password confirmation to add it as Basic Auth for every requests.
* TODO: ensure we cannot register them twice
* @param axios

Check warning on line 117 in src/main.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "axios" description
*/
export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void {
// We should never have more than one request waiting for password confirmation
// but in doubt, we use a map to store the state of potential synchronous requests.
const requestState: Record<symbol, AuthenticatedRequestState> = {}
const resolveConfig: Record<symbol, (value: InternalAxiosRequestConfig) => void> = {}

axios.interceptors.request.use(
async (config) => {
if (config.confirmPassword === undefined) {
Expand All @@ -120,66 +127,44 @@ export function addPasswordConfirmationInterceptors(axios: AxiosInstance): void
return config
}

return new Promise((resolve) => {
const confirmPasswordId = config.confirmPasswordId ?? Symbol('authenticated-request')
resolveConfig[confirmPasswordId] = resolve
const password = await getPassword()

if (config.confirmPasswordId !== undefined) {
return
if (config.confirmPassword === 'reminder') {
const url = generateUrl('/login/confirm')
const { data } = await axios.post(url, { password })
window.nc_lastLogin = data.lastLogin
} else {
config.auth = {
username: getCurrentUser()?.uid ?? '',
password,
}
}

getPasswordDialog(async (password: string) => {
if (config.confirmPassword === 'reminder') {
const url = generateUrl('/login/confirm')
const { data } = await axios.post(url, { password })
window.nc_lastLogin = data.lastLogin
resolveConfig[confirmPasswordId](config)
} else {
// We store all the necessary information to resolve or reject
// the password confirmation in the response interceptor.
requestState[confirmPasswordId] = Promise.withResolvers()

// Resolving the config will trigger the request.
resolveConfig[confirmPasswordId]({
...config,
confirmPasswordId,
auth: {
username: getCurrentUser()?.uid ?? '',
password,
},
})

await requestState[confirmPasswordId].promise
window.nc_lastLogin = Date.now() / 1000
}
})
})
return config
},
)

axios.interceptors.response.use(
(response) => {
if (response.config.confirmPasswordId !== undefined) {
requestState[response.config.confirmPasswordId].resolve()
delete requestState[response.config.confirmPasswordId]
}

window.nc_lastLogin = Date.now() / 1000
getDialog().$destroy()
return response
},
(error) => {
if (error.config.confirmPasswordId === undefined) {
return error
}

if (error.response?.status !== 403 || error.response.data.message !== 'Password confirmation is required') {
return error
}

// If the password confirmation failed, we reject the promise and trigger another request.
// That other request will go through the password confirmation flow again.
requestState[error.config.confirmPasswordId].reject()
delete requestState[error.config.confirmPasswordId]
// If the password confirmation failed, we trigger another request.
// that will go through the password confirmation flow again.
getDialog().$data.showError = true
getDialog().$data.loading = false
return axios.request(error.config)
},
{
runWhen(config) {
return config.confirmPassword !== undefined
},
},
)
}

0 comments on commit 6707df4

Please sign in to comment.