├── .npmrc
├── .vscode
└── settings.json
├── commitlint.config.cjs
├── tsconfig.test.json
├── src
├── convertRoutes.ts
├── typings
│ └── express-session
│ │ └── index.d.ts
├── logger.ts
├── authentication
│ ├── protected-routes.handler.ts
│ ├── logout.handler.ts
│ ├── refresh.handler.ts
│ └── login.handler.ts
├── errors.ts
├── types.ts
├── buildAuthenticatedRouter.ts
├── buildRouter.ts
└── index.ts
├── nodemon.json
├── nodemon.auth.json
├── examples
├── mongoose
│ ├── admin-model.ts
│ └── article-model.ts
├── simple.ts
└── auth.ts
├── test
└── plugin.test.ts
├── .eslintrc.cjs
├── .releaserc
├── jest.json
├── tsconfig.json
├── LICENSE
├── README.md
├── .gitignore
├── .github
└── workflows
│ └── push.yml
└── package.json
/.npmrc:
--------------------------------------------------------------------------------
1 | access=public
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/commitlint.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | '@commitlint/config-conventional',
4 | ],
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["./src/**/*.ts", "./test/**/*.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/src/convertRoutes.ts:
--------------------------------------------------------------------------------
1 | export const convertToExpressRoute = (adminRoute: string): string =>
2 | adminRoute.replace(/{/g, ":").replace(/}/g, "");
3 |
--------------------------------------------------------------------------------
/src/typings/express-session/index.d.ts:
--------------------------------------------------------------------------------
1 | export {};
2 |
3 | declare module "express-session" {
4 | interface SessionData {
5 | adminUser?: unknown;
6 | redirectTo?: string;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "verbose": false,
3 | "ignore": ["*.test.*", "*.spec.*"],
4 | "exec": "ts-node --files examples/simple.ts",
5 | "watch": [
6 | "src",
7 | "examples"
8 | ]
9 | }
--------------------------------------------------------------------------------
/nodemon.auth.json:
--------------------------------------------------------------------------------
1 | {
2 | "verbose": false,
3 | "ignore": ["*.test.*", "*.spec.*"],
4 | "exec": "ts-node --files examples/auth.ts",
5 | "watch": [
6 | "src",
7 | "examples"
8 | ],
9 | "ext": "ts"
10 | }
--------------------------------------------------------------------------------
/examples/mongoose/admin-model.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const { Schema } = mongoose;
4 |
5 | const AdminSchema = new Schema({
6 | email: String,
7 | password: String,
8 | });
9 |
10 | const Admin = mongoose.model("Admin", AdminSchema);
11 |
12 | module.exports = Admin;
13 |
--------------------------------------------------------------------------------
/src/logger.ts:
--------------------------------------------------------------------------------
1 | export const log = {
2 | /**
3 | * Logs the debug message to console if `process.env.ADMINJS_EXPRESS_DEBUG` is set
4 | */
5 | debug: (message: string): void => {
6 | if (process.env.ADMINJS_EXPRESS_DEBUG) {
7 | console.debug(message);
8 | }
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/examples/mongoose/article-model.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const { Schema } = mongoose;
4 |
5 | const ArticleSchema = new Schema({
6 | title: String,
7 | content: String,
8 | author: String,
9 | createdAt: Date,
10 | published: Boolean,
11 | });
12 |
13 | const Article = mongoose.model("Article", ArticleSchema);
14 |
15 | module.exports = Article;
16 |
--------------------------------------------------------------------------------
/test/plugin.test.ts:
--------------------------------------------------------------------------------
1 | import { AdminJS } from "adminjs";
2 | import express from "express";
3 | import { jest } from "@jest/globals";
4 |
5 | import { buildRouter } from "../src/buildRouter";
6 |
7 | jest.useFakeTimers();
8 |
9 | describe("plugin", () => {
10 | describe(".buildRouter", () => {
11 | it("returns an express router when AdminJS instance given as an argument", () => {
12 | expect(buildRouter(new AdminJS())).toBeInstanceOf(
13 | express.Router().constructor
14 | );
15 | });
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: "@typescript-eslint/parser",
3 | plugins: ["@typescript-eslint/eslint-plugin"],
4 | extends: [
5 | "plugin:@typescript-eslint/recommended",
6 | "plugin:prettier/recommended",
7 | ],
8 | parserOptions: {
9 | ecmaVersion: 2020,
10 | sourceType: "module",
11 | ecmaFeatures: {
12 | jsx: true,
13 | },
14 | },
15 | rules: {
16 | "prettier/prettier": "error",
17 | },
18 | ignorePatterns: ["node_modules", "lib", "types"],
19 | settings: {
20 | react: {
21 | version: "detect",
22 | },
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "branches": [
3 | "+([0-9])?(.{+([0-9]),x}).x",
4 | "master",
5 | "next",
6 | "next-major",
7 | {
8 | "name": "beta",
9 | "prerelease": true
10 | }
11 | ],
12 | "plugins": [
13 | "@semantic-release/commit-analyzer",
14 | "@semantic-release/release-notes-generator",
15 | "@semantic-release/npm",
16 | "@semantic-release/github",
17 | "@semantic-release/git",
18 | [
19 | "semantic-release-slack-bot",
20 | {
21 | "notifyOnSuccess": true,
22 | "notifyOnFail": false
23 | }
24 | ]
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/src/authentication/protected-routes.handler.ts:
--------------------------------------------------------------------------------
1 | import AdminJS from "adminjs";
2 | import { Router, RequestHandler } from "express";
3 |
4 | export const withProtectedRoutesHandler = (
5 | router: Router,
6 | admin: AdminJS
7 | ): void => {
8 | const { loginPath } = admin.options;
9 | const authorizedRoutesMiddleware: RequestHandler = (
10 | request,
11 | response,
12 | next
13 | ) => {
14 | if (!request.session || !request.session.adminUser) {
15 | return response.redirect(loginPath);
16 | }
17 | return next();
18 | };
19 |
20 | router.use(authorizedRoutesMiddleware);
21 | };
22 |
--------------------------------------------------------------------------------
/jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts", "tsx"],
3 | "rootDir": ".",
4 | "testEnvironment": "node",
5 | "testRegex": ".test.ts$",
6 | "extensionsToTreatAsEsm": [".ts"],
7 | "transformIgnorePatterns": ["node_modules"],
8 | "transform": {
9 | "^.+\\.(t|j)sx?$": [
10 | "ts-jest",
11 | {
12 | "useESM": true,
13 | "tsconfig": "./tsconfig.test.json",
14 | "isolatedModules": true
15 | }
16 | ]
17 | },
18 | "moduleNameMapper": {
19 | "^(\\.{1,2}/.*)\\.js$": "$1"
20 | },
21 | "testTimeout": 10000,
22 | "preset": "ts-jest/presets/default-esm",
23 | "verbose": true,
24 | "silent": true,
25 | "forceExit": true
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "nodenext",
4 | "sourceMap": false,
5 | "outDir": "lib",
6 | "esModuleInterop": true,
7 | "allowSyntheticDefaultImports": true,
8 | "declaration": true,
9 | "types": ["express-formidable", "jest", "./src/typings/express-session"],
10 | "resolveJsonModule": true,
11 | "target": "es2017",
12 | "jsx": "preserve",
13 | "importHelpers": true,
14 | "skipLibCheck": true,
15 | "strictNullChecks": true,
16 | "strictPropertyInitialization": true,
17 | "strictFunctionTypes": true,
18 | "strictBindCallApply": true,
19 | "noImplicitThis": true,
20 | "moduleResolution": "nodenext",
21 | "baseUrl": "./",
22 | "typeRoots": ["./src/typings", "./node_modules/@types"],
23 | "allowJs": true
24 | },
25 | "exclude": ["lib", "types", "test", "examples", "commitlint.config.cjs"]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/simple.ts:
--------------------------------------------------------------------------------
1 | import AdminJS from "adminjs";
2 | import express from "express";
3 | import mongoose from "mongoose";
4 | import MongooseAdapter from "@adminjs/mongoose";
5 |
6 | import AdminJSExpress from "../src/index.js";
7 | import "./mongoose/article-model.js";
8 | import "./mongoose/admin-model.js";
9 |
10 | AdminJS.registerAdapter(MongooseAdapter);
11 |
12 | const start = async () => {
13 | const connection = await mongoose.connect(
14 | process.env.MONGO_URL || "mongodb://localhost:27017/example"
15 | );
16 | const app = express();
17 |
18 | const adminJs = new AdminJS({
19 | databases: [connection],
20 | rootPath: "/admin",
21 | });
22 | const router = AdminJSExpress.buildRouter(adminJs);
23 |
24 | app.use(adminJs.options.rootPath, router);
25 |
26 | app.listen(process.env.PORT || 8080, () =>
27 | console.log("AdminJS is running under localhost:8080/admin")
28 | );
29 | };
30 |
31 | start();
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2018 SoftwareBrothers.co
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/src/authentication/logout.handler.ts:
--------------------------------------------------------------------------------
1 | import AdminJS from "adminjs";
2 | import { Router } from "express";
3 | import { AuthenticationOptions } from "../types.js";
4 |
5 | const getLogoutPath = (admin: AdminJS) => {
6 | const { logoutPath, rootPath } = admin.options;
7 | const normalizedLogoutPath = logoutPath.replace(rootPath, "");
8 |
9 | return normalizedLogoutPath.startsWith("/")
10 | ? normalizedLogoutPath
11 | : `/${normalizedLogoutPath}`;
12 | };
13 |
14 | export const withLogout = (
15 | router: Router,
16 | admin: AdminJS,
17 | auth: AuthenticationOptions
18 | ): void => {
19 | const logoutPath = getLogoutPath(admin);
20 |
21 | const { provider } = auth;
22 |
23 | router.get(logoutPath, async (request, response) => {
24 | if (provider) {
25 | try {
26 | await provider.handleLogout({ req: request, res: response });
27 | } catch (error) {
28 | console.error(error); // fail silently and still logout
29 | }
30 | }
31 |
32 | request.session.destroy(() => {
33 | response.redirect(admin.options.loginPath);
34 | });
35 | });
36 | };
37 |
--------------------------------------------------------------------------------
/src/errors.ts:
--------------------------------------------------------------------------------
1 | export const MISSING_AUTH_CONFIG_ERROR =
2 | 'You must configure either "authenticate" method or assign an auth "provider"';
3 | export const INVALID_AUTH_CONFIG_ERROR =
4 | 'You cannot configure both "authenticate" and "provider". "authenticate" will be removed in next major release.';
5 |
6 | export class WrongArgumentError extends Error {
7 | constructor(message: string) {
8 | super(message);
9 | this.name = "WrongArgumentError";
10 | }
11 | }
12 |
13 | export class OldBodyParserUsedError extends Error {
14 | constructor(
15 | message = `
16 | You probably used old \`body-parser\` middleware, which is not compatible
17 | with @adminjs/express. In order to make it work you will have to
18 | 1. move body-parser invocation after the AdminJS setup like this:
19 |
20 | const adminJs = new AdminJS()
21 | const router = new buildRouter(adminJs)
22 | app.use(adminJs.options.rootPath, router)
23 |
24 | // body parser goes after the AdminJS router
25 | app.use(bodyParser())
26 |
27 | 2. Upgrade body-parser to the latest version and use it like this:
28 | app.use(bodyParser.json())
29 | `
30 | ) {
31 | super(message);
32 | this.name = "WrongArgumentError";
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { BaseAuthProvider } from "adminjs";
2 | import { Request, Response } from "express";
3 |
4 | export type FormidableOptions = {
5 | encoding?: string;
6 | uploadDir?: string;
7 | keepExtensions?: boolean;
8 | type?: "multipart" | "urlencoded";
9 | maxFileSize?: number;
10 | maxFieldsSize?: number;
11 | maxFields?: number;
12 | hash?: boolean | "sha1" | "md5";
13 | multiples?: boolean;
14 | };
15 |
16 | export type AuthenticationContext = {
17 | /**
18 | * @description Authentication request object
19 | */
20 | req: Request;
21 | /**
22 | * @description Authentication response object
23 | */
24 | res: Response;
25 | };
26 |
27 | export type AuthenticationMaxRetriesOptions = {
28 | /**
29 | * @description Count of retries
30 | */
31 | count: number;
32 | /**
33 | * @description Time to reset (in seconds)
34 | */
35 | duration: number;
36 | };
37 |
38 | export type AuthenticationOptions = {
39 | cookiePassword: string;
40 | cookieName?: string;
41 | authenticate?: (
42 | email: string,
43 | password: string,
44 | context?: AuthenticationContext
45 | ) => unknown | null;
46 | /**
47 | * @description Maximum number of authorization attempts (if number - per minute)
48 | */
49 | maxRetries?: number | AuthenticationMaxRetriesOptions;
50 | provider?: BaseAuthProvider;
51 | };
52 |
--------------------------------------------------------------------------------
/examples/auth.ts:
--------------------------------------------------------------------------------
1 | import MongooseAdapter from "@adminjs/mongoose";
2 | import AdminJS from "adminjs";
3 | import express from "express";
4 | import mongoose from "mongoose";
5 |
6 | import AdminJSExpress from "../src/index.js";
7 | import "./mongoose/admin-model.js";
8 | import "./mongoose/article-model.js";
9 |
10 | AdminJS.registerAdapter(MongooseAdapter);
11 |
12 | const ADMIN = {
13 | email: "test@example.com",
14 | password: "password",
15 | };
16 |
17 | const start = async () => {
18 | const connection = await mongoose.connect(
19 | process.env.MONGO_URL || "mongodb://localhost:27017/example"
20 | );
21 | const app = express();
22 |
23 | const adminJs = new AdminJS({
24 | databases: [connection],
25 | rootPath: "/admin",
26 | });
27 |
28 | const router = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
29 | authenticate: async (email, password) => {
30 | if (ADMIN.password === password && ADMIN.email === email) {
31 | return ADMIN;
32 | }
33 | return null;
34 | },
35 | cookiePassword: "somasd1nda0asssjsdhb21uy3g",
36 | maxRetries: {
37 | count: 3,
38 | duration: 120,
39 | },
40 | });
41 |
42 | app.use(adminJs.options.rootPath, router);
43 |
44 | app.listen(process.env.PORT || 8080, () =>
45 | console.log("AdminJS is running under localhost:8080/admin")
46 | );
47 | };
48 |
49 | start();
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Expressjs plugin for AdminJS
2 |
3 | This is an official [AdminJS](https://github.com/SoftwareBrothers/adminjs) plugin which integrates it to [expressjs](https://expressjs.com/) framework.
4 |
5 | ## AdminJS
6 |
7 | AdminJS is an automatic admin interface which can be plugged into your application. You, as a developer, provide database models (like posts, comments, stores, products or whatever else your application uses), and AdminJS generates UI which allows you (or other trusted users) to manage content.
8 |
9 | Check out the example application with mongo and postgres models here: https://adminjs-demo.herokuapp.com/admin/
10 |
11 | Or visit [AdminJS](https://github.com/SoftwareBrothers/adminjs) github page.
12 |
13 | ## Usage
14 |
15 | To see example usage visit the [Express section under AdminJS project page](https://docs.adminjs.co/installation/plugins/express)
16 |
17 | ## Debugging
18 | Set `process.env.ADMINJS_EXPRESS_DEBUG` env variable to see debug logs from the library
19 |
20 | ## License
21 |
22 | AdminJS is copyrighted © 2023 rst.software. It is a free software, and may be redistributed under the terms specified in the [LICENSE](LICENSE.md) file.
23 |
24 | ## About rst.software
25 |
26 |
27 |
28 | We’re an open, friendly team that helps clients from all over the world to transform their businesses and create astonishing products.
29 |
30 | * We are available for [hire](https://www.rst.software/estimate-your-project).
31 | * If you want to work for us - check out the [career page](https://www.rst.software/join-us).
32 |
--------------------------------------------------------------------------------
/src/authentication/refresh.handler.ts:
--------------------------------------------------------------------------------
1 | import AdminJS, { CurrentAdmin } from "adminjs";
2 | import { Router } from "express";
3 | import { AuthenticationOptions } from "../types.js";
4 | import { WrongArgumentError } from "../errors.js";
5 |
6 | const getRefreshTokenPath = (admin: AdminJS) => {
7 | const { refreshTokenPath, rootPath } = admin.options;
8 | const normalizedRefreshTokenPath = refreshTokenPath.replace(rootPath, "");
9 |
10 | return normalizedRefreshTokenPath.startsWith("/")
11 | ? normalizedRefreshTokenPath
12 | : `/${normalizedRefreshTokenPath}`;
13 | };
14 |
15 | const MISSING_PROVIDER_ERROR =
16 | '"provider" has to be configured to use refresh token mechanism';
17 |
18 | export const withRefresh = (
19 | router: Router,
20 | admin: AdminJS,
21 | auth: AuthenticationOptions
22 | ): void => {
23 | const refreshTokenPath = getRefreshTokenPath(admin);
24 |
25 | const { provider } = auth;
26 |
27 | router.post(refreshTokenPath, async (request, response) => {
28 | if (!provider) {
29 | throw new WrongArgumentError(MISSING_PROVIDER_ERROR);
30 | }
31 |
32 | const updatedAuthInfo = await provider.handleRefreshToken(
33 | {
34 | data: request.fields ?? {},
35 | query: request.query,
36 | params: request.params,
37 | headers: request.headers,
38 | },
39 | { req: request, res: response }
40 | );
41 |
42 | let admin = request.session.adminUser as Partial | null;
43 | if (!admin) {
44 | admin = {};
45 | }
46 |
47 | if (!admin._auth) {
48 | admin._auth = {};
49 | }
50 |
51 | admin._auth = {
52 | ...admin._auth,
53 | ...updatedAuthInfo,
54 | };
55 |
56 | request.session.adminUser = admin;
57 | request.session.save(() => {
58 | response.send(admin);
59 | });
60 | });
61 | };
62 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # Snowpack dependency directory (https://snowpack.dev/)
45 | web_modules/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 | .parcel-cache
78 |
79 | # Next.js build output
80 | .next
81 | out
82 |
83 | # Nuxt.js build / generate output
84 | .nuxt
85 | dist
86 |
87 | # Gatsby files
88 | .cache/
89 | # Comment in the public line in if your project uses Gatsby and not Next.js
90 | # https://nextjs.org/blog/next-9-1#public-directory-support
91 | # public
92 |
93 | # vuepress build output
94 | .vuepress/dist
95 |
96 | # Serverless directories
97 | .serverless/
98 |
99 | # FuseBox cache
100 | .fusebox/
101 |
102 | # DynamoDB Local files
103 | .dynamodb/
104 |
105 | # TernJS port file
106 | .tern-port
107 |
108 | # Stores VSCode versions used for testing VSCode extensions
109 | .vscode-test
110 |
111 | # yarn v2
112 | .yarn/cache
113 | .yarn/unplugged
114 | .yarn/build-state.yml
115 | .yarn/install-state.gz
116 | .pnp.*
117 |
118 | .DS_store
119 |
120 | # build files
121 | /lib
122 | .idea
--------------------------------------------------------------------------------
/.github/workflows/push.yml:
--------------------------------------------------------------------------------
1 | name: CI/CD
2 | on: push
3 | jobs:
4 | setup:
5 | name: setup
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Dump GitHub context
9 | env:
10 | GITHUB_CONTEXT: ${{ toJson(github) }}
11 | run: echo "$GITHUB_CONTEXT"
12 | - name: Checkout
13 | uses: actions/checkout@v2
14 | - name: Setup
15 | uses: actions/setup-node@v2
16 | with:
17 | node-version: "18.x"
18 | - uses: actions/cache@v2
19 | id: yarn-cache
20 | with:
21 | path: node_modules
22 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
23 | restore-keys: |
24 | ${{ runner.os }}-node_modules-
25 | - name: Install
26 | if: steps.yarn-cache.outputs.cache-hit != 'true'
27 | run: yarn install
28 |
29 | test:
30 | name: Test
31 | runs-on: ubuntu-latest
32 | needs: setup
33 | steps:
34 | - name: Checkout
35 | uses: actions/checkout@v2
36 | - name: Setup
37 | uses: actions/setup-node@v2
38 | with:
39 | node-version: "18.x"
40 | - uses: actions/cache@v2
41 | id: yarn-cache
42 | with:
43 | path: node_modules
44 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
45 | restore-keys: |
46 | ${{ runner.os }}-node_modules-
47 | - name: Install
48 | if: steps.yarn-cache.outputs.cache-hit != 'true'
49 | run: yarn install
50 | - name: Lint
51 | run: yarn lint
52 | - name: Build
53 | run: yarn build
54 | - name: test
55 | run: yarn test
56 |
57 | publish:
58 | name: Publish
59 | needs: test
60 | runs-on: ubuntu-latest
61 | steps:
62 | - name: Checkout
63 | uses: actions/checkout@v2
64 | - name: Setup
65 | uses: actions/setup-node@v2
66 | with:
67 | node-version: "18.x"
68 | - uses: actions/cache@v2
69 | id: yarn-cache
70 | with:
71 | path: node_modules
72 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
73 | restore-keys: |
74 | ${{ runner.os }}-node_modules-
75 | - name: Install
76 | if: steps.yarn-cache.outputs.cache-hit != 'true'
77 | run: yarn install
78 | - name: Build
79 | run: yarn build
80 | - name: Release
81 | env:
82 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
84 | JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}
85 | JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
86 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
87 | run: yarn release
88 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@adminjs/express",
3 | "version": "6.1.1",
4 | "description": "This is an official AdminJS plugin which integrates it with Express.js framework",
5 | "main": "lib/index.js",
6 | "type": "module",
7 | "exports": {
8 | ".": {
9 | "import": "./lib/index.js",
10 | "types": "./lib/index.d.ts"
11 | }
12 | },
13 | "scripts": {
14 | "dev": "rm -rf lib && tsc --watch",
15 | "build": "rm -rf lib && tsc",
16 | "example:simple": "nodemon",
17 | "example:auth": "nodemon --config nodemon.auth.json",
18 | "test": "NODE_OPTIONS=--experimental-vm-modules jest --config ./jest.json --runInBand --detectOpenHandles",
19 | "lint": "eslint './**/*.ts'",
20 | "check:all": "yarn lint && yarn build && yarn test",
21 | "release": "semantic-release"
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/SoftwareBrothers/adminjs-expressjs.git"
26 | },
27 | "keywords": [
28 | "expressjs",
29 | "admin",
30 | "adminjs",
31 | "admin-panel"
32 | ],
33 | "author": "Michał Laskowski",
34 | "license": "SEE LICENSE IN LICENSE",
35 | "bugs": {
36 | "url": "https://github.com/SoftwareBrothers/adminjs-expressjs/issues"
37 | },
38 | "homepage": "https://github.com/SoftwareBrothers/adminjs-expressjs#readme",
39 | "peerDependencies": {
40 | "adminjs": "^7.4.0",
41 | "express": ">=4.18.2",
42 | "express-formidable": "^1.2.0",
43 | "express-session": ">=1.17.3",
44 | "tslib": "^2.5.0"
45 | },
46 | "husky": {
47 | "hooks": {
48 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
49 | }
50 | },
51 | "devDependencies": {
52 | "@adminjs/mongoose": "^4.0.0",
53 | "@commitlint/config-conventional": "^17.4.4",
54 | "@jest/globals": "^29.5.0",
55 | "@semantic-release/git": "^9.0.0",
56 | "@types/express": "^4.17.17",
57 | "@types/express-formidable": "^1.2.0",
58 | "@types/express-session": "^1.17.6",
59 | "@types/jest": "^26.0.15",
60 | "@types/node": "^18.15.3",
61 | "@typescript-eslint/eslint-plugin": "^5.53.0",
62 | "@typescript-eslint/parser": "^5.53.0",
63 | "adminjs": "^7.4.0",
64 | "commitlint": "^17.4.4",
65 | "eslint": "^8.35.0",
66 | "eslint-config-airbnb-base": "^15.0.0",
67 | "eslint-config-prettier": "^8.6.0",
68 | "eslint-plugin-import": "^2.27.5",
69 | "eslint-plugin-prettier": "^4.2.1",
70 | "express": "^4.18.2",
71 | "express-formidable": "^1.2.0",
72 | "express-session": "^1.17.3",
73 | "husky": "^4.3.0",
74 | "jest": "^29.5.0",
75 | "mongoose": "^6.10.0",
76 | "nodemon": "^2.0.6",
77 | "prettier": "^2.8.4",
78 | "semantic-release": "^17.2.4",
79 | "semantic-release-slack-bot": "^1.6.2",
80 | "ts-jest": "^29.0.5",
81 | "ts-node": "^10.9.1",
82 | "typescript": "^4.9.5"
83 | },
84 | "dependencies": {}
85 | }
86 |
--------------------------------------------------------------------------------
/src/buildAuthenticatedRouter.ts:
--------------------------------------------------------------------------------
1 | import AdminJS, { Router as AdminRouter } from "adminjs";
2 | import express, { Router } from "express";
3 | import formidableMiddleware from "express-formidable";
4 | import session from "express-session";
5 |
6 | import { withLogin } from "./authentication/login.handler.js";
7 | import { withLogout } from "./authentication/logout.handler.js";
8 | import { withProtectedRoutesHandler } from "./authentication/protected-routes.handler.js";
9 | import { buildAssets, buildRoutes, initializeAdmin } from "./buildRouter.js";
10 | import {
11 | INVALID_AUTH_CONFIG_ERROR,
12 | MISSING_AUTH_CONFIG_ERROR,
13 | OldBodyParserUsedError,
14 | WrongArgumentError,
15 | } from "./errors.js";
16 | import { AuthenticationOptions, FormidableOptions } from "./types.js";
17 | import { withRefresh } from "./authentication/refresh.handler.js";
18 |
19 | /**
20 | * @typedef {Function} Authenticate
21 | * @memberof module:@adminjs/express
22 | * @description
23 | * function taking 2 arguments email and password
24 | * @param {string} [email] email given in the form
25 | * @param {string} [password] password given in the form
26 | * @return {CurrentAdmin | null} returns current admin or null
27 | */
28 |
29 | /**
30 | * Builds the Express Router which is protected by a session auth
31 | *
32 | * Using the router requires you to install `express-session` as a
33 | * dependency. Normally express-session holds session in memory, which is
34 | * not optimized for production usage and, in development, it causes
35 | * logging out after every page refresh (if you use nodemon).
36 | * @static
37 | * @memberof module:@adminjs/express
38 | * @example
39 | * const ADMIN = {
40 | * email: 'test@example.com',
41 | * password: 'password',
42 | * }
43 | *
44 | * AdminJSExpress.buildAuthenticatedRouter(adminJs, {
45 | * authenticate: async (email, password) => {
46 | * if (ADMIN.password === password && ADMIN.email === email) {
47 | * return ADMIN
48 | * }
49 | * return null
50 | * },
51 | * cookieName: 'adminjs',
52 | * cookiePassword: 'somePassword',
53 | * }, [router])
54 | */
55 | export const buildAuthenticatedRouter = (
56 | admin: AdminJS,
57 | auth: AuthenticationOptions,
58 | predefinedRouter?: express.Router | null,
59 | sessionOptions?: session.SessionOptions,
60 | formidableOptions?: FormidableOptions
61 | ): Router => {
62 | initializeAdmin(admin);
63 |
64 | const { routes, assets } = AdminRouter;
65 | const router = predefinedRouter || express.Router();
66 |
67 | if (!auth.authenticate && !auth.provider) {
68 | throw new WrongArgumentError(MISSING_AUTH_CONFIG_ERROR);
69 | }
70 |
71 | if (auth.authenticate && auth.provider) {
72 | throw new WrongArgumentError(INVALID_AUTH_CONFIG_ERROR);
73 | }
74 |
75 | if (auth.provider) {
76 | admin.options.env = {
77 | ...admin.options.env,
78 | ...auth.provider.getUiProps(),
79 | };
80 | }
81 |
82 | router.use((req, _, next) => {
83 | if ((req as any)._body) {
84 | next(new OldBodyParserUsedError());
85 | }
86 | next();
87 | });
88 |
89 | // todo fix types
90 | router.use(
91 | session({
92 | ...sessionOptions,
93 | secret: auth.cookiePassword,
94 | name: auth.cookieName || "adminjs",
95 | }) as any
96 | );
97 | router.use(formidableMiddleware(formidableOptions) as any);
98 |
99 | withLogin(router, admin, auth);
100 | withLogout(router, admin, auth);
101 | buildAssets({ admin, assets, routes, router });
102 |
103 | withProtectedRoutesHandler(router, admin);
104 | withRefresh(router, admin, auth);
105 | buildRoutes({ admin, routes, router });
106 |
107 | return router;
108 | };
109 |
--------------------------------------------------------------------------------
/src/buildRouter.ts:
--------------------------------------------------------------------------------
1 | import AdminJS, { Router as AdminRouter } from "adminjs";
2 | import { RequestHandler, Router } from "express";
3 | import formidableMiddleware from "express-formidable";
4 | import path from "path";
5 |
6 | import { WrongArgumentError } from "./errors.js";
7 | import { log } from "./logger.js";
8 | import { FormidableOptions } from "./types.js";
9 | import { convertToExpressRoute } from "./convertRoutes.js";
10 |
11 | const INVALID_ADMINJS_INSTANCE =
12 | "You have to pass an instance of AdminJS to the buildRouter() function";
13 |
14 | export type RouteHandlerArgs = {
15 | admin: AdminJS;
16 | route: (typeof AdminRouter)["routes"][0];
17 | };
18 |
19 | export type BuildRoutesArgs = {
20 | admin: AdminJS;
21 | routes: (typeof AdminRouter)["routes"];
22 | router: Router;
23 | };
24 |
25 | export type BuildAssetsArgs = {
26 | admin: AdminJS;
27 | assets: (typeof AdminRouter)["assets"];
28 | routes: (typeof AdminRouter)["routes"];
29 | router: Router;
30 | };
31 |
32 | export const initializeAdmin = (admin: AdminJS): void => {
33 | if (admin?.constructor?.name !== "AdminJS") {
34 | throw new WrongArgumentError(INVALID_ADMINJS_INSTANCE);
35 | }
36 |
37 | admin.initialize().then(() => {
38 | log.debug("AdminJS: bundle ready");
39 | });
40 | };
41 |
42 | export const routeHandler =
43 | ({ admin, route }: RouteHandlerArgs): RequestHandler =>
44 | async (req, res, next) => {
45 | try {
46 | const controller = new route.Controller(
47 | { admin },
48 | req.session && req.session.adminUser
49 | );
50 | const { params, query } = req;
51 | const method = req.method.toLowerCase();
52 | const payload = {
53 | ...(req.fields || {}),
54 | ...(req.files || {}),
55 | };
56 | const html = await controller[route.action](
57 | {
58 | ...req,
59 | params,
60 | query,
61 | payload,
62 | method,
63 | },
64 | res
65 | );
66 | if (route.contentType) {
67 | res.set({ "Content-Type": route.contentType });
68 | }
69 | if (html) {
70 | res.send(html);
71 | }
72 | } catch (e) {
73 | next(e);
74 | }
75 | };
76 |
77 | export const buildRoute = ({
78 | route,
79 | router,
80 | admin,
81 | }: {
82 | route: (typeof AdminRouter)["routes"][number];
83 | router: Router;
84 | admin: AdminJS;
85 | }) => {
86 | // we have to change routes defined in AdminJS from {recordId} to :recordId
87 | const expressPath = convertToExpressRoute(route.path);
88 |
89 | if (route.method === "GET") {
90 | router.get(expressPath, routeHandler({ admin, route }));
91 | }
92 |
93 | if (route.method === "POST") {
94 | router.post(expressPath, routeHandler({ admin, route }));
95 | }
96 | };
97 |
98 | export const buildRoutes = ({
99 | admin,
100 | routes,
101 | router,
102 | }: BuildRoutesArgs): void => {
103 | routes.forEach((route) => buildRoute({ route, router, admin }));
104 | };
105 |
106 | export const buildAssets = ({
107 | admin,
108 | assets,
109 | routes,
110 | router,
111 | }: BuildAssetsArgs): void => {
112 | // Note: We want components.bundle.js to be globally available. In production it is served as a .js asset, meanwhile
113 | // in local environments it's a route with "bundleComponents" action assigned.
114 | const componentBundlerRoute = routes.find(
115 | (r) => r.action === "bundleComponents"
116 | );
117 | if (componentBundlerRoute) {
118 | buildRoute({ route: componentBundlerRoute, router, admin });
119 | }
120 |
121 | assets.forEach((asset) => {
122 | router.get(asset.path, async (_req, res) => {
123 | res.sendFile(path.resolve(asset.src));
124 | });
125 | });
126 | };
127 |
128 | export const buildRouter = (
129 | admin: AdminJS,
130 | predefinedRouter?: Router | null,
131 | formidableOptions?: FormidableOptions
132 | ): Router => {
133 | initializeAdmin(admin);
134 |
135 | const { routes, assets } = AdminRouter;
136 | const router = predefinedRouter ?? Router();
137 | // todo fix types
138 | router.use(formidableMiddleware(formidableOptions) as any);
139 |
140 | buildAssets({ admin, assets, routes, router });
141 | buildRoutes({ admin, routes, router });
142 |
143 | return router;
144 | };
145 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len */
2 | import { buildAuthenticatedRouter } from "./buildAuthenticatedRouter.js";
3 | import { buildRouter } from "./buildRouter.js";
4 |
5 | /**
6 | * @module @adminjs/express
7 | * @subcategory Plugins
8 | * @section modules
9 | *
10 | * @classdesc
11 | * Plugin that allows you to add AdminJS to Express.js applications.
12 | *
13 | * ## Installation
14 | *
15 | * ```sh
16 | * npm install @adminjs/express
17 | * ```
18 | *
19 | * It has 2 peerDependencies: `express-formidable` and `express`,
20 | * so you have to install them as well (if they are not installed already)
21 | *
22 | * ```
23 | * npm install express express-formidable
24 | * ```
25 | *
26 | * ## Usage
27 | *
28 | * ```
29 | * const AdminJSExpress = require('@adminjs/express')
30 | * ```
31 | *
32 | * It exposes 2 methods that create an Express Router, which can be attached
33 | * to a given url in the API. Each method takes a pre-configured instance of {@link AdminJS}.
34 | *
35 | * - {@link module:@adminjs/express.buildRouter AdminJSExpress.buildRouter(admin, [predefinedRouter])}
36 | * - {@link module:@adminjs/express.buildAuthenticatedRouter AdminJSExpress.buildAuthenticatedRouter(admin, auth, [predefinedRouter], sessionOptions)}
37 | *
38 | * If you want to use a router you have already created - not a problem. Just pass it
39 | * as a `predefinedRouter` parameter.
40 | *
41 | * You may want to use this option when you want to include
42 | * some custom auth middleware for you AdminJS routes.
43 | *
44 | * ## Example without an authentication
45 | *
46 | * ```
47 | * const AdminJS = require('adminjs')
48 | * const AdminJSExpress = require('@adminjs/express')
49 | *
50 | * const express = require('express')
51 | * const app = express()
52 | *
53 | * const adminJs = new AdminJS({
54 | * databases: [],
55 | * rootPath: '/admin',
56 | * })
57 | *
58 | * const router = AdminJSExpress.buildRouter(adminJs)
59 | * app.use(adminJs.options.rootPath, router)
60 | * app.listen(8080, () => console.log('AdminJS is running under localhost:8080/admin'))
61 | * ```
62 | *
63 | * ## Using build in authentication
64 | *
65 | * To protect the routes with a session authentication, you can use predefined
66 | * {@link module:@adminjs/express.buildAuthenticatedRouter} method.
67 | *
68 | * Note! To use authentication in production environment, there is a need to configure
69 | * express-session for production build. It can be achieved by passing options to
70 | * `sessionOptions` parameter. Read more on [express/session Github page](https://github.com/expressjs/session)
71 | *
72 | * ## Adding custom authentication
73 | *
74 | * You can add your custom authentication setup by firstly creating the router and then
75 | * passing it via the `predefinedRouter` option.
76 | *
77 | * ```
78 | * let router = express.Router()
79 | * router.use((req, res, next) => {
80 | * if (req.session && req.session.admin) {
81 | * req.session.adminUser = req.session.admin
82 | * next()
83 | * } else {
84 | * res.redirect(adminJs.options.loginPath)
85 | * }
86 | * })
87 | * router = AdminJSExpress.buildRouter(adminJs, router)
88 | * ```
89 | *
90 | * Where `req.session.admin` is {@link AdminJS#CurrentAdmin},
91 | * meaning that it should have at least an email property.
92 | */
93 |
94 | /**
95 | * Plugin name
96 | * @static
97 | * @memberof module:@adminjs/express
98 | */
99 | export const name = "AdminJSExpressjs";
100 | export { SessionData } from "express-session";
101 |
102 | export type ExpressPlugin = {
103 | name: string;
104 | buildAuthenticatedRouter: typeof buildAuthenticatedRouter;
105 | buildRouter: typeof buildRouter;
106 | };
107 |
108 | const plugin: ExpressPlugin = { name, buildAuthenticatedRouter, buildRouter };
109 |
110 | export default plugin;
111 |
112 | export type {
113 | AuthenticationOptions,
114 | FormidableOptions,
115 | AuthenticationMaxRetriesOptions,
116 | AuthenticationContext,
117 | } from "./types.js";
118 | export * from "./buildRouter.js";
119 | export * from "./buildAuthenticatedRouter.js";
120 | export * from "./convertRoutes.js";
121 | export * from "./errors.js";
122 | export * from "./logger.js";
123 | export * from "./authentication/login.handler.js";
124 | export * from "./authentication/logout.handler.js";
125 | export * from "./authentication/protected-routes.handler.js";
126 |
--------------------------------------------------------------------------------
/src/authentication/login.handler.ts:
--------------------------------------------------------------------------------
1 | import AdminJS from "adminjs";
2 | import { Router } from "express";
3 |
4 | import type {
5 | AuthenticationContext,
6 | AuthenticationMaxRetriesOptions,
7 | AuthenticationOptions,
8 | } from "../types.js";
9 | import { INVALID_AUTH_CONFIG_ERROR, WrongArgumentError } from "../errors.js";
10 |
11 | const getLoginPath = (admin: AdminJS): string => {
12 | const { loginPath, rootPath } = admin.options;
13 | // since we are inside already namespaced router we have to replace login and logout routes that
14 | // they don't have rootUrl inside. So changing /admin/login to just /login.
15 | // but there is a case where user gives / as a root url and /login becomes `login`. We have to
16 | // fix it by adding / in front of the route
17 | const normalizedLoginPath = loginPath.replace(rootPath, "");
18 |
19 | return normalizedLoginPath.startsWith("/")
20 | ? normalizedLoginPath
21 | : `/${normalizedLoginPath}`;
22 | };
23 |
24 | class Retry {
25 | private static retriesContainer: Map = new Map();
26 | private lastRetry: Date | undefined;
27 | private retriesCount = 0;
28 |
29 | constructor(ip: string) {
30 | const existing = Retry.retriesContainer.get(ip);
31 | if (existing) {
32 | return existing;
33 | }
34 | Retry.retriesContainer.set(ip, this);
35 | }
36 |
37 | public canLogin(
38 | maxRetries: number | AuthenticationMaxRetriesOptions | undefined
39 | ): boolean {
40 | if (maxRetries === undefined) {
41 | return true;
42 | } else if (typeof maxRetries === "number") {
43 | maxRetries = {
44 | count: maxRetries,
45 | duration: 60,
46 | };
47 | } else if (maxRetries.count <= 0) {
48 | return true;
49 | }
50 | if (
51 | !this.lastRetry ||
52 | new Date().getTime() - this.lastRetry.getTime() >
53 | maxRetries.duration * 1000
54 | ) {
55 | this.lastRetry = new Date();
56 | this.retriesCount = 1;
57 | return true;
58 | } else {
59 | this.lastRetry = new Date();
60 | this.retriesCount++;
61 | return this.retriesCount <= maxRetries.count;
62 | }
63 | }
64 | }
65 |
66 | export const withLogin = (
67 | router: Router,
68 | admin: AdminJS,
69 | auth: AuthenticationOptions
70 | ): void => {
71 | const { rootPath } = admin.options;
72 | const loginPath = getLoginPath(admin);
73 |
74 | const { provider } = auth;
75 | const providerProps = provider?.getUiProps?.() ?? {};
76 |
77 | router.get(loginPath, async (req, res) => {
78 | const baseProps = {
79 | action: admin.options.loginPath,
80 | errorMessage: null,
81 | };
82 | const login = await admin.renderLogin({
83 | ...baseProps,
84 | ...providerProps,
85 | });
86 |
87 | return res.send(login);
88 | });
89 |
90 | router.post(loginPath, async (req, res, next) => {
91 | if (!new Retry(req.ip).canLogin(auth.maxRetries)) {
92 | const login = await admin.renderLogin({
93 | action: admin.options.loginPath,
94 | errorMessage: "tooManyRequests",
95 | ...providerProps,
96 | });
97 |
98 | return res.send(login);
99 | }
100 |
101 | const context: AuthenticationContext = { req, res };
102 |
103 | let adminUser;
104 | try {
105 | if (provider) {
106 | adminUser = await provider.handleLogin(
107 | {
108 | headers: req.headers,
109 | query: req.query,
110 | params: req.params,
111 | data: req.fields ?? {},
112 | },
113 | context
114 | );
115 | } else if (auth.authenticate) {
116 | const { email, password } = req.fields as {
117 | email: string;
118 | password: string;
119 | };
120 | // "auth.authenticate" must always be defined if "auth.provider" isn't
121 | adminUser = await auth.authenticate(email, password, context);
122 | } else {
123 | throw new WrongArgumentError(INVALID_AUTH_CONFIG_ERROR);
124 | }
125 | } catch (error) {
126 | const errorMessage = error.message || error.error || "invalidCredentials";
127 |
128 | const loginPage = await admin.renderLogin({
129 | action: admin.options.loginPath,
130 | errorMessage,
131 | ...providerProps,
132 | });
133 |
134 | return res.status(400).send(loginPage);
135 | }
136 |
137 | if (adminUser) {
138 | req.session.adminUser = adminUser;
139 | req.session.save((err) => {
140 | if (err) {
141 | return next(err);
142 | }
143 | if (req.session.redirectTo) {
144 | return res.redirect(302, req.session.redirectTo);
145 | } else {
146 | return res.redirect(302, rootPath);
147 | }
148 | });
149 | } else {
150 | const login = await admin.renderLogin({
151 | action: admin.options.loginPath,
152 | errorMessage: "invalidCredentials",
153 | ...providerProps,
154 | });
155 |
156 | return res.send(login);
157 | }
158 | });
159 | };
160 |
--------------------------------------------------------------------------------