You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							369 lines
						
					
					
						
							12 KiB
						
					
					
				
			
		
		
	
	
							369 lines
						
					
					
						
							12 KiB
						
					
					
				| import merge from 'lodash/merge'
 | |
| import * as path from 'path'
 | |
| import * as fs from 'fs'
 | |
| import i18n, { i18n as I18n } from 'i18next'
 | |
| import { FC } from 'react'
 | |
| 
 | |
| import { AdminJSOptionsWithDefault, AdminJSOptions } from './adminjs-options.interface'
 | |
| import BaseResource from './backend/adapters/resource/base-resource'
 | |
| import BaseDatabase from './backend/adapters/database/base-database'
 | |
| import ConfigurationError from './backend/utils/errors/configuration-error'
 | |
| import ResourcesFactory from './backend/utils/resources-factory/resources-factory'
 | |
| import userComponentsBundler from './backend/bundler/user-components-bundler'
 | |
| import { RecordActionResponse, Action, BulkActionResponse } from './backend/actions/action.interface'
 | |
| import { DEFAULT_PATHS } from './constants'
 | |
| import { ACTIONS } from './backend/actions'
 | |
| 
 | |
| import loginTemplate from './frontend/login-template'
 | |
| import { ListActionResponse } from './backend/actions/list/list-action'
 | |
| import { combineTranslations, Locale } from './locale/config'
 | |
| import { locales } from './locale'
 | |
| import { TranslateFunctions, createFunctions } from './utils/translate-functions.factory'
 | |
| import { relativeFilePathResolver } from './utils/file-resolver'
 | |
| import { getComponentHtml } from './backend/utils'
 | |
| import { ComponentLoader } from './backend/utils/component-loader'
 | |
| import { OverridableComponent } from './frontend'
 | |
| 
 | |
| const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8'))
 | |
| export const VERSION = pkg.version
 | |
| 
 | |
| export const defaultOptions: AdminJSOptionsWithDefault = {
 | |
|   rootPath: DEFAULT_PATHS.rootPath,
 | |
|   logoutPath: DEFAULT_PATHS.logoutPath,
 | |
|   loginPath: DEFAULT_PATHS.loginPath,
 | |
|   databases: [],
 | |
|   resources: [],
 | |
|   dashboard: {},
 | |
|   pages: {},
 | |
|   bundler: {},
 | |
| }
 | |
| 
 | |
| type ActionsMap = {
 | |
|   show: Action<RecordActionResponse>;
 | |
|   edit: Action<RecordActionResponse>;
 | |
|   delete: Action<RecordActionResponse>;
 | |
|   bulkDelete: Action<BulkActionResponse>;
 | |
|   new: Action<RecordActionResponse>;
 | |
|   list: Action<ListActionResponse>;
 | |
| }
 | |
| 
 | |
| export type LoginOverride<T = Record<string, unknown>> = {
 | |
|   component: FC<T>;
 | |
|   props?: T;
 | |
| }
 | |
| 
 | |
| export type Adapter = { Database: typeof BaseDatabase; Resource: typeof BaseResource }
 | |
| 
 | |
| /**
 | |
|  * Main class for AdminJS extension. It takes {@link AdminJSOptions} as a
 | |
|  * parameter and creates an admin instance.
 | |
|  *
 | |
|  * Its main responsibility is to fetch all the resources and/or databases given by a
 | |
|  * user. Its instance is a currier - injected in all other classes.
 | |
|  *
 | |
|  * @example
 | |
|  * const AdminJS = require('adminjs')
 | |
|  * const admin = new AdminJS(AdminJSOptions)
 | |
|  */
 | |
| class AdminJS {
 | |
|   public resources: Array<BaseResource>
 | |
| 
 | |
|   public options: AdminJSOptionsWithDefault
 | |
| 
 | |
|   public locale!: Locale
 | |
| 
 | |
|   public i18n!: I18n
 | |
| 
 | |
|   public translateFunctions!: TranslateFunctions
 | |
| 
 | |
|   public componentLoader: ComponentLoader
 | |
| 
 | |
|   /**
 | |
|    * List of all default actions. If you want to change the behavior for all actions like:
 | |
|    * _list_, _edit_, _show_, _delete_ and _bulkDelete_ you can do this here.
 | |
|    *
 | |
|    * @example <caption>Modifying accessibility rules for all show actions</caption>
 | |
|    * const { ACTIONS } = require('adminjs')
 | |
|    * ACTIONS.show.isAccessible = () => {...}
 | |
|    */
 | |
|   public static ACTIONS: ActionsMap
 | |
| 
 | |
|   /**
 | |
|    * AdminJS version
 | |
|    */
 | |
|   public static VERSION: string
 | |
| 
 | |
|   /**
 | |
|    * Login override
 | |
|    */
 | |
|   private loginOverride?: LoginOverride
 | |
| 
 | |
|   /**
 | |
|    * @param   {AdminJSOptions} options      Options passed to AdminJS
 | |
|    */
 | |
|   constructor(options: AdminJSOptions = {}) {
 | |
|     /**
 | |
|      * @type {BaseResource[]}
 | |
|      * @description List of all resources available for the AdminJS.
 | |
|      * They can be fetched with the {@link AdminJS#findResource} method
 | |
|      */
 | |
|     this.resources = []
 | |
| 
 | |
|     /**
 | |
|      * @type {AdminJSOptions}
 | |
|      * @description Options given by a user
 | |
|      */
 | |
|     this.options = merge({}, defaultOptions, options)
 | |
| 
 | |
|     this.resolveBabelConfigPath()
 | |
| 
 | |
|     this.initI18n()
 | |
| 
 | |
|     const { databases, resources } = this.options
 | |
| 
 | |
|     this.componentLoader = options.componentLoader ?? new ComponentLoader()
 | |
| 
 | |
|     const resourcesFactory = new ResourcesFactory(this, global.RegisteredAdapters || [])
 | |
|     this.resources = resourcesFactory.buildResources({ databases, resources })
 | |
|   }
 | |
| 
 | |
|   initI18n(): void {
 | |
|     const language = this.options.locale?.language || locales.en.language
 | |
|     const defaultTranslations = locales[language]?.translations || locales.en.translations
 | |
|     this.locale = {
 | |
|       translations: combineTranslations(defaultTranslations, this.options.locale?.translations),
 | |
|       language,
 | |
|     }
 | |
|     if (i18n.isInitialized) {
 | |
|       i18n.addResourceBundle(this.locale.language, 'translation', this.locale.translations)
 | |
|     } else {
 | |
|       i18n.init({
 | |
|         lng: this.locale.language,
 | |
|         initImmediate: false, // loads translations immediately
 | |
|         resources: {
 | |
|           [this.locale.language]: {
 | |
|             translation: this.locale.translations,
 | |
|           },
 | |
|         },
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     // mixin translate functions to AdminJS instance so users will be able to
 | |
|     // call AdminJS.translateMessage(...)
 | |
|     this.translateFunctions = createFunctions(i18n)
 | |
|     Object.getOwnPropertyNames(this.translateFunctions).forEach((translateFunctionName) => {
 | |
|       this[translateFunctionName] = this.translateFunctions[translateFunctionName]
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Registers various database adapters written for AdminJS.
 | |
|    *
 | |
|    * @example
 | |
|    * const AdminJS = require('adminjs')
 | |
|    * const MongooseAdapter = require('adminjs-mongoose')
 | |
|    * AdminJS.registerAdapter(MongooseAdapter)
 | |
|    *
 | |
|    * @param  {Object}       options
 | |
|    * @param  {typeof BaseDatabase} options.Database subclass of {@link BaseDatabase}
 | |
|    * @param  {typeof BaseResource} options.Resource subclass of {@link BaseResource}
 | |
|    */
 | |
|   static registerAdapter({ Database, Resource }: {
 | |
|     Database: typeof BaseDatabase;
 | |
|     Resource: typeof BaseResource;
 | |
|   }): void {
 | |
|     if (!Database || !Resource) {
 | |
|       throw new Error('Adapter has to have both Database and Resource')
 | |
|     }
 | |
| 
 | |
|     // TODO: check if this is actually valid because "isAdapterFor" is always defined.
 | |
|     // checking if both Database and Resource have at least isAdapterFor method
 | |
|     // @ts-ignore
 | |
|     if (Database.isAdapterFor && Resource.isAdapterFor) {
 | |
|       global.RegisteredAdapters = global.RegisteredAdapters || []
 | |
|       global.RegisteredAdapters.push({ Database, Resource })
 | |
|     } else {
 | |
|       throw new Error('Adapter elements have to be a subclass of AdminJS.BaseResource and AdminJS.BaseDatabase')
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Initializes AdminJS instance in production. This function should be called by
 | |
|    * all external plugins.
 | |
|    */
 | |
|   async initialize(): Promise<void> {
 | |
|     if (process.env.NODE_ENV === 'production'
 | |
|         && !(process.env.ADMIN_JS_SKIP_BUNDLE === 'true')) {
 | |
|       // eslint-disable-next-line no-console
 | |
|       console.log('AdminJS: bundling user components...')
 | |
|       await userComponentsBundler(this, { write: true })
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Watches for local changes in files imported via {@link ComponentLoader}.
 | |
|    * It doesn't work on production environment.
 | |
|    *
 | |
|    * @return  {Promise<never>}
 | |
|    */
 | |
|   async watch(): Promise<string | undefined> {
 | |
|     if (process.env.NODE_ENV !== 'production') {
 | |
|       return userComponentsBundler(this, { write: true, watch: true })
 | |
|     }
 | |
|     return undefined
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Allows you to override the default login view by providing your React components
 | |
|    * and custom props.
 | |
|    *
 | |
|    * @param  {Object} options
 | |
|    * @param  {String} options.component       Custom React component
 | |
|    * @param  {String} [options.props]         Props to be passed to React component
 | |
|    * @return {Promise<void>}
 | |
|    */
 | |
|   overrideLogin({ component, props }: LoginOverride): void {
 | |
|     this.loginOverride = { component, props: props ?? {} }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Renders an entire login page with email and password fields
 | |
|    * using {@link Renderer}.
 | |
|    *
 | |
|    * Used by external plugins
 | |
|    *
 | |
|    * @param  {Object} options
 | |
|    * @param  {String} options.action          Login form action url - it could be
 | |
|    *                                          '/admin/login'
 | |
|    * @param  {String} [options.errorMessage]  Optional error message. When set,
 | |
|    *                                          renderer will print this message in
 | |
|    *                                          the form
 | |
|    * @return {Promise<string>}                HTML of the rendered page
 | |
|    */
 | |
|   async renderLogin({ action, errorMessage }): Promise<string> {
 | |
|     if (this.loginOverride) {
 | |
|       const { component, props = {} } = this.loginOverride
 | |
|       const mergedProps = {
 | |
|         action,
 | |
|         message: errorMessage,
 | |
|         ...props,
 | |
|       }
 | |
|       return getComponentHtml(component, mergedProps, this)
 | |
|     }
 | |
|     return loginTemplate(this, { action, errorMessage })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Returns resource base on its ID
 | |
|    *
 | |
|    * @example
 | |
|    * const User = admin.findResource('users')
 | |
|    * await User.findOne(userId)
 | |
|    *
 | |
|    * @param  {String} resourceId    ID of a resource defined under {@link BaseResource#id}
 | |
|    * @return {BaseResource}         found resource
 | |
|    * @throws {Error}                When resource with given id cannot be found
 | |
|    */
 | |
|   findResource(resourceId): BaseResource {
 | |
|     const resource = this.resources.find((m) => m._decorated?.id() === resourceId)
 | |
|     if (!resource) {
 | |
|       throw new Error([
 | |
|         `There are no resources with given id: "${resourceId}"`,
 | |
|         'This is the list of all registered resources you can use:',
 | |
|         this.resources.map((r) => r._decorated?.id() || r.id()).join(', '),
 | |
|       ].join('\n'))
 | |
|     }
 | |
|     return resource
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Resolve babel config file path,
 | |
|    * and load configuration to this.options.bundler.babelConfig.
 | |
|    */
 | |
|   resolveBabelConfigPath(): void {
 | |
|     if (typeof this.options?.bundler?.babelConfig !== 'string') {
 | |
|       return
 | |
|     }
 | |
|     let filePath = ''
 | |
|     let config = this.options?.bundler?.babelConfig
 | |
|     if (config[0] === '/') {
 | |
|       filePath = config
 | |
|     } else {
 | |
|       filePath = relativeFilePathResolver(config, /new AdminJS/)
 | |
|     }
 | |
| 
 | |
|     if (!fs.existsSync(filePath)) {
 | |
|       throw new ConfigurationError(`Given babel config "${filePath}", doesn't exist.`, 'AdminJS.html')
 | |
|     }
 | |
|     if (path.extname(filePath) === '.js') {
 | |
|       // eslint-disable-next-line
 | |
|       const configModule = require(filePath)
 | |
|       config = configModule && configModule.__esModule
 | |
|         ? configModule.default || undefined
 | |
|         : configModule
 | |
|       if (!config || typeof config !== 'object' || Array.isArray(config)) {
 | |
|         throw new Error(
 | |
|           `${filePath}: Configuration should be an exported JavaScript object.`,
 | |
|         )
 | |
|       }
 | |
|     } else {
 | |
|       try {
 | |
|         config = JSON.parse(fs.readFileSync(filePath, 'utf8'))
 | |
|       } catch (err) {
 | |
|         throw new Error(`${filePath}: Error while parsing config - ${err.message}`)
 | |
|       }
 | |
|       if (!config) throw new Error(`${filePath}: No config detected`)
 | |
|       if (typeof config !== 'object') {
 | |
|         throw new Error(`${filePath}: Config returned typeof ${typeof config}`)
 | |
|       }
 | |
|       if (Array.isArray(config)) {
 | |
|         throw new Error(`${filePath}: Expected config object but found array`)
 | |
|       }
 | |
|     }
 | |
|     this.options.bundler.babelConfig = config
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Requires given `.jsx/.tsx` file, that it can be bundled to the frontend.
 | |
|    * It will be available under AdminJS.UserComponents[componentId].
 | |
|    *
 | |
|    * @param   {String}  src  Path to a file containing react component.
 | |
|    *
 | |
|    * @param  {OverridableComponent}  [componentName] - name of the component which you want
 | |
|    *                                  to override
 | |
|    * @returns {String}                componentId - uniq id of a component
 | |
|    *
 | |
|    * @example <caption>Passing custom components in AdminJS options</caption>
 | |
|    * const adminJsOptions = {
 | |
|    *   dashboard: {
 | |
|    *     component: AdminJS.bundle('./path/to/component'),
 | |
|    *   }
 | |
|    * }
 | |
|    * @example <caption>Overriding AdminJS core components</caption>
 | |
|    * // somewhere in the code
 | |
|    * AdminJS.bundle('./path/to/new-sidebar/component', 'SidebarFooter')
 | |
|    *
 | |
|    * @deprecated since version 6.5.0, use {@link ComponentLoader} instead
 | |
|    */
 | |
|   public static bundle(src: string, componentName?: OverridableComponent): string {
 | |
|     // eslint-disable-next-line no-plusplus
 | |
|     const name = componentName ?? `Component${this.__unsafe_componentIndex++}`
 | |
|     this.__unsafe_staticComponentLoader.__unsafe_addWithoutChecks(name, src, 'bundle')
 | |
|     return name
 | |
|   }
 | |
| 
 | |
|   private static __unsafe_componentIndex = 0
 | |
| 
 | |
|   public static __unsafe_staticComponentLoader = new ComponentLoader()
 | |
| }
 | |
| 
 | |
| AdminJS.VERSION = VERSION
 | |
| AdminJS.ACTIONS = ACTIONS
 | |
| 
 | |
| // eslint-disable-next-line @typescript-eslint/no-empty-interface
 | |
| interface AdminJS extends TranslateFunctions {}
 | |
| 
 | |
| export const { registerAdapter } = AdminJS
 | |
| 
 | |
| export default AdminJS
 |