1
0
mirror of https://gitlab.com/bramw/baserow.git synced 2024-11-24 16:36:46 +00:00
bramw_baserow/web-frontend/modules/core/plugins/clientHandler.js
2024-09-24 15:06:35 +00:00

622 lines
22 KiB
JavaScript

import axios from 'axios'
import { upperCaseFirst } from '@baserow/modules/core/utils/string'
import { makeRefreshAuthInterceptor } from '@baserow/modules/core/plugins/clientAuthRefresh'
export class ResponseErrorMessage {
constructor(title, message) {
this.title = title
this.message = message
}
}
/**
* This class holds all the default error messages and offers the ability to
* register new ones. This is stored in a separate class because need to inject
* this, so it can be used by other modules.
*/
export class ClientErrorMap {
constructor(app) {
// Declare the default error messages.
this.errorMap = {
ERROR_USER_NOT_IN_GROUP: new ResponseErrorMessage(
app.i18n.t('clientHandler.userNotInWorkspaceTitle'),
app.i18n.t('clientHandler.userNotInWorkspaceDescription')
),
ERROR_USER_INVALID_GROUP_PERMISSIONS: new ResponseErrorMessage(
app.i18n.t('clientHandler.invalidWorkspacePermissionsTitle'),
app.i18n.t('clientHandler.invalidWorkspacePermissionsDescription')
),
// @TODO move these errors to the module.
ERROR_TABLE_DOES_NOT_EXIST: new ResponseErrorMessage(
app.i18n.t('clientHandler.tableDoesNotExistTitle'),
app.i18n.t('clientHandler.tableDoesNotExistDescription')
),
ERROR_ROW_DOES_NOT_EXIST: new ResponseErrorMessage(
app.i18n.t('clientHandler.rowDoesNotExistTitle'),
app.i18n.t('clientHandler.rowDoesNotExistDescription')
),
ERROR_CANNOT_CREATE_FIELD_TYPE: new ResponseErrorMessage(
app.i18n.t('clientHandler.cannotCreateFieldTypeTitle'),
app.i18n.t('clientHandler.cannotCreateFieldTypeDescription')
),
ERROR_NOTIFICATION_DOES_NOT_EXIST: new ResponseErrorMessage(
app.i18n.t('clientHandler.notificationDoesNotExistTitle'),
app.i18n.t('clientHandler.notificationDoesNotExistDescription')
),
ERROR_FILE_SIZE_TOO_LARGE: new ResponseErrorMessage(
app.i18n.t('clientHandler.fileSizeTooLargeTitle'),
app.i18n.t('clientHandler.fileSizeTooLargeDescription')
),
ERROR_INVALID_FILE: new ResponseErrorMessage(
app.i18n.t('clientHandler.invalidFileTitle'),
app.i18n.t('clientHandler.invalidFileDescription')
),
ERROR_FILE_URL_COULD_NOT_BE_REACHED: new ResponseErrorMessage(
app.i18n.t('clientHandler.fileUrlCouldNotBeReachedTitle'),
app.i18n.t('clientHandler.fileUrlCouldNotBeReachedDescription')
),
ERROR_INVALID_FILE_URL: new ResponseErrorMessage(
app.i18n.t('clientHandler.invalidFileUrlTitle'),
app.i18n.t('clientHandler.invalidFileUrlDescription')
),
USER_ADMIN_CANNOT_DEACTIVATE_SELF: new ResponseErrorMessage(
app.i18n.t('clientHandler.adminCannotDeactivateSelfTitle'),
app.i18n.t('clientHandler.adminCannotDeactivateSelfDescription')
),
USER_ADMIN_CANNOT_DELETE_SELF: new ResponseErrorMessage(
app.i18n.t('clientHandler.adminCannotDeleteSelfTitle'),
app.i18n.t('clientHandler.adminCannotDeleteSelfDescription')
),
USER_ADMIN_ALREADY_EXISTS: new ResponseErrorMessage(
app.i18n.t('clientHandler.adminAlreadyExistsTitle'),
app.i18n.t('clientHandler.adminAlreadyExistsDescription')
),
ERROR_MAX_FIELD_COUNT_EXCEEDED: new ResponseErrorMessage(
app.i18n.t('clientHandler.maxFieldCountExceededTitle'),
app.i18n.t('clientHandler.maxFieldCountExceededDescription')
),
ERROR_CANNOT_RESTORE_PARENT_BEFORE_CHILD: new ResponseErrorMessage(
app.i18n.t('clientHandler.cannotRestoreParentBeforeChildTitle'),
app.i18n.t('clientHandler.cannotRestoreParentBeforeChildDescription')
),
ERROR_CANT_RESTORE_AS_RELATED_TABLE_TRASHED: new ResponseErrorMessage(
app.i18n.t('clientHandler.cannotRestoreAsRelatedTableTrashedTitle'),
app.i18n.t(
'clientHandler.cannotRestoreAsRelatedTableTrashedDescription'
)
),
ERROR_GROUP_USER_IS_LAST_ADMIN: new ResponseErrorMessage(
app.i18n.t('clientHandler.workspaceUserIsLastAdminTitle'),
app.i18n.t('clientHandler.workspaceUserIsLastAdminDescription')
),
ERROR_MAX_JOB_COUNT_EXCEEDED: new ResponseErrorMessage(
app.i18n.t('clientHandler.errorMaxJobCountExceededTitle'),
app.i18n.t('clientHandler.errorMaxJobCountExceededDescription')
),
ERROR_FAILED_TO_LOCK_FIELD_DUE_TO_CONFLICT: new ResponseErrorMessage(
app.i18n.t('clientHandler.failedToLockFieldDueToConflictTitle'),
app.i18n.t('clientHandler.failedToLockFieldDueToConflictDescription')
),
ERROR_FAILED_TO_LOCK_TABLE_DUE_TO_CONFLICT: new ResponseErrorMessage(
app.i18n.t('clientHandler.failedToLockTableDueToConflictTitle'),
app.i18n.t('clientHandler.failedToLockTableDueToConflictDescription')
),
ERROR_UNDO_REDO_LOCK_CONFLICT: new ResponseErrorMessage(
app.i18n.t('clientHandler.failedToUndoRedoDueToConflictTitle'),
app.i18n.t('clientHandler.failedToUndoRedoDueToConflictDescription')
),
ERROR_MAXIMUM_SNAPSHOTS_REACHED: new ResponseErrorMessage(
app.i18n.t('clientHandler.maximumSnapshotsReachedTitle'),
app.i18n.t('clientHandler.maximumSnapshotsReachedDescription')
),
ERROR_SNAPSHOT_IS_BEING_CREATED: new ResponseErrorMessage(
app.i18n.t('clientHandler.snapshotBeingCreatedTitle'),
app.i18n.t('clientHandler.snapshotBeingCreatedDescription')
),
ERROR_SNAPSHOT_IS_BEING_RESTORED: new ResponseErrorMessage(
app.i18n.t('clientHandler.snapshotBeingRestoredTitle'),
app.i18n.t('clientHandler.snapshotBeingRestoredDescription')
),
ERROR_SNAPSHOT_IS_BEING_DELETED: new ResponseErrorMessage(
app.i18n.t('clientHandler.snapshotBeingDeletedTitle'),
app.i18n.t('clientHandler.snapshotBeingDeletedDescription')
),
ERROR_SNAPSHOT_NAME_NOT_UNIQUE: new ResponseErrorMessage(
app.i18n.t('clientHandler.snapshotNameNotUniqueTitle'),
app.i18n.t('clientHandler.snapshotNameNotUniqueDescription')
),
ERROR_SNAPSHOT_OPERATION_LIMIT_EXCEEDED: new ResponseErrorMessage(
app.i18n.t('clientHandler.snapshotOperationLimitExceededTitle'),
app.i18n.t('clientHandler.snapshotOperationLimitExceededDescription')
),
ERROR_AUTH_PROVIDER_DISABLED: new ResponseErrorMessage(
app.i18n.t('clientHandler.disabledPasswordProviderTitle'),
app.i18n.t('clientHandler.disabledPasswordProviderMessage')
),
ERROR_OUTPUT_PARSER: new ResponseErrorMessage(
app.i18n.t('clientHandler.outputParserTitle'),
app.i18n.t('clientHandler.outputParserDescription')
),
ERROR_GENERATIVE_AI_PROMPT: new ResponseErrorMessage(
app.i18n.t('clientHandler.generateAIPromptTitle'),
app.i18n.t('clientHandler.generateAIPromptDescription')
),
// TODO: Move to enterprise module if possible
ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS: new ResponseErrorMessage(
app.i18n.t('clientHandler.cannotDisableAllAuthProvidersTitle'),
app.i18n.t('clientHandler.cannotDisableAllAuthProvidersDescription')
),
ERROR_MAX_LOCKS_PER_TRANSACTION_EXCEEDED: new ResponseErrorMessage(
app.i18n.t('clientHandler.maxLocksPerTransactionExceededTitle'),
app.i18n.t('clientHandler.maxLocksPerTransactionExceededDescription')
),
ERROR_LAST_ADMIN_OF_GROUP: new ResponseErrorMessage(
app.i18n.t('clientHandler.lastAdminTitle'),
app.i18n.t('clientHandler.lastAdminMessage')
),
ERROR_GENERATIVE_AI_DOES_NOT_EXIST: new ResponseErrorMessage(
app.i18n.t('clientHandler.generativeAIDoesNotExistTitle'),
app.i18n.t('clientHandler.generativeAIDoesNotExistDescription')
),
ERROR_MODEL_DOES_NOT_BELONG_TO_TYPE: new ResponseErrorMessage(
app.i18n.t('clientHandler.modelDoesNotBelongToTypeTitle'),
app.i18n.t('clientHandler.modelDoesNotBelongToTypeDescription')
),
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED:
new ResponseErrorMessage(
app.i18n.t(
'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedTitle'
),
app.i18n.t(
'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedDescription'
)
),
ERROR_FIELD_IS_ALREADY_PRIMARY: new ResponseErrorMessage(
app.i18n.t('clientHandler.fieldIsAlreadyPrimaryTitle'),
app.i18n.t('clientHandler.fieldIsAlreadyPrimaryDescription')
),
ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE: new ResponseErrorMessage(
app.i18n.t('clientHandler.incompatiblePrimaryFieldTypeTitle'),
app.i18n.t('clientHandler.incompatiblePrimaryFieldTypeDescription')
),
ERROR_CANNOT_CREATE_ROWS_IN_TABLE: new ResponseErrorMessage(
app.i18n.t('clientHandler.cannotCreateRowsInTableTitle'),
app.i18n.t('clientHandler.cannotCreateRowsInTableDescription')
),
}
}
setError(code, title, description) {
this.errorMap[code] = new ResponseErrorMessage(title, description)
}
}
export class ErrorHandler {
constructor(
store,
app,
clientErrorMap,
response,
code = null,
detail = null
) {
this.isHandled = false
this.store = store
this.app = app
this.response = response
this.setError(code, detail)
this.errorMap = clientErrorMap.errorMap
// A temporary notFoundMap containing the error messages for when the
// response contains a 404 error based on the provided context name. Note
// that an entry is not found a default message will be generated.
this.notFoundMap = {}
}
/**
* Changes the error code and details.
*/
setError(code, detail) {
this.code = code
this.detail = detail
}
hasBaserowAPIError() {
return this.response !== undefined && this.code != null
}
hasRequestBodyValidationError() {
return (
this.response !== undefined &&
this.response?.data?.error === 'ERROR_REQUEST_BODY_VALIDATION'
)
}
/**
* Returns true is the response status code is equal to not found (404).
* @return {boolean}
*/
isNotFound() {
return this.response !== undefined && this.response.status === 404
}
/**
* Returns true if the response status code is equal to not found (429) which
* means that the user is sending too much requests to the server.
* @return {boolean}
*/
isTooManyRequests() {
return this.response !== undefined && this.response.status === 429
}
/**
* Return true if there is a network error.
* @return {boolean}
*/
hasNetworkError() {
return this.response === undefined
}
/**
* Finds a message in the global errors or in the provided specific error map.
*/
getErrorMessage(specificErrorMap = null) {
if (
specificErrorMap !== null &&
Object.prototype.hasOwnProperty.call(specificErrorMap, this.code)
) {
return specificErrorMap[this.code]
}
if (Object.prototype.hasOwnProperty.call(this.errorMap, this.code)) {
return this.errorMap[this.code]
}
return this.genericDefaultError()
}
searchForMatchingFieldException(
listOfDetailErrors,
mapOfDetailCodeToResponseError
) {
for (const detailError of listOfDetailErrors) {
if (
detailError &&
typeof detailError === 'object' &&
typeof detailError.code === 'string'
) {
const handledError = mapOfDetailCodeToResponseError[detailError.code]
if (handledError) {
return handledError
}
}
}
return null
}
/**
* Given a "ERROR_REQUEST_BODY_VALIDATION" error has occurred this function matches
* a provided error map against the machine readable error codes in the "detail"
* key in the response.
*
* For example if the response contains an error looking like:
*
* {
* "error": "ERROR_REQUEST_BODY_VALIDATION",
* "detail": {
* "url": [
* {
* "error": "Enter a valid URL.",
* "code": "invalid"
* }
* ]
* }
* }
*
* Then you would call this function like so to match the above error and get your
* ResponseErrorMessage returned:
*
* getRequestBodyErrorMessage({"url":{"invalid": new ResponseErrorMessage('a','b')}})
*
* @param requestBodyErrorMap An object where it's keys are the names of the
* request body attribute that can fail with a value being another sub object. This
* sub object should be keyed by the "code" returned in the error detail with the
* value being a ResponseErrorMessage that should be returned if the API returned an
* error for that attribute and code.
* @return Any The first ResponseErrorMessage which is found in the error map that
* matches an error in the response body, or null if no match is found.
*/
getRequestBodyErrorMessage(requestBodyErrorMap) {
const detail = this.response?.data?.detail
if (requestBodyErrorMap && detail && typeof detail === 'object') {
for (const fieldName of Object.keys(detail)) {
const errorsForField = detail[fieldName]
const supportedExceptionsForField = requestBodyErrorMap[fieldName]
if (
errorsForField != null &&
Array.isArray(errorsForField) &&
supportedExceptionsForField
) {
const matchingException = this.searchForMatchingFieldException(
errorsForField,
supportedExceptionsForField
)
if (matchingException) {
return matchingException
}
}
}
}
return null
}
/**
* Finds a not found message for a given context.
*/
getNotFoundMessage(name) {
if (!Object.prototype.hasOwnProperty.call(this.notFoundMap, name)) {
return new ResponseErrorMessage(
this.app.i18n.t('clientHandler.notFoundTitle', {
name: upperCaseFirst(name),
}),
this.app.i18n.t('clientHandler.notFoundDescription', {
name: name.toLowerCase(),
})
)
}
return this.notFoundMap[name]
}
/**
* Returns a standard network error message. For example if the API server
* could not be reached.
*/
getNetworkErrorMessage() {
return new ResponseErrorMessage(
this.app.i18n.t('clientHandler.networkErrorTitle'),
this.app.i18n.t('clientHandler.networkErrorDescription')
)
}
/**
* Returns a standard network error message. For example if the API server
* could not be reached.
*/
getTooManyRequestsError() {
return new ResponseErrorMessage(
this.app.i18n.t('clientHandler.tooManyRequestsTitle'),
this.app.i18n.t('clientHandler.tooManyRequestsDescription')
)
}
/**
* If there is an error or the requested detail is not found an error
* message related to the problem is returned.
*/
getMessage(name = null, specificErrorMap = null, requestBodyErrorMap = null) {
if (this.isTooManyRequests()) {
return this.getTooManyRequestsError()
}
if (this.hasNetworkError()) {
return this.getNetworkErrorMessage()
}
if (this.hasBaserowAPIError()) {
if (this.hasRequestBodyValidationError()) {
const matchingRequestBodyError =
this.getRequestBodyErrorMessage(requestBodyErrorMap)
if (matchingRequestBodyError) {
return matchingRequestBodyError
}
}
return this.getErrorMessage(specificErrorMap)
}
if (this.isNotFound()) {
return this.getNotFoundMessage(name)
}
return this.genericDefaultError()
}
genericDefaultError() {
return new ResponseErrorMessage(
this.app.i18n.t('clientHandler.notCompletedTitle'),
this.app.i18n.t('clientHandler.notCompletedDescription')
)
}
/**
* If there is an error or the requested detail is not found we will try to
* get find an existing message of one is not provided and notify the user
* about what went wrong. After that the error is marked as handled.
*/
notifyIf(name = null, message = null) {
if (
!(
this.hasBaserowAPIError() ||
this.hasNetworkError() ||
this.isNotFound()
) ||
this.isHandled
) {
return
}
if (message === null) {
message = this.getMessage(name)
}
this.store.dispatch(
'toast/error',
{
title: message.title,
message: message.message,
},
{ root: true }
)
this.handled()
}
/**
* Will mark the error as handled so that the same error message isn't shown
* twice.
*/
handled() {
this.isHandled = true
}
}
export function makeErrorResponseInterceptor(
store,
app,
clientErrorMap,
nuxtErrorHandler
) {
return (error) => {
const rspData = error.response?.data
// user session expired. Redirect to login page to start a new session.
if (rspData?.error === 'ERROR_INVALID_REFRESH_TOKEN') {
nuxtErrorHandler({ statusCode: 401, message: 'User session expired' })
return Promise.reject(error)
}
error.handler = new ErrorHandler(store, app, clientErrorMap, error.response)
if (
typeof rspData === 'object' &&
'error' in rspData &&
'detail' in rspData
) {
error.handler.setError(rspData.error, rspData.detail)
} else if (typeof rspData !== 'object') {
error.handler.setError(500, null)
}
return Promise.reject(error)
}
}
/**
* Add the user related headers according to the current authentication status.
*/
const prepareRequestHeaders = (store) => (config) => {
const application = store.getters['userSourceUser/getCurrentApplication']
if (store.getters['auth/isAuthenticated']) {
const token = store.getters['auth/token']
config.headers.Authorization = `JWT ${token}`
config.headers.ClientSessionId =
store.getters['auth/getUntrustedClientSessionId']
// If we are logged with Baserow user and with a user source user
// so we also want to send this user token
// to the backend through the custom `UserSourceAuthorization` header.
// This enables the "double" authentication.
// We access the data with the permission of the currently logged Baserow user
// but we can see the data of the user source user.
if (store.getters['userSourceUser/isAuthenticated'](application)) {
const userSourceToken =
store.getters['userSourceUser/accessToken'](application)
config.headers.UserSourceAuthorization = `JWT ${userSourceToken}`
}
} else if (store.getters['userSourceUser/isAuthenticated'](application)) {
// Here we are logged as a user source user
const userSourceToken =
store.getters['userSourceUser/accessToken'](application)
// We don't want to add the user source token if we are refreshing as the token
// won't be accepted.
if (!store.getters['userSourceUser/isRefreshing'](application)) {
config.headers.Authorization = `JWT ${userSourceToken}`
}
}
if (store.getters['auth/webSocketId'] !== null) {
const webSocketId = store.getters['auth/webSocketId']
config.headers.WebSocketId = webSocketId
}
return config
}
const createAxiosInstance = (app) => {
const url =
(process.client
? app.$config.PUBLIC_BACKEND_URL
: app.$config.PRIVATE_BACKEND_URL) + '/api'
return axios.create({
baseURL: url,
withCredentials: false,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
}
export default function ({ app, store, error }, inject) {
const client = createAxiosInstance(app)
// Create and inject the client error map, so that other modules can also register
// default error messages.
const clientErrorMap = new ClientErrorMap(app)
inject('clientErrorMap', clientErrorMap)
client.interceptors.request.use(prepareRequestHeaders(store))
// Create a response interceptor to add more detail to the error message
// and to create a toast when there is a network error.
client.interceptors.response.use(
null,
makeErrorResponseInterceptor(store, app, clientErrorMap, error)
)
// Main auth refresh token
const shouldInterceptRequest = () =>
store.getters['auth/shouldRefreshToken']()
const shouldInterceptResponse = (error) =>
store.getters['auth/isAuthenticated'] &&
error.response?.data?.error === 'ERROR_INVALID_ACCESS_TOKEN'
const refreshToken = async () => await store.dispatch('auth/refresh')
const refreshAuthInterceptor = makeRefreshAuthInterceptor(
client,
refreshToken,
shouldInterceptRequest,
shouldInterceptResponse
)
client.interceptors.response.use(null, refreshAuthInterceptor)
// User source auth refresh token (only active if it's not a double authentication)
const shouldInterceptUserSourceRequest = (req) => {
const application = store.getters['userSourceUser/getCurrentApplication']
return (
!store.getters['auth/isAuthenticated'] &&
store.getters['userSourceUser/shouldRefreshToken'](application)
)
}
const shouldInterceptUserSourceResponse = (error) => {
const application = store.getters['userSourceUser/getCurrentApplication']
return (
!store.getters['auth/isAuthenticated'] &&
store.getters['userSourceUser/isAuthenticated'](application) &&
error.response?.data?.error === 'ERROR_INVALID_ACCESS_TOKEN'
)
}
const refreshUserSourceToken = async () =>
await store.dispatch('userSourceUser/refreshAuth', {
application: store.getters['userSourceUser/getCurrentApplication'],
})
const refreshUserSourceUserInterceptor = makeRefreshAuthInterceptor(
client,
refreshUserSourceToken,
shouldInterceptUserSourceRequest,
shouldInterceptUserSourceResponse
)
client.interceptors.response.use(null, refreshUserSourceUserInterceptor)
inject('client', client)
}