uppy/packages/@uppy/provider-views/src/ProviderView/ProviderView.jsx
Mikael Finstad ec4bc58508
Provider user sessions (#4619)
New concept "simple auth" - authentication that happens immediately (in one http request) without redirecting to any third party.

uppyAuthToken initially used to simply contain an encrypted & json encoded OAuth2 access_token for a specific provider. Then we added refresh tokens as well inside uppyAuthToken #4448. Now we also allow storing other state or parameters needed for that specific provider, like username, password, host name, webdav URL etc... This is needed for providers like webdav, ftp etc, where the user needs to give some more input data while authenticating

Companion:
- `providerTokens` has been renamed to `providerUserSession` because it now includes not only tokens, but a user's session with a provider.

Companion `Provider` class:
- New `hasSimpleAuth` static boolean property - whether this provider uses simple auth
- uppyAuthToken expiry default 24hr again for providers that don't support refresh tokens
- make uppyAuthToken expiry configurable per provider - new `authStateExpiry` static property (defaults to 24hr)
- new static property `grantDynamicToUserSession`, allows providers to specify which state from Grant `dynamic` to include into the provider's `providerUserSession`.
2023-12-05 22:55:41 +01:00

500 lines
16 KiB
JavaScript

import { h } from 'preact'
import PQueue from 'p-queue'
import { getSafeFileId } from '@uppy/utils/lib/generateFileID'
import AuthView from './AuthView.jsx'
import Header from './Header.jsx'
import Browser from '../Browser.jsx'
import CloseWrapper from '../CloseWrapper.js'
import View from '../View.js'
import packageJson from '../../package.json'
function formatBreadcrumbs (breadcrumbs) {
return breadcrumbs.slice(1).map((directory) => directory.name).join('/')
}
function prependPath (path, component) {
if (!path) return component
return `${path}/${component}`
}
export function defaultPickerIcon () {
return (
<svg aria-hidden="true" focusable="false" width="30" height="30" viewBox="0 0 30 30">
<path d="M15 30c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15C6.716 0 0 6.716 0 15c0 8.284 6.716 15 15 15zm4.258-12.676v6.846h-8.426v-6.846H5.204l9.82-12.364 9.82 12.364H19.26z" />
</svg>
)
}
/**
* Class to easily generate generic views for Provider plugins
*/
export default class ProviderView extends View {
static VERSION = packageJson.version
/**
* @param {object} plugin instance of the plugin
* @param {object} opts
*/
constructor (plugin, opts) {
super(plugin, opts)
// set default options
const defaultOptions = {
viewType: 'list',
showTitles: true,
showFilter: true,
showBreadcrumbs: true,
loadAllFiles: false,
}
// merge default options with the ones set by user
this.opts = { ...defaultOptions, ...opts }
// Logic
this.filterQuery = this.filterQuery.bind(this)
this.clearFilter = this.clearFilter.bind(this)
this.getFolder = this.getFolder.bind(this)
this.getNextFolder = this.getNextFolder.bind(this)
this.logout = this.logout.bind(this)
this.handleAuth = this.handleAuth.bind(this)
this.handleScroll = this.handleScroll.bind(this)
this.donePicking = this.donePicking.bind(this)
// Visual
this.render = this.render.bind(this)
// Set default state for the plugin
this.plugin.setPluginState({
authenticated: undefined, // we don't know yet
files: [],
folders: [],
breadcrumbs: [],
filterInput: '',
isSearchVisible: false,
currentSelection: [],
})
}
// eslint-disable-next-line class-methods-use-this
tearDown () {
// Nothing.
}
#abortController
async #withAbort (op) {
// prevent multiple requests in parallel from causing race conditions
this.#abortController?.abort()
const abortController = new AbortController()
this.#abortController = abortController
const cancelRequest = () => {
abortController.abort()
this.clearSelection()
}
try {
this.plugin.uppy.on('dashboard:close-panel', cancelRequest)
this.plugin.uppy.on('cancel-all', cancelRequest)
await op(abortController.signal)
} finally {
this.plugin.uppy.off('dashboard:close-panel', cancelRequest)
this.plugin.uppy.off('cancel-all', cancelRequest)
this.#abortController = undefined
}
}
async #list ({ requestPath, absDirPath, signal }) {
const { username, nextPagePath, items } = await this.provider.list(requestPath, { signal })
this.username = username || this.username
return {
items: items.map((item) => ({
...item,
absDirPath,
})),
nextPagePath,
}
}
async #listFilesAndFolders ({ breadcrumbs, signal }) {
const absDirPath = formatBreadcrumbs(breadcrumbs)
const { items, nextPagePath } = await this.#list({ requestPath: this.nextPagePath, absDirPath, signal })
this.nextPagePath = nextPagePath
const files = []
const folders = []
items.forEach((item) => {
if (item.isFolder) {
folders.push(item)
} else {
files.push(item)
}
})
return { files, folders }
}
/**
* Select a folder based on its id: fetches the folder and then updates state with its contents
* TODO rename to something better like selectFolder or navigateToFolder (breaking change?)
*
* @param {string} requestPath
* the path we need to use when sending list request to companion (for some providers it's different from ID)
* @param {string} name used in the UI and to build the absDirPath
* @returns {Promise} Folders/files in folder
*/
async getFolder (requestPath, name) {
this.setLoading(true)
try {
await this.#withAbort(async (signal) => {
this.lastCheckbox = undefined
let { breadcrumbs } = this.plugin.getPluginState()
const index = breadcrumbs.findIndex((dir) => requestPath === dir.requestPath)
if (index !== -1) {
// means we navigated back to a known directory (already in the stack), so cut the stack off there
breadcrumbs = breadcrumbs.slice(0, index + 1)
} else {
// we have navigated into a new (unknown) folder, add it to the stack
breadcrumbs = [...breadcrumbs, { requestPath, name }]
}
this.nextPagePath = requestPath
let files = []
let folders = []
do {
const { files: newFiles, folders: newFolders } = await this.#listFilesAndFolders({
breadcrumbs, signal,
})
files = files.concat(newFiles)
folders = folders.concat(newFolders)
this.setLoading(this.plugin.uppy.i18n('loadedXFiles', { numFiles: files.length + folders.length }))
} while (
this.opts.loadAllFiles && this.nextPagePath
)
this.plugin.setPluginState({ folders, files, breadcrumbs, filterInput: '' })
})
} catch (err) {
// This is the first call that happens when the provider view loads, after auth, so it's probably nice to show any
// error occurring here to the user.
if (err?.name === 'UserFacingApiError') {
this.plugin.uppy.info({ message: this.plugin.uppy.i18n(err.message) }, 'warning', 5000)
return
}
this.handleError(err)
} finally {
this.setLoading(false)
}
}
/**
* Fetches new folder
*
* @param {object} folder
*/
getNextFolder (folder) {
this.getFolder(folder.requestPath, folder.name)
this.lastCheckbox = undefined
}
/**
* Removes session token on client side.
*/
async logout () {
try {
await this.#withAbort(async (signal) => {
const res = await this.provider.logout({ signal })
if (res.ok) {
if (!res.revoked) {
const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
provider: this.plugin.title,
url: res.manual_revoke_url,
})
this.plugin.uppy.info(message, 'info', 7000)
}
const newState = {
authenticated: false,
files: [],
folders: [],
breadcrumbs: [],
filterInput: '',
}
this.plugin.setPluginState(newState)
}
})
} catch (err) {
this.handleError(err)
}
}
filterQuery (input) {
this.plugin.setPluginState({ filterInput: input })
}
clearFilter () {
this.plugin.setPluginState({ filterInput: '' })
}
async handleAuth (authFormData) {
try {
await this.#withAbort(async (signal) => {
this.setLoading(true)
await this.provider.login({ authFormData, signal })
this.plugin.setPluginState({ authenticated: true })
this.preFirstRender()
})
} catch (err) {
if (err.name === 'UserFacingApiError') {
this.plugin.uppy.info({ message: this.plugin.uppy.i18n(err.message) }, 'warning', 5000)
return
}
this.plugin.uppy.log(`login failed: ${err.message}`)
} finally {
this.setLoading(false)
}
}
async handleScroll (event) {
if (this.shouldHandleScroll(event) && this.nextPagePath) {
this.isHandlingScroll = true
try {
await this.#withAbort(async (signal) => {
const { files, folders, breadcrumbs } = this.plugin.getPluginState()
const { files: newFiles, folders: newFolders } = await this.#listFilesAndFolders({
breadcrumbs, signal,
})
const combinedFiles = files.concat(newFiles)
const combinedFolders = folders.concat(newFolders)
this.plugin.setPluginState({ folders: combinedFolders, files: combinedFiles })
})
} catch (error) {
this.handleError(error)
} finally {
this.isHandlingScroll = false
}
}
}
async #recursivelyListAllFiles ({ requestPath, absDirPath, relDirPath, queue, onFiles, signal }) {
let curPath = requestPath
while (curPath) {
const res = await this.#list({ requestPath: curPath, absDirPath, signal })
curPath = res.nextPagePath
const files = res.items.filter((item) => !item.isFolder)
const folders = res.items.filter((item) => item.isFolder)
onFiles(files)
// recursively queue call to self for each folder
const promises = folders.map(async (folder) => queue.add(async () => (
this.#recursivelyListAllFiles({
requestPath: folder.requestPath,
absDirPath: prependPath(absDirPath, folder.name),
relDirPath: prependPath(relDirPath, folder.name),
queue,
onFiles,
signal,
})
)))
await Promise.all(promises) // in case we get an error
}
}
async donePicking () {
this.setLoading(true)
try {
await this.#withAbort(async (signal) => {
const { currentSelection } = this.plugin.getPluginState()
const messages = []
const newFiles = []
for (const selectedItem of currentSelection) {
const { requestPath } = selectedItem
const withRelDirPath = (newItem) => ({
...newItem,
// calculate the file's path relative to the user's selected item's path
// see https://github.com/transloadit/uppy/pull/4537#issuecomment-1614236655
relDirPath: newItem.absDirPath.replace(selectedItem.absDirPath, '').replace(/^\//, ''),
})
if (selectedItem.isFolder) {
let isEmpty = true
let numNewFiles = 0
const queue = new PQueue({ concurrency: 6 })
const onFiles = (files) => {
for (const newFile of files) {
const tagFile = this.getTagFile(newFile)
const id = getSafeFileId(tagFile)
// If the same folder is added again, we don't want to send
// X amount of duplicate file notifications, we want to say
// the folder was already added. This checks if all files are duplicate,
// if that's the case, we don't add the files.
if (!this.plugin.uppy.checkIfFileAlreadyExists(id)) {
newFiles.push(withRelDirPath(newFile))
numNewFiles++
this.setLoading(this.plugin.uppy.i18n('addedNumFiles', { numFiles: numNewFiles }))
}
isEmpty = false
}
}
await this.#recursivelyListAllFiles({
requestPath,
absDirPath: prependPath(selectedItem.absDirPath, selectedItem.name),
relDirPath: selectedItem.name,
queue,
onFiles,
signal,
})
await queue.onIdle()
let message
if (isEmpty) {
message = this.plugin.uppy.i18n('emptyFolderAdded')
} else if (numNewFiles === 0) {
message = this.plugin.uppy.i18n('folderAlreadyAdded', {
folder: selectedItem.name,
})
} else {
// TODO we don't really know at this point whether any files were actually added
// (only later after addFiles has been called) so we should probably rewrite this.
// Example: If all files fail to add due to restriction error, it will still say "Added 100 files from folder"
message = this.plugin.uppy.i18n('folderAdded', {
smart_count: numNewFiles, folder: selectedItem.name,
})
}
messages.push(message)
} else {
newFiles.push(withRelDirPath(selectedItem))
}
}
// Note: this.plugin.uppy.addFiles must be only run once we are done fetching all files,
// because it will cause the loading screen to disappear,
// and that will allow the user to start the upload, so we need to make sure we have
// finished all async operations before we add any file
// see https://github.com/transloadit/uppy/pull/4384
this.plugin.uppy.log('Adding files from a remote provider')
this.plugin.uppy.addFiles(newFiles.map((file) => this.getTagFile(file)))
this.plugin.setPluginState({ filterInput: '' })
messages.forEach(message => this.plugin.uppy.info(message))
this.clearSelection()
})
} catch (err) {
this.handleError(err)
} finally {
this.setLoading(false)
}
}
render (state, viewOptions = {}) {
const { authenticated, didFirstRender } = this.plugin.getPluginState()
const { i18n } = this.plugin.uppy
if (!didFirstRender) {
this.preFirstRender()
}
const targetViewOptions = { ...this.opts, ...viewOptions }
const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState()
const { isChecked, toggleCheckbox, recordShiftKeyPress, filterItems } = this
const hasInput = filterInput !== ''
const pluginIcon = this.plugin.icon || defaultPickerIcon
const headerProps = {
showBreadcrumbs: targetViewOptions.showBreadcrumbs,
getFolder: this.getFolder,
breadcrumbs: this.plugin.getPluginState().breadcrumbs,
pluginIcon,
title: this.plugin.title,
logout: this.logout,
username: this.username,
i18n,
}
const browserProps = {
isChecked,
toggleCheckbox,
recordShiftKeyPress,
currentSelection,
files: hasInput ? filterItems(files) : files,
folders: hasInput ? filterItems(folders) : folders,
getNextFolder: this.getNextFolder,
getFolder: this.getFolder,
loadAllFiles: this.opts.loadAllFiles,
// For SearchFilterInput component
showSearchFilter: targetViewOptions.showFilter,
search: this.filterQuery,
clearSearch: this.clearFilter,
searchTerm: filterInput,
searchOnInput: true,
searchInputLabel: i18n('filter'),
clearSearchLabel: i18n('resetFilter'),
noResultsLabel: i18n('noFilesFound'),
logout: this.logout,
handleScroll: this.handleScroll,
done: this.donePicking,
cancel: this.cancelPicking,
headerComponent: Header(headerProps),
title: this.plugin.title,
viewType: targetViewOptions.viewType,
showTitles: targetViewOptions.showTitles,
showBreadcrumbs: targetViewOptions.showBreadcrumbs,
pluginIcon,
i18n: this.plugin.uppy.i18n,
uppyFiles: this.plugin.uppy.getFiles(),
validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
isLoading: loading,
}
if (authenticated === false) {
return (
<CloseWrapper onUnmount={this.clearSelection}>
<AuthView
pluginName={this.plugin.title}
pluginIcon={pluginIcon}
handleAuth={this.handleAuth}
i18n={this.plugin.uppy.i18nArray}
renderForm={this.opts.renderAuthForm}
loading={loading}
/>
</CloseWrapper>
)
}
return (
<CloseWrapper onUnmount={this.clearSelection}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<Browser {...browserProps} />
</CloseWrapper>
)
}
}