# Agent-first open-source admin panel framework > Build robust and powerful agentic back-office panels for your projects while maintaining full control over the code. This file contains all documentation content in a single document following the llmstxt.org standard. ## Getting Started This page provides a step-by-step guide to quickly get started with AdminForth using the `adminforth` CLI. You will learn how to set up a new project using the `adminforth create-app` command and explore AdminForth’s fundamentals. > πŸ‘† For setup example without CLI check out [Hello World without CLI](./01-helloWorld.md) ## Prerequisites AdminForth requires **Node v20** or higher. If you’re on a different version, you can switch or install using: ```bash nvm install 20 nvm alias default 20 nvm use 20 ``` ## Creating an AdminForth Project The recommended way to get started with AdminForth is via the **`create-app`** CLI, which scaffolds a basic fully functional back-office application. Apart from boilerplate, it creates one resource for user management. There are two common setup paths: ### Path 1: Existing Database Use this path when you already have a database and your own schema or migrations. Pass your database URL with `--db`, or enter it when the CLI asks `Please specify the database URL to use`: ```bash npx adminforth create-app --app-name myadmin --db "postgresql://user:password@localhost:5432/dbname" ``` When you provide your own database URL, the CLI treats this as your own database. It does not create Prisma schema or Prisma migration scripts for that database. Instead, the generated project README contains the SQL or schema notes for adding the required `adminuser` table with your own migration tool. After the project is created, navigate into it and generate resources from your existing tables: ```bash cd myadmin npx adminforth resource ``` Resource files are needed for AdminForth to know about your tables and define how to work with them. Use `npx adminforth resource` again when you add new tables or change their schema. ### Path 2: New Database Use this path when you want AdminForth to scaffold a standalone app with a new local SQLite database. Omit `--db`, or accept the default `sqlite://.db.sqlite` value in the interactive prompt: ```bash npx adminforth create-app --app-name myadmin ``` Or omit all options to be prompted interactively: ```bash npx adminforth create-app ``` Once the project is created, navigate into its directory: ```bash cd myadmin # or any other name you provided ``` For the new database path, the CLI can scaffold Prisma files and migration scripts for the default SQLite database. CLI options: * **`--app-name`** - name for your project. Used in `package.json`, `index.ts` branding, etc. Default value: **`adminforth-app`**. * **`--db`** - database connection string. Currently PostgreSQL, MongoDB, SQLite, MySQL, Clickhouse and Qdrant (read only) are supported. Default value: **`sqlite://.db.sqlite`** > ☝️ Database Connection String format: > > Format is `://:@:/`. Examples: > > - SQLite β€” `sqlite://.db.sqlite`. If database not yet exists it will be created > - PostgreSQL β€” `postgresql://user:password@localhost:5432/dbname` > - MongoDB β€” `mongodb://localhost:27017/dbname` > - Clickhouse β€” `clickhouse://localhost:8123/dbname` > - MySQL β€” `mysql://user:password@localhost:3306/dbname` > - Qdrant - `qdrant://localhost:6333` ### Understand the generated Project Structure The CLI will create boilerplate files and folders in your current directory and install dependencies. A typical layout looks like this: ```text myadmin/ β”œβ”€β”€ custom β”‚ β”œβ”€β”€ assets/ # Static assets like images, fonts, etc. β”‚ β”œβ”€β”€ package.json # For any custom npm packages you will use in Vue files β”‚ └── tsconfig.json # Tsconfig for Vue project (adds completion for AdminForth core components) β”œβ”€β”€ resources β”‚ └── adminuser.ts # Example resource file for users management β”œβ”€β”€ schema.prisma # Prisma schema file, generated only for the new database path β”œβ”€β”€ index.ts # Main entry point: configures AdminForth & starts the server β”œβ”€β”€ package.json # Project dependencies β”œβ”€β”€ pnpm-workspace.yaml β”œβ”€β”€ tsconfig.json # TypeScript configuration β”œβ”€β”€ .env # Env vars like tokens, secrets that should not be in version control β”œβ”€β”€ .env.local # General local environment variables └── .gitignore ``` ### Initial Migration & Future Migrations For the new database path, the CLI creates Prisma files for managing migrations. Prisma is not required by AdminForth itself, but it is a convenient migration tool for standalone projects that do not have database management yet. CLI will suggest you a command to initialize the database with Prisma: ```bash pnpm makemigration --name init && pnpm migrate:local ``` This will create a migration file and apply it to the database. In future, when you need to add new resources, you need to modify `schema.prisma` (add models, change fields, etc.). After doing any modification you need to create a new migration using next command: ```bash pnpm makemigration --name init ; pnpm migrate:local ``` Other developers need to pull migration and run `pnpm migrate:local` to apply any unapplied migrations. For the existing database path, use your own migration tool instead. The generated project README shows how to add the required `adminuser` table to your database. ## Run the Server Now you can run your app: ```bash pnpm start ``` Open http://localhost:3500 in your browser and (default credentials are `adminforth`/`adminforth` if you haven’t changed them). ![alt text](localhost_3500_login.png) ## AdminForth Basic Philosophy AdminForth connects to existing databases and provides a back-office for managing data including CRUD operations, filtering, sorting, and more. Database can be already created by using any database management tool, ORM or migrator. AdminForth itself never modifies database schema, does not add columns or new tables. However for those who have no own migration managment AdminForth CLI suggests using Prisma. This allows to provide simple and reliable schema management for standalone projects which have no DB yet. If you already have a database, you pass a connection string to AdminForth and define resources(tables) and describe columns you would like to see in back-office. For most DBs AdminForth can "discover" column types and constraints (e.g. max-length) by connecting to DB. However you can redefine them in AdminForth configuration. Type and constraints definition in AdminForth resource are take precedence over DB schema. Also in AdminForth you can define in "Vue" way: * how each field will be rendered * create own pages e.g. Dashboard using AdminForth Components Library (AFCL) or any other Vue componetns. * insert injections into standard pages (e.g. add diagram to list view) ## Adding an `apartments` Model So far, our freshly generated AdminForth project includes a default `adminuser` model and a corresponding `adminuser` resource. Let’s expand our app to suport managment of **`apartments`** model. Adding new resource will involve next steps: 1. **Add a new Prisma model** to your `schema.prisma`. 2. **Run a Prisma migration** to update your database schema. 3. **Create a corresponding resource** in the `resources/` folder. 4. **Register the new resource** in `index.ts` and see it in your AdminForth back-office. Please note that steps 1 and 2 are compleatly independent from 3 and 4, so you can make them with any other way then Prisma. ### Step 1. Define the `apartments` Model in `schema.prisma` Open `schema.prisma` in your project root and add a new model for `apartments`: ```prisma title="./schema.prisma" ... //diff-add model apartments { //diff-add id String @id //diff-add created_at DateTime? //diff-add title String //diff-add square_meter Float? //diff-add price Decimal //diff-add number_of_rooms Int? //diff-add description String? //diff-add country String? //diff-add listed Boolean //diff-add realtor_id String? //diff-add } ``` ### Step 2. Create and Apply the Migration Run the following command to create a new migration: ```bash pnpm makemigration --name add-apartments ; pnpm migrate:local ``` ### Step3. Create the `apartments` resource Use command to create a new file `apartments.ts` in the `resources/` folder ```bash npx adminforth resource ``` After the resource file is generated, extend it with display and validation settings. - Use recordLabel to control how each record is represented in lists and relations. - Apply fillOnCreate to automatically populate fields during creation (e.g., generated IDs or timestamps). - Add minLength and maxLength to string fields to enforce input constraints. - Use enum to limit fields to predefined values and render them as dropdowns in the UI. - Configure allowedActions to control which operations are available for the resource (editing, deleting, viewing, and filtering). To properly apply these changes, refer to the example below and adjust the configuration according to your settings ```ts title="./resources/apartments.ts" import { AdminForthResourceInput, AdminForthDataTypes } from 'adminforth'; export default { dataSource: 'maindb', table: 'apartments', //diff-remove resourceId: 'apartments' //diff-add resourceId: 'aparts', // resourceId is defaulted to table name but you can redefine it like this e.g. //diff-add // in case of same table names from different data sources label: 'Apartments', // label is defaulted to table name but you can change it //diff-add recordLabel: (r) => `🏑 ${r.title}`, columns: [ { name: 'id', //diff-add type: AdminForthDataTypes.STRING, //diff-add label: 'Identifier', // if you wish you can redefine label, defaulted to uppercased name showIn: { // show column in filter and in show page //diff-remove all:true, //diff-add list: false, //diff-add edit: false, //diff-add create: false, }, //diff-add primaryKey: true, //diff-add fillOnCreate: ({ initialRecord, adminUser }) => Math.random().toString(36).substring(7), // called during creation to generate content of field, initialRecord is values user entered, adminUser object of user who creates record }, { name: "title", //diff-add required: true, showIn: { all:true, // all available options }, //diff-add type: AdminForthDataTypes.STRING, //diff-add maxLength: 255, // you can set max length for string fields //diff-add minLength: 3, // you can set min length for string fields }, { name: 'created_at', //diff-add type: AdminForthDataTypes.DATETIME, //diff-add allowMinMaxQuery: true, showIn: { //diff-remove all:true, //diff-add create: false, }, //diff-add fillOnCreate: ({ initialRecord, adminUser }) => (new Date()).toISOString(), }, { name: 'price', showIn: { all:true, }, //diff-add inputSuffix: 'USD', // you can add a suffix to an input field that will be displayed when creating or editing records //diff-add allowMinMaxQuery: true, // use better experience for filtering e.g. date range, set it only if you have index on this column or if you sure there will be low number of rows //diff-add editingNote: 'Price is in USD', // you can put a note near field on editing or creating page }, { name: 'square_meter', //diff-add label: 'Square', //diff-add allowMinMaxQuery: true, showIn: { all:true, }, //diff-add minValue: 1, // you can set min /max value for number columns so users will not be able to enter more/less //diff-add maxValue: 1000, }, { name: 'number_of_rooms', //diff-add allowMinMaxQuery: true, showIn: { all:true, }, //diff-add enum: [ //diff-add { value: 1, label: '1 room' }, //diff-add { value: 2, label: '2 rooms' }, //diff-add { value: 3, label: '3 rooms' }, //diff-add { value: 4, label: '4 rooms' }, //diff-add { value: 5, label: '5 rooms' }, //diff-add ], }, { name: 'description', //diff-add sortable: false, showIn: { //diff-remove all:true, //diff-add list: false, } }, { name: 'country', showIn: { all:true, }, //diff-add enum: [{ //diff-add value: 'US', //diff-add label: 'United States' //diff-add }, { //diff-add value: 'DE', //diff-add label: 'Germany' //diff-add }, { //diff-add value: 'FR', //diff-add label: 'France' //diff-add }, { //diff-add value: 'GB', //diff-add label: 'United Kingdom' //diff-add }, { //diff-add value: 'NL', //diff-add label: 'Netherlands' //diff-add }, { //diff-add value: 'IT', //diff-add label: 'Italy' //diff-add }, { //diff-add value: 'ES', //diff-add label: 'Spain' //diff-add }, { //diff-add value: 'DK', //diff-add label: 'Denmark' //diff-add }, { //diff-add value: 'PL', //diff-add label: 'Poland' //diff-add }, { //diff-add value: 'UA', //diff-add label: 'Ukraine' //diff-add }, { //diff-add value: null, //diff-add label: 'Not defined' //diff-add }], }, { name: 'listed', //diff-add required: true, // will be required on create/edit showIn: { all:true, } }, { name: 'realtor_id', //diff-add foreignResource: { //diff-add resourceId: 'adminuser', //diff-add searchableFields: ["id", "email"], // fields available for search in filter //diff-add }, showIn: { all:true, } } ], options: { listPageSize: 10, //diff-add allowedActions: { //diff-add edit: true, //diff-add delete: true, //diff-add show: true, //diff-add filter: true, //diff-add }, }, } as AdminForthResourceInput; ``` ### Step 4. Register the `apartments` Resource Open `index.ts` in your project root and import the new resource: ```ts title="./index.ts" ... //diff-add import apartmentsResource from "./resources/apartments.js"; ... export const admin = new AdminForth({ ... menu: [ //diff-add { //diff-add label: 'Core', //diff-add icon: 'flowbite:brain-solid', //diff-add open: true, //diff-add children: [ //diff-add { //diff-add homepage: true, //diff-add label: 'Apartments', //diff-add icon: 'flowbite:home-solid', //diff-add resourceId: 'aparts', //diff-add }, //diff-add ] //diff-add }, //diff-add { type: 'gap' }, //diff-add { type: 'divider' }, { type: 'heading', label: 'SYSTEM' }, { label: 'Users', icon: 'flowbite:user-solid', resourceId: 'adminuser' }, { label: "Apartments", icon: "flowbite:user-solid", //diff-remove resourceId: "apartments", //diff-add resourceId: "aparts", }, ], ... }); ``` ## Generating fake appartments ```ts title="./index.ts" //diff-add async function seedDatabase() { //diff-add if (await admin.resource('aparts').count() > 0) { //diff-add return //diff-add } //diff-add for (let i = 0; i < 100; i++) { //diff-add await admin.resource('aparts').create({ //diff-add id: `${i}`, //diff-add title: `Apartment ${i}`, //diff-add square_meter: Number((Math.random() * 100).toFixed(1)), //diff-add price: (Math.random() * 10000).toFixed(2), //diff-add number_of_rooms: Math.floor(Math.random() * 4) + 1, //diff-add description: 'Next gen apartments', //diff-add created_at: (new Date(Date.now() - Math.random() * 60 * 60 * 24 * 14 * 1000)).toISOString(), //diff-add listed: i % 2 == 0, //diff-add country: `${['US', 'DE', 'FR', 'GB', 'NL', 'IT', 'ES', 'DK', 'PL', 'UA'][Math.floor(Math.random() * 10)]}` //diff-add }); //diff-add }; //diff-add }; if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { ... admin.discoverDatabases().then(async () => { if (!await admin.resource('adminuser').get([Filters.EQ('email', 'adminforth')])) { await admin.resource('adminuser').create({ email: 'adminforth', password_hash: await AdminForth.Utils.generatePasswordHash('adminforth'), role: 'superadmin', }); } //diff-add await seedDatabase(); }); ``` This will create records during first launch. Now you should see: ![alt text](localhost_3500_resource_aparts.png) Feel free to play with the data, add more fields, and customize the UI to your liking. --- ## Hello world app without CLI While AdminForth CLI is the fastest way to create a new project, you can also create a new project manually. This might help you better understand how AdminForth project is structured and how to customize it. Here we create database with users and posts tables and admin panel for it. Users table will be used to store a credentials for login into admin panel itself. When back-office user creates a new post it will be automatically assigned using `authorId` to the user who created it. ## Prerequisites We will use Node v20 for this demo. If you have other Node versions, we recommend using [NVM](https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script) to switch them easily: ```bash nvm install 20 nvm alias default 20 nvm use 20 ``` ## Installation ```bash mkdir af-hello cd af-hello pnpm init pnpm add adminforth express@^4 @dotenvx/dotenvx @types/express typescript tsx @types/node prisma @prisma/client -D npx --yes tsc --init --module NodeNext --target ESNext ``` ## Environment variables Create two files in your project's root directory: - `.env.local` β€” Place your non-sensitive environment variables here (e.g., local database paths, default configurations). This file can be safely committed to your repository as a demo or template configuration. - `.env` β€” Store sensitive tokens and secrets here (for example, `ADMINFORTH_SECRET` and other private keys). Ensure that `.env` is added to your `.gitignore` to prevent accidentally committing sensitive data. Put the following content to the `.env.local` file: ```bash title="./.env.local" ADMINFORTH_SECRET=123 NODE_ENV=development DATABASE_URL=sqlite://.db.sqlite PRISMA_DATABASE_URL=file:.db.sqlite ``` > ☝️ Production best practices: > > 1) Most likely you not need `.env` file at all, instead you should use environment variables (from Docker, Kubernetes, Operating System, etc.) > 2) Set `NODE_ENV` to `production` in your deployment environment to optimize performance and disable development features like hot reloading. > 3) You should generate very unique value `ADMINFORTH_SECRET` and store it in Vault or other secure place. ## Setting up the scripts Open `package.json` and add the following scripts: ```json title="./package.json" { ... //diff-add "type": "module", "scripts": { ... //diff-add "dev": "pnpm _env:dev tsx watch index.ts", //diff-add "prod": "pnpm _env:prod tsx index.ts", //diff-add "start": "pnpm dev", //diff-add "makemigration": "pnpm _env:dev npx --yes prisma migrate dev --create-only", //diff-add "migrate:local": "pnpm _env:dev npx --yes prisma migrate deploy", //diff-add "migrate:prod": "pnpm _env:prod npx --yes prisma migrate deploy", //diff-add "_env:dev": "dotenvx run -f .env -f .env.local --", //diff-add "_env:prod": "dotenvx run -f .env.prod --" }, //diff-add "engines": { //diff-add "author": "", //diff-add "node": ">=20" //diff-add }, } ``` Create `./pnpm-workspace.yaml` and put next content there: ```json title="./pnpm-workspace.yaml" onlyBuiltDependencies: - better-sqlite3 ``` Run installation command to apply all dependencies ```bash pnpm install ``` ## Setting up AdminForth Create `index.ts` file in root directory with following content: ```ts title="./index.ts" import express from 'express'; import AdminForth from 'adminforth'; import usersResource from "./resources/adminuser.js"; import { fileURLToPath } from 'url'; import path from 'path'; import { Filters } from 'adminforth'; import { initApi } from './api.js'; import { logger } from 'adminforth'; const ADMIN_BASE_URL = ''; export const admin = new AdminForth({ baseUrl: ADMIN_BASE_URL, auth: { usersResourceId: 'adminuser', usernameField: 'email', passwordHashField: 'password_hash', rememberMeDuration: '30d', loginBackgroundImage: 'https://images.unsplash.com/photo-1534239697798-120952b76f2b?q=80&w=3389&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', loginBackgroundPosition: '1/2', loginPromptHTML: async () => { const adminforthUserExists = await admin.resource("adminuser").count(Filters.EQ('email', 'adminforth')) > 0; if (adminforthUserExists) { return "Please use adminforth as username and adminforth as password" } }, }, customization: { brandName: "myadmin", title: "myadmin", datesFormat: 'DD MMM', timeFormat: 'HH:mm a', showBrandNameInSidebar: true, showBrandLogoInSidebar: true, emptyFieldPlaceholder: '-', styles: { colors: { light: { primary: '#1a56db', sidebar: { main: '#f9fafb', text: '#213045' }, }, dark: { primary: '#82ACFF', sidebar: { main: '#1f2937', text: '#9ca3af' }, } } }, }, dataSources: [ { id: 'maindb', url: `${process.env.DATABASE_URL}` }, ], resources: [ usersResource ], menu: [ { type: 'heading', label: 'SYSTEM' }, { label: 'Users', icon: 'flowbite:user-solid', resourceId: 'adminuser' } ], }); if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { const app = express(); app.use(express.json()); initApi(app, admin); const port = 3500; admin.bundleNow({ hotReload: process.env.NODE_ENV === 'development' }).then(() => { logger.info('Bundling AdminForth SPA done.'); }); admin.express.serve(app); admin.discoverDatabases().then(async () => { if (await admin.resource('adminuser').count() === 0) { await admin.resource('adminuser').create({ email: 'adminforth', password_hash: await AdminForth.Utils.generatePasswordHash('adminforth'), role: 'superadmin', }); } }); admin.express.listen(port, () => { logger.info(`\x1b[38;5;249m ⚑ AdminForth is available at\x1b[1m\x1b[38;5;46m http://localhost:${port}${ADMIN_BASE_URL}\x1b[0m\n`); }); } ``` > ☝️ For simplicity we defined whole configuration in one file. Normally once configuration grows you should > move each resource configuration to separate file and organize them to folder and import them in `index.ts`. Create `api.ts` file in root directory with following content: ```ts title="./api.ts" import { Express, Response } from "express"; import { IAdminForth, IAdminUserExpressRequest } from "adminforth"; import * as z from "zod"; export function initApi(app: Express, admin: IAdminForth) { app.get(`${admin.config.baseUrl}/api/hello/`, admin.express.withSchema( { description: "Returns example data from a custom Express API together with the current authenticated AdminForth user.", response: z.object({ message: z.string(), users: z.array(z.record(z.string(), z.unknown())), adminUser: z.record(z.string(), z.unknown()), }), }, // you can use data API to work with your database https://adminforth.dev/docs/tutorial/Customization/dataApi/ // and admin.express.authorize to inject req.adminUser admin.express.authorize( async (req: IAdminUserExpressRequest, res: Response) => { const allUsers = await admin.resource("adminuser").list([]); res.json({ message: "Hello from AdminForth API!", users: allUsers, adminUser: req.adminUser, }); } ) ) ); } ``` >Install and import Zod before using this pattern: `pnpm add zod` or `npm install zod`, then `import * as z from 'zod';`. `admin.express.withSchema(...)` will convert the Zod schema to OpenAPI for you. Update `tsconfig.json` file in root directory with following content: ```ts title="./tsconfig.json" { "compilerOptions": { "target": "esnext", "module": "nodenext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true }, "exclude": ["node_modules", "dist"] } ``` Create new directory `./resources/adminuser.ts` with following content: ```ts title="./resources/adminuser.ts" import AdminForth, { AdminForthDataTypes } from 'adminforth'; import type { AdminForthResourceInput, AdminForthResource, AdminUser } from 'adminforth'; import { randomUUID } from 'crypto'; import { logger } from 'adminforth'; async function allowedForSuperAdmin({ adminUser }: { adminUser: AdminUser }): Promise { return adminUser.dbUser.role === 'superadmin'; } export default { dataSource: 'maindb', table: 'adminuser', resourceId: 'adminuser', label: 'Admin Users', recordLabel: (r) => `πŸ‘€ ${r.email}`, options: { allowedActions: { edit: allowedForSuperAdmin, delete: allowedForSuperAdmin, }, }, columns: [ { name: 'id', primaryKey: true, type: AdminForthDataTypes.STRING, fillOnCreate: ({ initialRecord, adminUser }) => randomUUID(), showIn: { edit: false, create: false, }, }, { name: 'email', required: true, isUnique: true, type: AdminForthDataTypes.STRING, validation: [ // you can also use AdminForth.Utils.EMAIL_VALIDATOR which is alias to this object { regExp: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', message: 'Email is not valid, must be in format example@test.com' }, ] }, { name: 'created_at', type: AdminForthDataTypes.DATETIME, showIn: { edit: false, create: false, }, fillOnCreate: ({ initialRecord, adminUser }) => (new Date()).toISOString(), }, { name: 'role', type: AdminForthDataTypes.STRING, enum: [ { value: 'superadmin', label: 'Super Admin' }, { value: 'user', label: 'User' }, ] }, { name: 'password', virtual: true, // field will not be persisted into db required: { create: true }, // make required only on create page editingNote: { edit: 'Leave empty to keep password unchanged' }, type: AdminForthDataTypes.STRING, showIn: { // to show field only on create and edit pages show: false, list: false, filter: false, }, masked: true, // to show stars in input field minLength: 8, validation: [ // request to have at least 1 digit, 1 upper case, 1 lower case AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM, ], }, { name: 'password_hash', type: AdminForthDataTypes.STRING, backendOnly: true, showIn: { all: false } } ], hooks: { create: { beforeSave: async ({ record, adminUser, resource }: { record: any, adminUser: AdminUser, resource: AdminForthResource }) => { record.password_hash = await AdminForth.Utils.generatePasswordHash(record.password); return { ok: true }; } }, edit: { beforeSave: async ({ oldRecord, updates, adminUser, resource }: { oldRecord: any, updates: any, adminUser: AdminUser, resource: AdminForthResource }) => { logger.info(`Updating user, ${updates}`); if (oldRecord.id === adminUser.dbUser.id && updates.role) { return { ok: false, error: 'You cannot change your own role' }; } if (updates.password) { updates.password_hash = await AdminForth.Utils.generatePasswordHash(updates.password); } return { ok: true } }, }, }, } as AdminForthResourceInput; ``` Create new directory `./custom/tsconfig.json` with following content: ```ts title="./custom/tsconfig.json" { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["../node_modules/adminforth/dist/spa/src/*"], "@@/*": ["./*"] } } } ``` ## Database creation > ☝️ For demo purposes we will create a database using Prisma and SQLite. > You can also create it using any other favorite tool or ORM and skip this step. Create `./schema.prisma` and put next content there: ```text title="./schema.prisma" generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" } model adminuser { id String @id email String @unique password_hash String role String created_at DateTime } ``` Create `./prisma.config.ts` and put next content there: ```ts title="./prisma.config.ts" import 'dotenv/config' import { defineConfig, env } from 'prisma/config' export default defineConfig({ datasource: { url: env('PRISMA_DATABASE_URL'), }, }) ``` Create database using `prisma migrate`: ```bash pnpm run makemigration --name init ; pnpm run migrate:local ``` Now you can run your app: ```bash pnpm start ``` Open http://localhost:3500 in your browser and login with credentials `adminforth` / `adminforth`. ![alt text](localhost_3500_login.png) ## Initializing custom directory If you are not using CLI, you can create `custom` directory and initialize it with `npm`: ```bash cd ./custom npm init -y ``` We will use this directory for all custom components. If you want to call your dir with other name then `custom`, just set [customComponentsDir option](/docs/api/Back/interfaces/AdminForthConfigCustomization/#customcomponentsdir) Also, for better development experience we recommend to create file `custom/tsconfig.json` with the following content: ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": [ "../node_modules/adminforth/dist/spa/src/*" ], "*": [ "../node_modules/adminforth/dist/spa/node_modules/*" ], "@@/*": [ "." ] } } } ``` ## Possible configuration options Check [AdminForthConfig](/docs/api/Back/interfaces/AdminForthConfig.md) for all possible options. --- ## Glossary ## dataSource A DataSource is a connection to one database. Datasources have id for references from resources and URL which follows the standard URI format. For example `mysql://user:password@localhost:3306/database`. It used to: * Discover the columns in the database * Make queries to get the list and show records * Make queries to modify data There might be several datasources in the system for various databases e.g. One datasource to Mongo DBs and one to Postgres DB. ### connectionRecovery For PostgreSQL datasources AdminForth keeps a connection pool. The optional `connectionRecovery` flag controls how the connector reacts when that connection drops (DB restart, failover, network blip, etc.): ```ts dataSources: [ { id: 'maindb', url: `${process.env.DATABASE_URL}`, connectionRecovery: true, // default }, ], ``` - `true` (default, recommended) β€” **self-heal mode.** The pool recovers automatically: a dead idle connection is dropped and a fresh one is transparently opened on the next query, so the app keeps working without a manual restart. Queries that were in-flight at the moment of the outage will fail, but subsequent queries succeed once the database is back. - `false` β€” **legacy mode.** On a connection error the pool is destroyed and recreated after 1 second. If the outage outlasts that retry, the app can be left with a permanently dead pool and require a manual restart. Kept only for backward compatibility. This flag is currently honored by the PostgreSQL connector; other connectors rely on their driver's built-in recovery. ## resource A [Resource](/docs/api/Back/interfaces/AdminForthResource.md) is a AdminForth representation of a table or collection in database. One resource is one table in the database. Resource has `table` property which should be equal to the name of the table in the database. It has a datasource id to point to database, a definition of list of columns and various customization options. ## column A [Column](/docs/api/Back/interfaces/AdminForthResourceColumn.md) is a representation of a column in a table. It has a `name` which should be equal to name in database and various configuration options. ## record A record is a row in a relational database table. Or Document in document database table. ## action Action is one of operations which can be performed on the resource or it's records. There are next [actions](/docs/api/Common/enumerations/AllowedActionsEnum.md): * create * edit * delete * list * show * filter ## adminUser [Object](/docs/api/Common/interfaces/AdminUser) which represents a user who logged in to the AdminForth. ## hook Hook is a optional async function which allows to inject in backend logic before executing the datasource query or after it. Hooks exist for all database queries including data read queries like list, show, and data write queries like create, edit, delete. All AdminForth hooks are executed on the backend side only. ## allowedAction Static boolean value or async function which returns boolean and defines whether the action is allowed for the user. allowedAction checked before any hooks or datasource queries: this means that if your allowed action function returned false you can be sure that user attempt to perform the action or get the data will be strictly prohibited on backend side. ## component Component is a Vue frontend component which is used to add or modify UI elements in AdminForth. It can be used as a full custom page with a link in menu or as a part of the existing AdminForth page ## field Same to column, but considered in context of record. ## Plugin Plugin is a class defined to extend AdminForth functionality. Plugin philosophy is to simply modify AdminForth config after it is defined by user. In other words, everything that could be done in config, can be done in plugin and vice versa. In same way, like config does it, plugins set own Frontend components and backend hooks to modify AdminForth behavior. The main difference is that plugin allows to simplify routine repeating tasks and reduce the amount of code in the config file and code of cusomtom components. --- ## Branding and Theming The first things you would probably like to change are the logo, favicon and the name of the admin application. You can place your logo and favicon files into the `custom` directory e.g. replacing existing default `logo.svg` and `favicon.png` files. Then you can change the branding of the application in the configuration: ```ts title='./index.ts' const admin = new AdminForth({ ... customization: { //diff-remove brandName: "myadmin", //diff-add brandName: 'My App', // used in login page and sidebar //diff-remove title: "myadmin", //diff-add title: 'My App Admin', // used to set HTML title tag brandLogo: '@@/assets/logo.svg', // replace with your images in custom/assets directory favicon: '@@/assets/favicon.png', }, ... }); ``` Please note that `@@/` is a special prefix which tells AdminForth to look for the file in the `custom` directory. You can use `@@/` prefix for all paths in the configuration and also import images like this in your custom components e.g.: ```ts ``` ## Removing brand name from sidebar If you are using logo image which has branded title inside, you might want completely remove default text brand name from sidebar: ```ts title='./index.ts' brandName: 'My App', //diff-add showBrandNameInSidebar: false, ``` `brandName` will still be used in the other places e.g. login form. ## Removing brand logo from sidebar If you want to hide the logo image in the sidebar while keeping the brand name text (or vice versa), disable the logo with: ```ts title='./index.ts' brandName: 'My App', //diff-add showBrandLogoInSidebar: false, ``` By default, the logo is shown when available. This flag controls the sidebar logo only and does not affect the login page. ## Theming AdminForth uses TailwindCSS for styling. You are able to customize the look of the application by changing the TailwindCSS configuration. Use [styles.ts](https://github.com/devforth/adminforth/blob/main/adminforth/modules/styles.ts) file to see which variables are available for change. Let's say your brand has a primary purple color and you wish to make side bar purple with white text. In `index.ts` file set the `styles` property in the configuration: ```ts title='./index.ts' const admin = new AdminForth({ ... customization: { styles: { colors: { light: { //diff-add // color for links, icons etc. //diff-remove primary: '#1a56db', //diff-add primary: '#b400b8', //diff-add // color for sidebar and text //diff-remove sidebar: { main: '#f9fafb', text: '#213045' }, //diff-add sidebar: { main:'#571e58', text: 'white'}, }, } } }, ... }); ``` Here is how it looks: ![AdminForth Themes](image-10.png) ## Single theme If you want to enforce a consistent theme and disable the theme switcher, you can configure AdminForth to use only one theme variant. ```ts title='./index.ts' const admin = new AdminForth({ ... customization: { //diff-add singleTheme: "light", styles: { ... } } }, ... }); ``` ## Icon only sidebar AdminForth supports a collapsible sidebar that can be toggled between a full-width view (showing labels) and an icon-only view (showing only icons). ```ts title='./index.ts' const admin = new AdminForth({ ... customization: { //diff-add iconOnlySidebar: { //diff-add enabled: true, // Optional: Enable the collapsible icon-only sidebar feature //diff-add logo: '@@/logo.svg', // Optional: Custom logo to display in icon-only mode //diff-add expandedSidebarWidth: '18.5rem', // Optional: sets the expanded sidebar width, defaults to 16.5rem //diff-add }, }, ... }); ``` ## Square vs rounded buttons? Not an issue, just change: ```ts title='./index.ts' styles: { //diff-add borderRadius: { //diff-add "default": "0px" //diff-add } } ``` ## Login background To make login interface less boring background image matters. For example you might want to get [free sweet background](https://unsplash.com/s/photos/secure?license=free) from Unsplash like [Nate Watson's apartments view](https://images.unsplash.com/photo-1516501312919-d0cb0b7b60b8?q=80&w=3404&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D). Download it to `custom` directory, and just set it in the configuration: ```ts title='./index.ts' const admin = new AdminForth({ ... auth: { ... //diff-add loginBackgroundImage: '@@/photo-1516501312919-d0cb0b7b60b8.jpeg', }, ... }); ``` Here is how it looks: ![AdminForth Login Background](demo_adminforth_login.png) You can also set background position and size: ```ts title='./index.ts' auth: { ... loginBackgroundImage: '@@/photo-1516501312919-d0cb0b7b60b8.jpeg', //diff-add loginBackgroundPosition: 'over', } ``` `loginBackgroundPosition` accepts values: - `over` - image will be over the whole login page with cover mode - `1/2`(default), `3/4`, `2/5`, `3/5` etc. - image will be in the left side of the login page with cover mode ### Disabling background blend mode When using `loginBackgroundPosition: 'over'`, AdminForth applies a background blend mode by default to ensure text readability over the background image. If you want to disable this blend mode and display the background image without any overlay effects, you can add: ```ts title='./index.ts' auth: { ... loginBackgroundImage: '@@/photo-1516501312919-d0cb0b7b60b8.jpeg', loginBackgroundPosition: 'over', //diff-add removeBackgroundBlendMode: true, } ``` ## Custom items in html head If you want to add custom elements to the HTML head, you can define them in the configuration: ```ts title='./index.ts' customization: { customHeadItems: [ { tagName: 'link', attributes: { rel: 'stylesheet', href: 'https://example.com/custom.css' } }, { tagName: 'script', attributes: { src: 'https://example.com/custom.js', defer: true } }, { tagName: 'meta', attributes: { name: 'theme-color', content: ' #000000' } } ] } ``` --- ## Custom record field rendering ## Customizing how AdminForth renders the cells with record values Let's change how AdminForth renders the number of rooms in the 'list' and 'show' views. We will render '🟨' for each room and then we will print `square_meter` at the same cells. Create directory `custom`. Create a file `RoomsCell.vue` in it: ```html title='./custom/RoomsCell.vue' ``` Now you can use this component in the configuration of the resource: ```ts title='./resources/apartments.ts' { ... resourceId: 'aparts', columns: [ ... { ... name: 'number_of_rooms', //diff-add components: { //diff-add show: '@@/RoomsCell.vue', //diff-add list: '@@/RoomsCell.vue', //diff-add } }, ... ], ... } ``` Here is how it looks: ![alt text]() In very similar way you can render how cell is rendered in `'edit'` and `'create'` view. You can use it for creating custom editors for the fields. Check [component specs](/docs/api/Common/interfaces/AdminForthFieldComponents#create) to understand which props are passed to the component ## Parametrize the custom components Sometimes you need to render same component with different parameters. You can use [full component declaration](/docs/api/Common/interfaces/AdminForthComponentDeclarationFull) ```ts title='./resources/apartments.ts' { ... resourceId: 'aparts', columns: [ ... { ... name: 'number_of_rooms', components: { //diff-remove show: '@@/RoomsCell.vue', //diff-add show: { //diff-add file: '@@/RoomsCell.vue', //diff-add meta: { //diff-add filler: '🟨', //diff-add }, //diff-add }, //diff-remove list: '@@/RoomsCell.vue', //diff-add list: { //diff-add file: '@@/RoomsCell.vue', //diff-add meta: { //diff-add filler: '🟦', //diff-add }, //diff-add } } }, ... ], ... } ``` Now our component can read `filler` from `meta` prop: ```ts title='./custom/RoomsCell.vue' ``` ## Using 3rd-party npm packages in the Vue components To install 3rd-party npm packages you should create npm package in the `custom` directory: ```bash cd custom ``` And simply do `pnpm install` for the package you need: ```bash pnpm i -D ``` ## Editing values component In same way as we define `show` and list component, we can create component for edit/create page. Let's create custom dropdown for `country` field which will show emoji flags of the countries. ```html title='./custom/CountryDropdown.vue' ``` Now you can use this component in the configuration of the resource: ```ts title='./resources/apartments.ts' { ... resourceId: 'aparts', columns: [ ... { name: 'country', //diff-add components: { //diff-add edit: '@@/CountryDropdown.vue', //diff-add create: '@@/CountryDropdown.vue', //diff-add }, ... }, ... ], ... } ``` ### Custom record editing (updating other fields) Sometimes a custom editor needs to update not only its own field, but also other fields of the record (for example, generate a slug from a title). For this, custom `edit`/`create` components can emit an `update:recordFieldValue` event with the payload `{ fieldName, fieldValue }`. AdminForth will update the corresponding field in the record. > If you emit `update:recordFieldValue` to modify a field which is hidden by `showIn.create: false` / `showIn.edit: false`, the backend will reject the request by default. > To allow this, set the target column config to `allowModifyWhenNotShowInCreate: true` and/or `allowModifyWhenNotShowInEdit: true`. ```html title='./custom/TitleWithSlugEditor.vue' ``` And use it in the resource configuration for both `edit` and `create` views: ```ts title='./resources/apartments.ts' { ... resourceId: 'aparts', columns: [ ... { name: 'title', components: { edit: '@@/TitleWithSlugEditor.vue', create: '@@/TitleWithSlugEditor.vue', }, }, { name: 'slug', // standard input; value will be kept in sync ... }, ... ], ... } ``` ### Custom inValidity inside of the custom create/edit components Custom componets can emit `update:inValidity` event to parent to say that the field is invalid. You can define this emit as: ```ts title='./custom/.vue' const emit = defineEmits([ "update:value", //diff-add "update:inValidity" ]); ``` Every time when state in your component becomes invalid, you can emit this event with error message which will be shown in the UI to the user. ```ts emit('update:inValidity', "The field has wrong value"); ``` Every time when state in your component becomes valid, you can emit this event with `false` ```ts emit('update:inValidity', false); ``` If component never emits `update:inValidity` event (includign case when you don't use it at all), the field is considered valid. ### Custom emptiness inside of the custom create/edit components Custom componets can emit `update:emptiness` event to parent to say that the field is empty. Emptiness is used to prevent user from saving form when `column.required` is true and field is empty. When `column.required` is false emptiness is not checked. You can define this emit as: ```ts const emit = defineEmits([ "update:value", //diff-add "update:emptiness" ]); ``` Every time when state in your component becomes empty, you can emit this event with `true` ```ts emit('update:emptiness', true); ``` Every time when state in your component becomes not empty, you can emit this event with `false` ```ts emit('update:emptiness', false); ``` Emptiness emit has a higher priority than natural emptiness of the field. For example when actual value under column in record is empty but component emitted `false` for `update:emptiness` (in other words child component said it non-empty), the field is considered as Non-empty. For another example, if companent is naturally updated some value in record but emited `true` (said that it is empty) the field is considered as empty and error in form will be shown to user. ## Pre-made renderers Though creating custom renderers is super-easy, we have couple of pre-made renderers for you to use. ### CompactUUID If you have a UUID column which you want display in table in more compact manner, you can use `CompactUUID` renderer. ```ts title='./resources/apartments.ts' //diff-add import { randomUUID } from 'crypto'; ... columns: [ { name: 'id', primaryKey: true, showIn: { //diff-remove list: false, edit: false, create: false, }, //diff-remove fillOnCreate: ({ initialRecord, adminUser }) => Math.random().toString(36).substring(7), //diff-add fillOnCreate: ({initialRecord}: any) => randomUUID(), //diff-add components: { //diff-add list: '@/renderers/CompactUUID.vue' //diff-add } } ... ``` ![alt text]() ### Country Flag Renders string fields containing ISO-3166-1 alpha-2 country codes as flags (e.g. 'US', 'DE', 'FR', etc.) ```ts title='./resources/apartments.ts' columns: [ ... { name: 'country', //diff-add components: { //diff-add list: '@/renderers/CountryFlag.vue' //diff-add }, ... } ``` ![alt text]() You can also show country name after the flag: ```ts title='./resources/apartments.ts' columns: [ ... { name: 'country', //diff-add components: { //diff-add list: { //diff-add file: '@/renderers/CountryFlag.vue', //diff-add meta: { //diff-add showCountryName: true //diff-add } //diff-add } //diff-add }, ... } ``` ![alt text]() ### Human Number It formats large numbers into a human-readable format (e.g., 10k, 1.5M) and supports localization for different number formats. ```ts title='./resources/apartments.ts' columns: [ ... { name: 'square_meter', label: 'Square', minValue: 1, // you can set min /max value for number fields maxValue: 100000000, //diff-add components: { //diff-add list: { //diff-add file: '@/renderers/HumanNumber.vue', //diff-add } //diff-add } }, { ... ``` ![alt text]() ### URL If your field has absolute URLs as text strings you can use `URLs` renderer to render them as clickable links. ```ts title='./resources/anyResource.ts' columns: [ ... { name: 'url', //diff-add components: { //diff-add list: '@/renderers/URL.vue' //diff-add }, ... ``` ### Relative Time To format your date fields to display the elapsed time, you can utilize the RelativeTime renderer. ```ts title='./resources/anyResource.ts' columns: [ ... { name: 'created_at', //diff-add components: { //diff-add list: '@/renderers/RelativeTime.vue' //diff-add }, ... ``` ### Rich text and Zero-style Rich text If you have some field which holds HTML content you can use `RichText` renderer to render it as HTML. ```ts title='./resources/anyResource.ts' columns: [ ... { name: 'content', //diff-add components: { //diff-add list: '@/renderers/RichText.vue' //diff-add }, ... } ] ``` The renderer will render the HTML content and protect against XSS attacks. If HTML in field has some tags or classes which covered by adminforth internal styles (including Tailwind classes), they will be styled (text/p styles etc). If this is an issue for your task and you need full raw preview of the HTML, you can use `ZeroStyleRichText` renderer: ```ts title='./resources/anyResource.ts' //diff-remove list: '@/renderers/RichText.vue', //diff-add list: '@/renderers/ZeroStylesRichText.vue', //diff-add ``` `ZeroStyleRichText` fits well for tasks like email templates preview fields. ### Sensitive data blur For fields containing sensitive data (like passwords, API keys, tokens, or other confidential values), use the `SensitiveBlurCell` renderer. It blurs the value by default and reveals it on click. ```ts title='./resources/anyResource.ts' columns: [ ... { name: 'api_key', //diff-add components: { //diff-add show: '@/renderers/SensitiveBlurCell.vue', //diff-add list: '@/renderers/SensitiveBlurCell.vue', //diff-add }, ... ``` The renderer wraps the standard value output and adds a click-to-reveal blur effect. Clicking again hides the value. For long values (like API keys) you can enable compact mode by passing `compact: true` via `meta`. When set, the value is shortened the same way as the `CompactUUID` renderer (first 4 + `...` + last 4 characters). You can additionally pass `copy: true` to render a copy-to-clipboard button next to the value, and `eyeButton: true` to render an eye icon that also toggles the blur. `compact`, `copy` and `eyeButton` are independent of each other and can be combined in any way: ```ts title='./resources/anyResource.ts' columns: [ ... { name: 'api_key', components: { show: { //diff-add file: '@/renderers/SensitiveBlurCell.vue', //diff-add meta: { compact: true, copy: true, eyeButton: true }, }, list: { //diff-add file: '@/renderers/SensitiveBlurCell.vue', //diff-add meta: { compact: true, copy: true, eyeButton: true }, }, }, ... ``` ### Custom filter component for square meters Sometimes standard filters are not enough, and you want to make a convenient UI for selecting a range of apartment areas. For example, buttons with options for β€œSmall (<25 mΒ²)”, β€œMedium (25–90 mΒ²)” and β€œLarge (>90 mΒ²)”. ```ts title='./custom/SquareMetersFilter.vue' ``` ```ts title='./resources/apartments.ts' columns: [ ... { name: 'square_meter', label: 'Square', //diff-add components: { //diff-add filter: '@@/SquareMetersFilter.vue' //diff-add } }, ... ] --- ## Virtual columns ## Virtual column for show and list Sometimes you need to visualize custom columns which do not exist in database. For doing this you can use `virtual` columns. ```ts title='./resources/apartments.ts' //diff-add import { AdminForthResourcePages } from 'adminforth'; ... resourceId: 'aparts', columns: [ ... //diff-add { //diff-add name: 'Country Flag', //diff-add label: 'Country Flag', //diff-add type: AdminForthDataTypes.STRING, //diff-add virtual: true, //diff-add showIn: { //diff-add [AdminForthResourcePages.edit]: false, //diff-add [AdminForthResourcePages.create]: false, //diff-add [AdminForthResourcePages.filter]: false, //diff-add }, //diff-add components: { //diff-add show: '@@/CountryFlag.vue', //diff-add list: '@@/CountryFlag.vue', //diff-add }, //diff-add } ... ] ``` This field will be displayed in show and list views with custom component `CountryFlag.vue`. :::tip Enriching a virtual column from a hook If you fill a virtual column from a hook (e.g. [`afterDatasourceResponse`](./04-hooks.md#modify-record-after-it-is-returned-from-database)) instead of a custom component, write the enriched value into a field with **the same name as the virtual column**. This keeps the value addressable by column name everywhere β€” most importantly, the [Agent plugin](/docs/tutorial/Plugins/agent/) selects records by column name, so a mismatched field name makes it select the (empty) virtual column and miss your enriched data. ::: Create file `CountryFlag.vue` in `custom` folder of your project: ```html title="./custom/CountryFlag.vue" ``` Here is how it looks: ![alt text]() ## Virtual columns for filtering. Virtual column can also be used as a shorthand for a complex filtering. Lets say we want to divide apartments into two types: "base" ones and "luxury" and then allow admins to filter apartments by this category. Condition for being a "luxury" apartment is either having more then 80 sq.m area or costing more then 100k. One way to do it is to actually add a real column to a table and then fill it every time new apartment is added. A more simple way is to add a virtual column and then use `list.beforeDatasourceRequest` hook to replace filtering on this column with desired one. For this purpose following changes will be required for apartments config: ```ts title='./resources/apartments.ts' import { Filters } from "adminforth"; ... resourceId: 'aparts', ... hooks: { ... list: { beforeDatasourceRequest: async ({ query }: { query: any }) => { query.filters = query.filters.map((filter: any) => { // replace apartment_type filter with complex one if (filter.field === 'apartment_type') { if (filter.value === 'luxury') { return Filters.OR(Filters.GTE('square_meter', 80), Filters.GTE('price', 100000)); } // filter for "base" apartment as default return Filters.AND(Filters.LT('square_meter', 80), Filters.LT('price', 100000)); } return filter; }); return { ok: true, error: "" }; }, ... }, ... }, ... columns: [ ... { name: "apartment_type", virtual: true, showIn: { all: false, filter: true }, // hide it from display everywhere, except filter page enum: [ { value: 'base', label: 'Base', }, { value: 'luxury', label: 'Luxury' }, ], filterOptions: { multiselect: false, // allow to only select one category when filtering }, }, ... ] ``` This way, when admin selects, for example, "Luxury" option for "Apartment Type" filter, it will be replace with a more complex "or" filter. ### Custom SQL queries with `insecureRawSQL` Rarely the set of Filters supported by AdminForth is not enough for your needs. In this case you can use `insecureRawSQL` to write your own part of where clause. However the vital concern that the SQL passed to DB as is, so if you substitute any user inputs it will not be escaped and can lead to SQL injection. To mitigate the issue we recommend using `sqlstring` package which will escape the inputs for you. ```bash pnpm i sqlstring ``` Then you can use it like this: ```ts title='./resources/apartments.ts' import sqlstring from 'sqlstring'; ... beforeDatasourceRequest: async ({ query }: { query: any }) => { query.filters = query.filters.map((filter: any) => { // replace apartment_type filter with complex one if (filter.field === 'some_json_b_field') { return { // check if some_json_b_field->'$.some_field' is equal to filter.value insecureRawSQL: `some_json_b_field->>'$.some_field' = ${sqlstring.escape(filter.value)}`, } } return filter; }); return { ok: true, error: "" }; } ``` This example will allow to search for some nested field in JSONB column, however you can use any SQL query here. ### Custom Mongo queries with `insecureRawNoSQL` For MongoDB data sources, you can inject a raw Mongo filter object via `insecureRawNoSQL`. This is useful when the built-in filters are not enough or you need dot-notation and operators not covered by AdminForth helpers. Important: The object you provide is sent directly to MongoDB. Validate and sanitize any user inputs to prevent abuse of operators like `$where`, `$regex`, etc. Example β€” filter by nested field using dot-notation: ```ts title='./resources/apartments.ts' ... hooks: { list: { beforeDatasourceRequest: async ({ query, body }: { query: any, body: any }) => { // Add raw Mongo filter: meta.is_active must equal body.is_active query.filters.push({ insecureRawNoSQL: { 'meta.is_active': body.is_active }, }); return { ok: true, error: '' }; }, }, }, ``` You can combine it with other AdminForth filters using AND/OR: ```ts import { Filters } from 'adminforth'; query.filters = [ Filters.AND( { insecureRawNoSQL: { 'meta.is_active': true } }, Filters.EQ('status', 'active'), ) ]; ``` Notes: - `insecureRawNoSQL` is Mongo-only. For SQL databases, use `insecureRawSQL`. - If both `field`/`operator`/`value` and `insecureRawNoSQL` are present in one filter object, validation will fail. - `insecureRawSQL` is ignored by the Mongo connector. ## Virtual columns for editing. Another usecase of `virtual` columns is to add new fields in edit and create view. In the [Getting started](/docs/tutorial/001-gettingStarted.md) we used this feature to add `password` field to the `adminuser` resource. Thing is that password itself can't be stored in the database, but instead their hash is stored. So we need to add `password` field to the `adminuser` resource and make it `virtual` so it will not be stored in the database. ```ts title="./resources/adminuser.ts" ... resourceId: 'adminuser', ... columns: [ ... { name: 'password', virtual: true, // field will not be persisted into db required: { create: true }, // make required only on create page editingNote: { edit: 'Leave empty to keep password unchanged' }, minLength: 8, type: AdminForthDataTypes.STRING, showIn: { // to show field only on create and edit pages show: false, list: false, filter: false, }, masked: true, // to show stars in input field } ... ] ``` Now to handle virtual `password` field we use hooks: ```ts title="./resources/adminuser.ts" hooks: { create: { beforeSave: async ({ record, adminUser, resource }: { record: any, adminUser: AdminUser, resource: AdminForthResource }) => { record.password_hash = await AdminForth.Utils.generatePasswordHash(record.password); return { ok: true }; } }, edit: { beforeSave: async ({ updates, adminUser, resource }: { updates: any, adminUser: AdminUser, resource: AdminForthResource }) => { if (updates.password) { updates.password_hash = await AdminForth.Utils.generatePasswordHash(updates.password); } return { ok: true } }, }, }, ``` Hook still has access to the virtual field `updates.password`, and we use built-in AdminForth hasher to hash password and write it into `password_hash` field which exists in database. After hook is executed, `updates.password` will be removed from the record since it is virtual, so password itself will not be saved to the database. --- ## Hooks Hooks are used to: - modify the data before it is saved to the database on create or update - execute something after data were saved or deleted - change the query before fetching items from the database - modify the fetched data before it is displayed in the list and show - prevent the request to db depending on some condition (Better use [allowedActions](./05-limitingAccess.md) for this) Every hook is executed when AdminForth frontend (Vue SPA) makes some internal API HTTP request to the backend. Every hook function is always executed only on backend side (Node.js) from the HTTP request handler and allows you to perform some actions before or after the actual request to datasource (database) is made. This is most flexible way to control flow and extend it with custom logic. Every hook must return one of two objects: 1) If everything is fine and request flow should be continued hook should return `{ ok: true }` 2) If for some reason you need to interrupt request flow in hook you should return `{ ok: false, error: 'some error message for user' }`. This is handy for access-related tasks, though most of such tasks should be solved with [allowedActions](./05-limitingAccess.md) and not hooks. Every hook is array of async functions, so you can have multiple hooks for one event. For simplicity of course you can specify hook as scalar async function and not as array, but internally it will be anyway converted to array with single element just after app start. Plugins can push new own hooks in front of yours (using `unshift`) or after yours (using `push`). For example audit log plugin adds hooks for registration of all changes in the database. Here we will consider possible flows one by one ### Performance notice Every hook function is async, so you can use `await` inside it to perform some async operations like fetching data from another service or database, but please remember that while hook will not finish its execution, the request flow will be waiting for it. So every delay awaited in hook will delay the whole request. That is why we encourage you to use parallel async operations in hooks (like Promise.all) to make them faster. If multiple hooks are defined (e.g. plugin might add own hook to `list.beforeDatasourceRequest` after you will already add one in your config), then hooks will be executed one by one, and can't be parallelized. This ensures that different hooks will not interfere with each other, but also means that if you have bootleneck in one hook, all other hooks will wait for it and whole request will be slower. ## Initial data for edit page flow When user opens edit page, AdminForth makes a request to the backend to get the initial data for the form. ![Initial data for edit page flow](get_resource_data.png) Practically you can use `show.afterDatasourceResponse` to modify or add some data before it is displayed on the edit page. In other words, at this stage you can enrich the data with some additional metadata which might be handy on edit page for custom Vue fields. For example [upload plugin](/docs/tutorial/Plugins/upload/) uses this hook to generate signed preview URL so user can see existing uploaded file preview in form, and at the same time database stores only original file path which might be not accessible without presigned URL. ## Saving data on edit page When user clicks the "Save" button on edit page, AdminForth makes a request to the backend to save the data. ![Saving data on edit page](image-27.png) Practically you can use `hooks.edit.beforeSave` hook to modify the data or populate new fields before it is saved to the database. > πŸ‘† Note: according to diagram you should understand that interrupting flow from `hooks.edit.afterSave` does not prevent data modification in DB ## Saving data on create page When user clicks the "Save" button from create page, AdminForth makes a request to the backend to create new record. ![Saving data on create page](image-26.png) ### Example: modify the created object before it is saved to the database Let's add reference to `adminUser` when user creates a new apartment: ```ts title='./resources/apartments.ts' // diff-add import type { AdminUser } from 'adminforth'; { ... resourceId: 'aparts', columns: [ ... { name: 'realtor_id', ... //diff-add showIn: { // don't even show this field in create //diff-add create: false, //diff-add filter: false, //diff-add }, ... }, ... ], ... //diff-add hooks: { //diff-add create: { //diff-add beforeSave: async ({ adminUser, record }: { adminUser: AdminUser, record: any }) => { //diff-add record.realtor_id = adminUser.dbUser.id; //diff-add return { ok: true }; //diff-add } //diff-add } //diff-add } } ``` In this way user who creates the apartment will be assigned as a realtor. Also user can't set other realtor then himself, even if he will make request using curl/devtools because hook will override the value. ## List page flow When user opens the list page, AdminForth makes a request to the backend to get the list of items. ![List page flow](image-31.png) ### Example: limit access in list to user-related records For example we can prevent the user to see Apartments created by other users. Superadmin user still can see all: ```ts title='./resources/apartments.ts' { ... hooks: { list: { beforeDatasourceRequest: async ({ query, adminUser, resource, }: { query: any; adminUser: AdminUser; resource: AdminForthResource; }) => { if (adminUser.dbUser.role === "superadmin") { return { ok: true }; } // this function will skip existing realtor_id filter if it supplied already from UI or previous hook, and will add new one for realtor_id query.filterTools.replaceOrAddTopFilter(Filters.EQ('realtor_id', adminUser.dbUser.id)) return { ok: true }; }, }, }, } ``` This hook will prevent the user to see Apartments created by other users in list, however if user will be able to discover the apartment id, he will be able to use show page to see the apartment details, that is why separate limiting for show page is required as well. Below we will discover how to limit access to show page. ### Modify record after it is returned from database You can also change resource data after it was loaded. For example, you can change the way columns value is displayed by changing the value itself: ```ts title='./resources/apartments.ts' { ... hooks: { list: { //diff-add afterDatasourceResponse: async ({ response }: { response: any }) => { //diff-add response.forEach((r: any) => { //diff-add r.price = `$${r.price}`; //diff-add }); //diff-add return { ok: true, error: "" }; //diff-add }, }, }, } ``` Also you can use this hook to enrich the returned records list with some additional data fields which might be handy for custom Vue components defined in `components.list` or `components.show` for this resource. For example you can use [key-value adapter](/docs/tutorial/Adapters/key-value-adapters/) to get some global configuration values and add them to each record in the list: ```ts title='./resources/apartments.ts' { ... hooks: { list: { //diff-add afterDatasourceResponse: async ({ response }: { response: any }) => { //diff-add response.forEach((r: any) => { const taxRate = await kvAdapter.getValue('taxRate'); r.priceWithTax = r.price * (1 + taxRate); }); return { ok: true, error: "" }; }, }, }, } ``` :::tip Name enriched fields after the virtual column When you enrich records with extra fields for display, declare a matching [virtual column](./03-virtualColumns.md) and use **the same name** for the field you add in the hook (e.g. add a `priceWithTax` virtual column and write `r.priceWithTax` in the hook). Reusing the same name keeps the enriched value tied to a column the rest of AdminForth knows about. In particular, the [Agent plugin](/docs/tutorial/Plugins/agent/) selects records by column name when answering questions β€” if the hook writes to a different field than the virtual column, the agent selects the (empty) virtual column and never sees your enriched value. ::: ### Dropdown list of foreignResource By default if there is `foreignResource` like we use for demo on `realtor_id` column, the filter will suggest a select dropdown with list of all Realtors. This might bring us a leak where explorer will get id's of other users in the system which might be not desired Let's limit it: ```ts title='./resources/apartments.ts' { ... foreignResource: { ... hooks: { dropdownList: { beforeDatasourceRequest: async ({ adminUser, query }: { adminUser: AdminUser, query: any }) => { if (adminUser.dbUser.role !== "superadmin") { query.filtersTools.replaceOrAddTopFilter(Filters.EQ("id", adminUser.dbUser.id)); }; return { "ok": true, }; } }, }, } } ``` > ☝️☝️☝️ This hooks should be written only inside column. If you'll add it in resource hooks - it won't work In our case we limit the dropdown list to show only the current user, however you can use same sample to list only objects who are related to the current user in case if you will have relation configurations which require to show related objects which belongs to the current user. Flow diagram for dropdown list: ![Flow diagram for dropdown list](image-30.png) ## Show page flow When user opens the show page, AdminForth makes a request to the backend to get the item. This request ia absolutely the same as one for edit initial data, because naturally for most of cases data for show page are the same as initial data for edit page. However if you still need to distinguish between these two cases you can use `query.source` parameter in hook (we do not mentioned it in diagram for simplicity and rare demand). Here is show request flow: ![Here is show request](image-29.png) ### Example show limiting: ```ts title='./resources/apartments.ts' { ... hooks: { show: { afterDatasourceResponse: async ({ adminUser, response, }: { adminUser: AdminUser; response: any; }) => { if (adminUser.dbUser.role === "superadmin") { return { ok: true, response }; } if (response[0].realtor_id.pk !== adminUser.dbUser.id) { return { ok: false, error: "You are not allowed to see this record" }; } return { ok: true, response }; } } } } ``` > πŸ‘† Please note that we use `response[0].realtor_id.pk` because this field has `foreignResource` in column option is set > Otherwise you would use just `response[0].realtor_id` Important notice: Using hook to filter out list of items for list page or list of items for dropdown makes a lot of sense because gives ability to change filter of database request. However using hook for show page is not reasonable: First of all it semantically better aligns with using `allowedActions` interface. For this particular case you must use [allowedActions.show](./05-limitingAccess.md#disable-showing-the-resource-based-on-owner) Secondly limiting access from this hook will not prevent executing other hooks (e.g. `beforeDatasourceRequest`), when allowedActions check always performed before any hooks and any database requests. ## All hooks Check all hooks in the [API reference](/docs/api/Back/interfaces/AdminForthResource). --- ## Limiting actions access As you might have noticed in diagrams from [adminforth hooks](./04-hooks.md) section of this tutorial, AdminForth checks `options.allowedActions` before executing any action. In this section we will show real-code examples of how to limit access to actions based on user role or record values. Before we start it is worth to mention that callbacks or scalars defined in `allowedActions` are called/parsed not only before actual request but also before displaying buttons in the UI. So first time, when frontend loads any page of resource, it "calls" `allowedActions` to understand whether user has access to each function, and e.g. if it says that user can't delete record, AdminForth will not show delete icon in the UI: ![Resource any page request](image-21.png) As you can see allowedAction callbacks are called in parallel in async manner. However it is important to keep them fast and not to make any slow operations in them, to keep UI responsive. ## Statically disable some action on resource You can use `options.allowedActions` on resource to limit access to the resource actions (list, show, create, edit, delete). If you want to disable deletion of the resource records for all users: ```ts title="./resources/adminuser.ts" { ... resourceId: 'adminuser', ... //diff-add options: { //diff-add allowedActions: { //diff-add delete: false //diff-add } //diff-add } } ``` ## Disable full access to resource based on logged in user record or role If you want to disable all actions to the resource for all users except users with role `superadmin`: ```ts title="./resources/adminuser.ts" { ... resourceId: 'adminuser', ... //diff-add options: { //diff-add allowedActions: { //diff-add all: async ({ adminUser }: { adminUser: AdminUser }): Promise => { //diff-add return adminUser.dbUser.role === 'superadmin'; //diff-add } //diff-add } //diff-add } } ``` > ☝️ This will not hide link to the resource in the menu, you should separately use [menuItem.visible](/docs/tutorial/Customization/menuConfiguration/#visibility-of-menu-items) to hide it. > ☝️ instead of reading role from user you can check permission using complex ACL/RBAC models with permissions stored in the database. > However we recommend you to keep in mind that allowedActions callback is called on every request related to resource, so it should be fast. > So try to minimize requests to database as much as possible. ## Disable only some action based on logged in user record or role If you want to disable deletion of apartments for all users apart from users with role `superadmin`: ```ts title='./resources/apartments.ts' //diff-add import type { AdminUser } from 'adminforth'; { ... resourceId: 'aparts', ... options: { //diff-add allowedActions: { //diff-add delete: async ({ adminUser }: { adminUser: AdminUser }): Promise => { //diff-add return adminUser.dbUser.role === 'superadmin'; //diff-add } //diff-add } ... } } ``` ### Reuse the same callback for multiple actions Let's disable creating and editing of new users for all users apart from users with role `superadmin`, and at the same time disable deletion for all users: ```ts title="./resources/adminuser.ts" //diff-add import type { AdminUser } from 'adminforth'; //diff-add async function allowedForSuperAdmin({ adminUser }: { adminUser: AdminUser }): Promise { //diff-add return adminUser.dbUser.role === 'superadmin'; //diff-add } ... { ... resourceId: 'adminuser', ... options: { allowedActions: { //diff-add create: allowedForSuperAdmin, //diff-add edit: allowedForSuperAdmin, delete: false } ... } } ``` ## Customizing the access control based on resource values In more advanced cases you might need to check access based on record value. Generally it happens in multi-tenant applications where you need to check if user has access to the record based on some field value. ### Disable editing of the resource based on owner For example, allow to edit apartments only if user is a realtor of the apartment (defined as realtor_id), otherwise return error "You are not assigned to this apartment and can't edit it": ```ts title="./resources/apartments.ts" import type { AdminUser } from 'adminforth'; import { ActionCheckSource } from 'adminforth'; async function canModifyApart({ adminUser, source, meta }: { adminUser: AdminUser, meta: any, source: ActionCheckSource }): Promise { if (source === ActionCheckSource.DisplayButtons) { // if check is done for displaying button - we show button to everyone return true; } const { oldRecord, newRecord } = meta; if (oldRecord.realtor_id !== adminUser.dbUser.id) { return "You are not assigned to this apartment and can't edit it"; } if (newRecord.realtor_id !== oldRecord.realtor_id) { return "You can't change the owner of the apartment"; } return true; } { ... resourceId: 'aparts', ... options: { allowedActions: { edit: canModifyApart, } ... } } ``` ### Disable deletion of the resource based on owner If we need to allow only owner to delete the apartment: ```ts title="./resources/apartments.ts" import type { AdminUser } from 'adminforth'; async function canDeleteApart({ adminUser, meta }: { adminUser: AdminUser, meta: any }): Promise { const { record } = meta; if (record.realtor_id !== adminUser.dbUser.id) { return "You are not assigned to this apartment and can't delete it"; } return true; } { ... resourceId: 'aparts', ... options: { allowedActions: { delete: canDeleteApart, } ... } } ``` ### Disable showing the resource based on owner This one might sound pretty tricky. If Update and Delete callbacks in allowedActions were called with `meta` object which already had a records values, here we need to fetch the record from the database to check if user is the owner of the record. This is done because of architecture of AdminForth: `show` callback is called before action `list` or `show` hooks and requests. ```ts title="./resources/apartments.ts" allowedActions: { ... show: async ({adminUser, meta, source, adminforth}: any) => { if (source === 'showRequest' || source === 'editLoadRequest') { const record = await adminforth.resource('aparts').get(Filters.EQ('id', meta.pk)); return record.realtor_id === adminUser.dbUser.id; } return true; }, } ``` Please note that show callback is called not only when user visits show page (source will be `'showRequest'` during this check) but also when user visits edit page (source will be `'editLoadRequest'`). --- ## Custom pages Most Admin Panels should have some Dashboards or custom pages. In AdminForth creation of custom page is very simple. Create a Vue component in the `custom` directory of your project, e.g. `Dashboard.vue`: ```html title="./custom/Dashboard.vue" ``` > ☝️ use https://flowbite.com/ to get pre-designed tailwind design blocks for your pages Now let's add this page to the AdminForth menu and make it homepage instead of Apartments page: ```ts title="/index.ts" menu: [ //diff-add { //diff-add label: 'Dashboard', //diff-add path: '/overview', //diff-add homepage: true, //diff-add icon: 'flowbite:chart-pie-solid', //diff-add component: '@@/Dashboard.vue', //diff-add }, { label: 'Core', icon: 'flowbite:brain-solid', open: true, children: [ { //diff-remove homepage: true, label: 'Apartments', icon: 'flowbite:home-solid', resourceId: 'aparts', }, ] }, ``` > ☝️ To find icon go to https://icon-sets.iconify.design/flowbite/?query=chart, click on icon you like and copy name: ![Iconify icon select](image-icon-select.png) You might notice that in mounted hook page fetches custom endpoint '/api/dashboard-stats'. Now we have to define this endpoint in the backend to make our page work: ## Defining custom API for own page and components Open `api.ts` file and add the following code *BEFORE* `admin.express.authorize` ! ```ts title="/api.ts" import type { IAdminUserExpressRequest } from 'adminforth'; import express from 'express'; import * as z from 'zod'; .... app.get(`${ADMIN_BASE_URL}/api/dashboard/`, admin.express.withSchema( { description: 'Returns aggregated apartment metrics for the custom dashboard page.', response: z.object({ apartsByDays: z.array(z.record(z.string(), z.unknown())), totalAparts: z.number(), }).catchall(z.unknown()), }, admin.express.authorize( async (req:IAdminUserExpressRequest, res: express.Response) => { const days = req.body.days || 7; const apartsByDays = admin.resource('aparts').dataConnector.client.prepare( `SELECT strftime('%Y-%m-%d', created_at) as day, COUNT(*) as count FROM apartments GROUP BY day ORDER BY day DESC LIMIT ?; ` ).all(days); const totalAparts = apartsByDays.reduce((acc: number, { count }: { count:number }) => acc + count, 0); // add listed, unlisted, listedPrice, unlistedPrice const listedVsUnlistedByDays = admin.resource('aparts').dataConnector.client.prepare( `SELECT strftime('%Y-%m-%d', created_at) as day, SUM(listed) as listed, COUNT(*) - SUM(listed) as unlisted, SUM(listed * price) as listedPrice, SUM((1 - listed) * price) as unlistedPrice FROM apartments GROUP BY day ORDER BY day DESC LIMIT ?; ` ).all(days); const apartsCountsByRooms = await admin.resource('aparts').dataConnector.client.prepare( `SELECT number_of_rooms, COUNT(*) as count FROM apartments GROUP BY number_of_rooms ORDER BY number_of_rooms; ` ).all(); const topCountries = await admin.resource('aparts').dataConnector.client.prepare( `SELECT country, COUNT(*) as count FROM apartments GROUP BY country ORDER BY count DESC LIMIT 4; ` ).all(); const totalSquare = admin.resource('aparts').dataConnector.client.prepare( `SELECT SUM(square_meter) as totalSquare FROM apartments; ` ).get(); const listedVsUnlistedPriceByDays = admin.resource('aparts').dataConnector.client.prepare( `SELECT strftime('%Y-%m-%d', created_at) as day, SUM(listed * price) as listedPrice, SUM((1 - listed) * price) as unlistedPrice FROM apartments GROUP BY day ORDER BY day DESC LIMIT ?; ` ).all(days); const totalListedPrice = Math.round(listedVsUnlistedByDays.reduce(( acc: number, { listedPrice }: { listedPrice:number } ) => acc + listedPrice, 0)); const totalUnlistedPrice = Math.round(listedVsUnlistedByDays.reduce(( acc: number, { unlistedPrice }: { unlistedPrice:number } ) => acc + unlistedPrice, 0)); res.json({ apartsByDays, totalAparts, listedVsUnlistedByDays, apartsCountsByRooms, topCountries, totalSquareMeters: totalSquare.totalSquare, totalListedPrice, totalUnlistedPrice, listedVsUnlistedPriceByDays, }); } ) ) ); ``` Install and import Zod before using this pattern: `pnpm add zod` or `npm install zod`, then `import * as z from 'zod';`. `admin.express.withSchema(...)` will convert the Zod schema to OpenAPI for you. If you created the app with the CLI defaults, start it and open `http://localhost:3500/api-docs` in your browser to see this custom method in the generated API docs. > ☝️ Please note that we are using `admin.express.authorize` middleware to check if the user is logged in. If you want to make this endpoint public, you can remove this middleware. If user is not logged in, the request will return 401 Unauthorized status code, and protect our statistics from leak. > ☝️ Moreover if you wrap your endpoint with `admin.express.authorize` middleware, you can access `req.adminUser` object in your endpoint to get the current user information. > ☝️ Using `admin.express.withSchema(...)` is the recommended approach because it adds your route to `/api/v1/openapi.json` and `/api-docs` (Solar), performs early runtime validation for API calls, and gives agent plugins a machine-readable API contract they can use in skills. It is still optional though, and you can register plain Express routes without `withSchema(...)` if you prefer. > ☝️ If you do not want to use Zod, you can pass a plain JSON Schema (or convert it from e.g. typebox) object instead of a Zod schema. For example, this Zod response schema: > > ```ts > response: z.object({ > apartsByDays: z.array(z.record(z.string(), z.unknown())), > totalAparts: z.number(), > }).catchall(z.unknown()), > ``` > > can be written as pure JSON Schema: > > ```ts > response: { > type: 'object', > properties: { > apartsByDays: { > type: 'array', > items: { > type: 'object', > additionalProperties: true, > }, > }, > totalAparts: { > type: 'number', > }, > }, > required: ['apartsByDays', 'totalAparts'], > additionalProperties: true, > }, > ``` > ☝️ AdminForth does provide own data access facility called [DATA API](./11-dataApi.md) to access data in database. But it is very basic and mostly covers only simple CRUD operations. For complex queries like in this example, it is better to use your own data access code with any ORM. You are free to use any ORM like Prisma, TypeORM, Sequelize, mongoose, or just use raw SQL queries against your tables. Demo: ![alt text](dashDemo.gif) ## Custom pages without menu item Sometimes you might need to add custom page but don't want to add it to the menu. In this case you can add custom page using `customization.customPages` option: ```ts title="/index.ts" new AdminForth({ // ... customization: { customPages: [ { path: '/setup2fa', // route path component: { file: '@@/pages/TwoFactorsSetup.vue', meta: { title: 'Setup 2FA', // meta title for this page //diff-add sidebarAndHeader: 'none' // Layout options: 'none' (no sidebar/header), 'default' (full layout), 'preferIconOnly' (collapsed sidebar) } } } ] } }) ``` > πŸ’‘ **Layout Options Explained:** > - `'none'`: Renders the page without AdminForth's default sidebar and header layout - perfect for standalone pages like setup wizards, or public (logged-out) pages (Terms-of-Service/PP/Contact form etc) > - `'default'`: Uses the full AdminForth layout with sidebar and header - ideal for pages that should feel integrated with the admin panel > - `'preferIconOnly'`: Uses the default layout but starts with a collapsed sidebar (even if icon-only sidebar is disabled in your configuration) - great for pages that need more screen space or already have some navigation This will register custom page with path `/setup2fa` and will not include it in the menu. You can navigate user to this page using any router link, e.g.: ```html ``` Add to your ` ``` Now let's add this page to the AdminForth menu: ```html title="/index.ts" menu: [ //diff-add { //diff-add label: 'Alerts', //diff-add icon: 'flowbite:bell-active-alt-solid', //diff-add component: '@@/Alerts.vue', //diff-add path: '/alerts' //diff-add } ``` Here is how alert looks: ![alt text](image-12.png) And here is how confirmation looks: ![alt text]() ## Announcement You can notify users of important information by displaying an announcement badge in side bar: ```ts title="/index.ts" customization: { //diff-add announcementBadge: (adminUser: AdminUser) => { //diff-add return { //diff-add html: '⭐ Star us on GitHub to support a project!', //diff-add closable: true, //diff-add title: 'Support us for free', //diff-add } //diff-add } }, ``` Here's what the announcement will look like: ![alt text](image-11.png) ## Disable "There are unsaved changed" popup for resource By default, when you want to leave create/edit pages with usaved changes, there will be shown an confirmation popup. If you don't like this behaviour, you can disable it for resource: ```ts title='./resources/cusom_resource.ts' ... options: { listPageSize: 12, //diff-add dontShowWarningAboutUnsavedChanges: true, ... } ... ``` --- ## Page Injections In addition to ability to create custom pages and overwrite how fields are rendered, you can also inject custom components in standard AdminForth page. For example let's add a custom pie chart to the `list` page of the `aparts` resource. Pie chart will show the distribution of the rooms count and more over will allow to filter the list by the rooms count. ```ts title="./resources/apartments.ts" { resourceId: 'aparts', ... //diff-add options: { //diff-add pageInjections: { //diff-add list: { //diff-add afterBreadcrumbs: '@@/ApartsPie.vue', //diff-add } //diff-add } //diff-add } } ``` Now create file `ApartsPie.vue` in the `custom` folder of your project: ```html title="./custom/ApartsPie.vue" ``` Also we have to add an Api to get percentages: ```ts title="/api.ts" import type { IAdminUserExpressRequest } from 'adminforth'; import express from 'express'; import * as z from 'zod'; .... app.get(`${ADMIN_BASE_URL}/api/aparts-by-room-percentages/`, admin.express.withSchema( { description: 'Returns apartment room-count percentages for the page injection chart.', response: z.array(z.object({ rooms: z.number(), percentage: z.number(), })), }, admin.express.authorize( async (req: IAdminUserExpressRequest, res: express.Response) => { const roomPercentages = await admin.resource('aparts').dataConnector.client.prepare( `SELECT number_of_rooms, COUNT(*) as count FROM apartments GROUP BY number_of_rooms ORDER BY number_of_rooms; ` ).all() const totalAparts = roomPercentages.reduce((acc, { count }) => acc + count, 0); res.json( roomPercentages.map( ({ number_of_rooms, count }) => ({ rooms: number_of_rooms, percentage: Math.round(count / totalAparts * 100), }) ) ); } ) ) ); ``` Install and import Zod before using this pattern: `pnpm add zod` or `npm install zod`, then `import * as z from 'zod';`. `admin.express.withSchema(...)` will convert the Zod schema to OpenAPI for you. > ☝️ Please note that we are using [Frontend API](/docs/api/FrontendAPI/interfaces/FrontendAPIInterface/) `adminforth.list.updateFilter({field: 'number_of_rooms', operator: 'eq', value: selectedRoomsCount});` to set filter when we are located on apartments list page > ☝️ The outer `admin.express.withSchema(...)` wrapper makes this custom Express route appear in `/api/v1/openapi.json` and `/api-docs`. Here is how it looks: ![alt text]() ## Login page customization You can also inject custom components to the login page. `loginPageInjections.underInputs` and `loginPageInjections.panelHeader` allows to add one or more panels under or over the login form inputs: ![login Page Injections underInputs]() For example: ```ts title="/index.ts" new AdminForth({ ... customization: { loginPageInjections: { underInputs: '@@/CustomLoginFooter.vue', } ... } ... }) ``` Now create file `CustomLoginFooter.vue` in the `custom` folder of your project: ```html title="./custom/CustomLoginFooter.vue" ``` Also you can add `panelHeader` ```ts title="/index.ts" new AdminForth({ ... customization: { loginPageInjections: { underInputs: '@@/CustomLoginFooter.vue', //diff-add panelHeader: '@@/CustomLoginHeader.vue', } ... } ... }) ``` Now create file `CustomLoginHeader.vue` in the `custom` folder of your project: ```html title="./custom/CustomLoginHeader.vue" ``` ## List view page injections shrinking: thin enough to shrink? When none of `bottom`, `beforeBreadcrumbs`, `beforeActionButtons`, `afterBreadcrumbs` injections are set in list table, the table tries to shrink into viewport for better UX. In other words, in this default mode it moves scroll from body to the table itself: ![alt text]() However if one of the above injections is set, the table will not try to shrink it's height into viewport and will have a fixed height. We apply this behavior because generally page injection might take a lot of height and table risks to be too small to be usable. So vertical scroll is moved to the body (horizontal scroll is still on the table): ![alt text]() However, if you intend to use injection as a small panel, you can set `meta.thinEnoughToShrinkTable` to `true` in the injection instantiation: ```ts title="/apartments.ts" { resourceId: 'aparts', ... options: { pageInjections: { list: { bottom: { file: '@@/.vue', meta: { thinEnoughToShrinkTable: true, } } } } } } ``` ![alt text]() If at least one injection will not set or will not define `meta.thinEnoughToShrinkTable` as `true`, the table will not try to shrink into viewport. ## Three dots menu customization You can also inject custom components to the three dots menu on the top right corner of the page. ![alt text]() ```ts title="/apartments.ts" { resourceId: 'aparts', ... options: { pageInjections: { show: { threeDotsDropdownItems: [ '@@/CheckReadingTime.vue', ] } } } } ``` Now create file `CheckReadingTime.vue` in the `custom` folder of your project: ```html title="./custom/CheckReadingTime.vue" ``` For this demo we will use text-analyzer package: ```bash cd custom pnpm i text-analyzer ``` > ☝️ Please note that we are using AdminForth [Frontend API](/docs/api/FrontendAPI/interfaces/FrontendAPIInterface/) `list.closeThreeDotsDropdown();` to close the dropdown after the item is clicked. >☝️ Please note that the injected component might have an exposed click function as well as a defined click function, which executes the click on component logic. ## List table custom action icons `customActionIcons` allows to add custom actions to the list page ![alt text]() ```ts title="/apartments.ts" { resourceId: 'aparts', ... options: { pageInjections: { list: { customActionIcons: [ '@@/SearchForApartmentInGoogle.vue', ] } } } } ``` Now create file `SearchForApartmentInGoogle.vue` in the `custom` folder of your project: ```html title="./custom/SearchForApartmentInGoogle.vue" ``` Install used icon: ```sh cd custom pnpm i @iconify-prerendered/vue-mdi ``` ## List table row replace injection `tableRowReplace` lets you fully control how each list table row is rendered. Instead of the default table `…` markup, AdminForth will mount your Vue component per record and use its returned DOM to display the row. Use this when you need custom row layouts, extra controls, or conditional styling that goes beyond column-level customization. Supported forms: - Single component: `pageInjections.list.tableRowReplace = '@@/MyRowRenderer.vue'` - Object form with meta: `pageInjections.list.tableRowReplace = { file: '@@/MyRowRenderer.vue', meta: { /* optional */ } }` - If an array is provided, the first element is used. Example configuration: ```ts title="/resources/apartments.ts" { resourceId: 'aparts', ... options: { pageInjections: { list: { tableRowReplace: { file: '@@/ApartRowRenderer.vue', meta: { // You can pass any meta your component may read } } } } } } ``` Minimal component example (decorate default row with a border): ```vue title="/custom/ApartRowRenderer.vue" ``` Component contract: - Inputs - `record`: the current record object - `resource`: the resource config object - `meta`: the meta object passed in the injection config - Slots - Default slot: the table’s standard row content (cells) will be projected here. Your component can wrap or style it. - Output - Render a full `…` fragment. For example, to replace the standard set of cells with a single full‑width cell, render: ```vue ``` Notes and tips: - Requirements: - Required `` structure around `` ## List table three dots menu injection `customActionIconsThreeDotsMenuItems` allows to inject component inside three dots menu for each recod in list table. ```ts options: { pageInjections: { list: { customActionIconsThreeDotsMenuItems: { file: '@@/ApartRowRenderer.vue', meta: { // You can pass any meta your component may read } } } } } ``` ## List table beforeActionButtons `beforeActionButtons` allows injecting one or more compact components into the header bar of the list page, directly to the left of the default action buttons (`Create`, `Filter`, bulk actions, three‑dots menu). Use it for small inputs (quick search, toggle, status chip) rather than large panels. ![alt text]() ```ts title="/apartments.ts" { resourceId: 'aparts', ... options: { pageInjections: { list: { beforeActionButtons: { file: '@@/UniversalQuickSearch.vue', meta: { thinEnoughToShrinkTable: true } } } } } } ``` Multiple components: ```ts beforeActionButtons: [ { file: '@@/UniversalQuickSearch.vue', meta: { thinEnoughToShrinkTable: true } }, { file: '@@/RecordsSummary.vue', meta: { thinEnoughToShrinkTable: true } } ] ``` > ☝️ Keep these components visually light; wide or tall content should use `afterBreadcrumbs` or `bottom` instead. ## List table custom ## Global Injections You have opportunity to inject custom components to the global layout. For example, you can add a custom items into user menu * `config.customization.globalInjections.userMenu`: ![alt text]() use `closeUserMenuDropdown();` to close the dropdown after the item is clicked. ```ts title="/index.ts" { ... customization: { globalInjections: { userMenu: [ '@@/CustomUserMenuItem.vue', ] } } ... } ``` Now create file `CustomUserMenuItem.vue` in the `custom` folder of your project: ```html title="./custom/CustomUserMenuItem.vue" ``` Also there are: * `config.customization.globalInjections.header` * `config.customization.globalInjections.sidebar` * `config.customization.globalInjections.sidebarTop` β€” renders inline at the very top of the sidebar, on the same row with the logo/brand name. If the logo is hidden via `showBrandLogoInSidebar: false`, this area expands to the whole row width. * `config.customization.globalInjections.everyPageBottom` Unlike `userMenu`, `header` and `sidebar` injections, `everyPageBottom` will be added to the bottom of every page even when user is not logged in. You can use it to execute some piece of code when any page is loaded. For example, you can add welcoming pop up when user visits a page. ```ts title="/index.ts" { ... customization: { globalInjections: { userMenu: [ '@@/CustomUserMenuItem.vue', //diff-remove ] //diff-add ], //diff-add everyPageBottom: [ //diff-add '@@/AnyPageWelcome.vue', //diff-add ] } } ... } ``` Now create file `AnyPageWelcome.vue` in the `custom` folder of your project: ```html title="./custom/AnyPageWelcome.vue" ``` ## Sidebar Top Injection You can place compact controls on the very top line of the sidebar, next to the logo/brand name: ```ts title="/index.ts" new AdminForth({ ... customization: { globalInjections: { sidebarTop: [ '@@/QuickSwitch.vue', ], } } }) ``` If you hide the logo with `showBrandLogoInSidebar: false`, components injected via `sidebarTop` will take the whole line width. ## Injection order Most of injections accept an array of components. By defult the order of components is the same as in the array. You can use standard array methods e.g. `push`, `unshift`, `splice` to put item in desired place. However, if you want to control the order of injections dynamically, which is very handly for plugins, you can use `meta.afOrder` property in the injection instantiation. The higher the number, the earlier the component will be rendered. For example ```ts title="/index.ts" { ... customization: { globalInjections: { userMenu: [ { file: '@@/CustomUserMenuItem.vue', meta: { afOrder: 10 } }, { file: '@@/AnotherCustomUserMenuItem.vue', meta: { afOrder: 20 } }, { file: '@@/LastCustomUserMenuItem.vue', meta: { afOrder: 5 } }, ] } } ... } ``` ## Order of components inserted by plugins For plugins, the plugin developers encouraged to use `meta.afOrder` to control the order of injections and allow to pass it from plugin options. For example "OAuth2 plugin", when registers a login button component for login page injection, uses `meta.afOrder` and sets it equal to 'YYY' passed in plugin options: ```ts title="/index.ts" // plugin CODE adminforth.config.customization.loginPageInjections.underLoginButton.push({ file: '@@/..vue', meta: { afOrder: this.pluginOptions.YYY || 0 } }) ``` So you can just pass `YYY` option to the plugin to control the order of the injection. ## Custom scripts in head If you want to inject tags in your html head: ```ts title='./index.ts' customization: { ... customHeadItems: [ { tagName: 'script', attributes: { async: 'true', defer: 'true' }, innerCode: "console.log('Hello from HTML head')" } ], ... } ``` --- ## Actions ## Single record actions You might need to give admin users a feature to perform some action on a single record. Actions can be displayed as buttons in the list view and/or in the three-dots menu. Here's how to add a custom action: ```ts title="./resources/apartments.ts" { resourceId: 'aparts', options: { actions: [ { name: 'Auto submit', // Display name of the action icon: 'flowbite:play-solid', // Icon to display (using Flowbite icons) // Control who can see/use this action allowed: ({ adminUser, standardAllowedActions }) => { return true; // Allow everyone }, // Handler function when action is triggered action: async ({ recordId, adminUser }) => { logger.info("auto submit", recordId, adminUser); return { ok: true, successMessage: "Auto submitted" }; }, // Configure where the action appears showIn: { list: false, // Show in list view listThreeDotsMenu: true, // Show in three dots menu in list view showButton: true, // Show as a button showThreeDotsMenu: true, // Show in three-dots menu } } ] } } ``` ### Action Configuration Options - `name`: Display name of the action - `icon`: Icon to show (using Flowbite icon set) - `allowed`: Function to control access to the action - `action`: Handler function that executes when action is triggered for a **single** record - `bulkHandler`: Handler function that executes when the action is triggered for **multiple** records at once (see [Dedicated bulk handler](#dedicated-bulk-handler)) - `showIn`: Controls where the action appears - `list`: whether to show as an icon button per row in the list view - `listThreeDotsMenu`: whether to show in the three-dots menu per row in the list view - `showButton`: whether to show as a button on the show view - `showThreeDotsMenu`: whether to show in the three-dots menu of the show view - `bulkButton`: whether to show as a bulk action button when rows are selected ### Bulk button with `action` When `showIn.bulkButton` is `true` and only `action` (not `bulkHandler`) is defined, AdminForth automatically calls your `action` function **once per selected record** using `Promise.all`. This is convenient for simple cases but means N separate handler invocations run in parallel: ```ts title="./resources/apartments.ts" { name: 'Auto submit', action: async ({ recordId }) => { // Called once per selected record when used as a bulk button await doSomething(recordId); return { ok: true, successMessage: 'Done' }; }, showIn: { bulkButton: true, // triggers Promise.all over selected records showButton: true, } } ``` ### Dedicated bulk handler If your operation can be expressed more efficiently as a single batched query (e.g., a single `UPDATE … WHERE id IN (…)`), define `bulkHandler` instead. AdminForth will call it **once** with all selected record IDs: ```ts title="./resources/apartments.ts" { name: 'Auto submit', // bulkHandler receives all recordIds in one call – use it for batched operations bulkHandler: async ({ recordIds, adminforth, resource }) => { await doSomethingBatch(recordIds); return { ok: true, successMessage: `Processed ${recordIds.length} records` }; }, // You can still keep `action` for the single-record show/edit buttons action: async ({ recordId }) => { await doSomething(recordId); return { ok: true, successMessage: 'Done' }; }, showIn: { bulkButton: true, showButton: true, } } ``` > ☝️ When both `action` and `bulkHandler` are defined, AdminForth uses `bulkHandler` for bulk operations and `action` for single-record operations. When only `action` is defined and `bulkButton` is enabled, AdminForth falls back to `Promise.all` over individual `action` calls. ### Bulk-specific options | Option | Type | Description | |---|---|---| | `showIn.bulkButton` | `boolean` | Show as a bulk action button in the list toolbar. | | `bulkHandler` | `async ({ recordIds, adminUser, adminforth, resource, response, tr }) => { ok, error?, message? }` | Called with all selected IDs at once. Falls back to calling `action` per record in parallel if omitted. | | `bulkConfirmationMessage` | `string` | Confirmation dialog text shown before the bulk action executes. | | `bulkSuccessMessage` | `string` | Success message shown after the bulk operation. Defaults to `"N out of M items processed successfully"`. | ## Standalone Bulk Actions For operations that only apply to multiple selected records, use `options.bulkActions`. The built-in **Delete checked** action is a good reference. ```ts title="./resources/apartments.ts" { resourceId: 'aparts', options: { bulkActions: [ { label: 'Send Invitation', icon: 'flowbite:envelope-solid', confirm: 'Are you sure you want to send invitation emails?', allowed: async ({ adminUser }) => adminUser.dbUser.role === 'superadmin', action: async ({ selectedIds }) => { await sendBulkInvitations(selectedIds); return { ok: true, successMessage: `Sent to ${selectedIds.length} users` }; }, }, ], }, } ``` ### Confirmation dialog Pass `confirm` to show a dialog before the action runs. **String** β€” shown as the dialog title, no secondary message: ```ts confirm: 'Are you sure you want to send invitation emails?', ``` **Object** β€” full control over the dialog. `{count}` in `message` is replaced with the number of selected records; `|` separates singular and plural forms: ```ts confirm: { title: 'Are you sure you want to archive the selected items?', message: 'Archiving {count} item. This process is irreversible. | Archiving {count} items. This process is irreversible.', yes: 'Archive', no: 'Cancel', }, ``` Omit `confirm` entirely to skip the dialog and run the action immediately. ### Access Control You can control who can use an action through the `allowed` function. This function receives: ```ts title="./resources/apartments.ts" { options: { actions: [ { name: 'Auto submit', allowed: async ({ adminUser, standardAllowedActions }) => { if (adminUser.dbUser.role !== 'superadmin') { return false; } return true; }, // ... other configuration } ] } } ``` The `allowed` function receives: - `adminUser`: The current admin user object - `standardAllowedActions`: Standard permissions for the current user Return: - `true` to allow access - `false` to deny access - A string with an error message to explain why access was denied β€” e.g. `return 'Only superadmins can perform this action'` Here is how it looks: ![alt text]() ### Action URL Instead of defining an `action` handler, you can specify a `url` that the user will be redirected to when clicking the action button: ```ts title="./resources/apartments.ts" { name: 'View details', icon: 'flowbite:eye-solid', url: '/resource/aparts', // URL to redirect to showIn: { list: true, listThreeDotsMenu: false, showButton: true, showThreeDotsMenu: true, } } ``` > ☝️ Note: You cannot specify both `action` and `url` for the same action - only one should be used. The URL can be: - A relative path within your admin panel (starting with '/') - An absolute URL (starting with 'http://' or 'https://') - function which creates URL based on record fields To open the URL in a new tab, append `target=_blank` as a query parameter. If the URL already has query parameters, use `&target=_blank`; otherwise use `?target=_blank`: ```ts { name: 'View on Google', icon: 'flowbite:external-link-solid', url: 'https://google.com/search?q=apartment&target=_blank', showIn: { list: true, showButton: true } } ``` Example to generate dynamic URL: ```ts { name: 'View on Google', icon: 'flowbite:external-link-solid', url: async ({record, recordId, adminUser, resource }) => `https://google.com/search?q=Apartment ${record.title}`, showIn: { list: true, showButton: true } } ``` > ☝️ Note: Though url function might be async we recommend to omit long awaits, or ideally don't use them at all, cause slow execution of this hook might be a subject of bottleneck for resource pages rendering. For built actions the async functions would be called in parallel to optimize loading speed. ### Deep-level redirects. Using `url` prop described above is recommended way to implementing URL navigation from actions (internal or external), because URLs are rendered into direct anchour tag and support all anchour features (like Open in new tab). However, rearely you might also like to decide whether to redirect only after performing some logic (conditionally). This way is not recommended for most of cases, because it is not compatible with action native features (we can't know URL before executing action body): ```ts { name: 'View on Google', icon: 'flowbite:external-link-solid', action: async ({ recordId }) => { if (await testSomething(recordId)) { return { ok: true, redirectUrl: 'https://google.com/search?q=apartment' }; }; return { ok: true, successMessage: 'Done' }; }, showIn: { list: true, showButton: true } } ``` ## Custom Component If you want to style an action's button/icon without changing its behavior, attach a custom UI wrapper via `customComponent`. The file points to your SFC in the custom folder (alias `@@/`), and `meta` lets you pass lightweight styling options (e.g., border color, radius). Below we wrap a "Mark as listed" action. ```ts title="./resources/apartments.ts" { resourceId: 'aparts', options: { actions: [ { name: 'Mark as listed', icon: 'flowbite:eye-solid', // UI wrapper for the built-in action button //diff-add customComponent: { //diff-add file: '@@/ActionBorder.vue', // SFC path in your custom folder //diff-add meta: { color: '#94a3b8', radius: 10 } //diff-add }, showIn: { list: false, listThreeDotsMenu: true, showButton: true, showThreeDotsMenu: true }, action: async ({ recordId }) => { await admin.resource('aparts').update(recordId, { listed: 1 }); return { ok: true, successMessage: 'Marked as listed' }; } }, ] } } ``` Use this minimal wrapper component to add a border/rounding around the default action UI while keeping the action logic intact. Keep the `` (that's where AdminForth renders the default button) and emit `callAction` (optionally with a payload) to trigger the handler when the wrapper is clicked. ```ts title="./custom/ActionBorder.vue" ``` ### Pass dynamic values to the action You can pass arbitrary data from your custom UI wrapper to the backend action by emitting `callAction` with a payload. That payload will be available on the server under the `extra` argument of your action handler. Frontend examples: ```vue title="./custom/ActionToggleListed.vue" ``` Backend handler: read the payload via `extra`. ```ts title="./resources/apartments.ts" { resourceId: 'aparts', options: { actions: [ { name: 'Toggle listed', icon: 'flowbite:eye-solid', showIn: { list: false, listThreeDotsMenu: false, showButton: true, showThreeDotsMenu: true }, // The payload from emit('callAction', { asListed: true|false }) arrives here as `extra` customComponent: { file: '@@/ActionToggleListed.vue' }, action: async ({ recordId, extra }) => { const asListed = extra?.asListed === true; // Example update (use your own data layer): await admin.resource('aparts').update(recordId, { listed: asListed }); return { ok: true, successMessage: `Set listed=${asListed}` }; } } ] } } ``` Notes: - If you don’t emit a payload, the default behavior is used by the UI (e.g., in lists the current row context is used). When you do provide a payload, it will be forwarded to the backend as `extra` for your action handler. - You can combine default context with your own payload by merging before emitting, for example: `emit('callAction', { ...row, asListed: true })` if your component has access to the row object. ## Start actions programmatically You can execute resource actions manually using adminforth.runAction(). This is useful inside hooks, plugins, cron jobs, custom endpoints, or any backend automation. ```ts title="./resources/apartments.ts" actions: [ { //diff-add id: 'testToggle listedAction', name: 'Toggle listed', icon: 'flowbite:eye-solid', ... } ] ``` Then execute it from a hook for example: ```ts title="./resources/apartments.ts" hooks: { ... afterSave: async ({ record, adminUser, resource, adminforth }: { record: any, adminUser: AdminUser, resource: AdminForthResource, adminforth: any }) => { await adminforth.runAction({ actionId: 'Toggle listed', resourceId: resource.resourceId, recordId: record.id, adminUser, }); return { ok: true }; }, }, ``` runAction() automatically: - finds the resource - finds the action - checks permissions via allowed - executes the action handler - passes full action context (recordId, adminUser, extra, etc.) > ☝️ runAction() is not limited to hooks β€” you can call it anywhere you have access to the AdminForth instance. --- ## Menu & Header ## Icons Adminforth uses [Iconify](https://iconify.design/) icons everywhere, including the menu. You can set an icon for each menu item using the `icon` field. You can use any icon from the [Iconify Gallery](https://icon-sets.iconify.design/) in the format `:`. For example, `flowbite:brain-solid`. ![Icons for AdminForth](image-14.png) > πŸ‘‹ With deep respect to Alex Kozack who created great [iconify-prerendered](https://github.com/cawa-93/iconify-prerendered) MIT package used by AdminForth. It uses a scheduled job to prerender all icons from Iconify to icons font and then publish them to npm ## Grouping You can created a group of menu items with open or close: E.g. create group "Blog" with Items who link to resource "posts" and "categories": ```ts title='./index.ts' { ... menu: [ { label: 'Blog', icon: 'flowbite:brain-solid', open: true, children: [ { label: 'Posts', icon: 'flowbite:book-open-outline', resourceId: 'posts', }, { label: 'Categories', icon: 'flowbite:folder-duplicate-outline', resourceId: 'categories', }, ], }, { label: 'Users', icon: 'flowbite:folder-duplicate-outline', resourceId: 'adminuser', }, ], ... } ``` If it is rare Group you can make it `open: false` so it would not take extra space in menu, but admin users will be able to open it by clicking on the group name. ## Adding menu items from plugins Plugins can add top-level menu items without mutating the user-defined `config.menu`. This keeps the application menu owned by the app configuration, while plugins can contribute their own entries. Use `registerMenuContribution` from a plugin's `modifyResourceConfig`: ```ts title='./plugins/adminforth-dashboard/index.ts' async modifyResourceConfig(adminforth, resourceConfig) { super.modifyResourceConfig(adminforth, resourceConfig); adminforth.registerMenuContribution({ item: { itemId: 'dashboard', type: 'page', label: 'Dashboard', icon: 'flowbite:chart-pie-solid', path: '/dashboard', component: this.componentPath('Dashboard.vue'), }, placement: { before: { resourceId: 'adminuser' } }, }); } ``` Supported placements: ```ts adminforth.registerMenuContribution({ item: { itemId: 'dashboard', type: 'page', label: 'Dashboard', path: '/dashboard', component: this.componentPath('Dashboard.vue'), }, placement: { position: 'first' }, }); adminforth.registerMenuContribution({ item: { itemId: 'reports', type: 'page', label: 'Reports', path: '/reports', component: this.componentPath('Reports.vue'), }, placement: { after: { resourceId: 'orders' } }, }); ``` `placement` can be: - `{ position: 'first' }` - `{ position: 'last' }` - `{ before: 'usersMenuItemId' }` - `{ after: 'usersMenuItemId' }` - `{ before: { itemId: 'usersMenuItemId' } }` - `{ after: { resourceId: 'adminuser' } }` - `{ before: { path: '/reports' } }` If placement is omitted, or if the target item is not found, AdminForth appends the contributed item to the end of the top-level menu. Plugin menu contributions are additive only: - user-defined `config.menu` is not changed - plugins cannot remove or edit existing menu items through this API - contributed `itemId` must not duplicate an existing top-level menu item - this first version inserts only top-level menu items ### Dynamic menu items from plugin state If a plugin needs to add menu items at runtime, for example after a user clicks a button and creates a new dashboard, register a menu contribution provider. AdminForth calls providers every time it fetches the menu. ```ts title='./plugins/adminforth-dashboard/index.ts' async modifyResourceConfig(adminforth, resourceConfig) { super.modifyResourceConfig(adminforth, resourceConfig); adminforth.registerMenuContributionProvider(async ({ adminUser, adminforth }) => { const dashboards = await adminforth.resource('dashboards').list(); return [ { item: { itemId: 'dashboardsMenu', type: 'group', label: 'Dashboards', icon: 'flowbite:chart-pie-solid', children: dashboards.map((dashboard) => ({ itemId: `dashboard-${dashboard.id}`, type: 'page', label: dashboard.name, path: `/dashboards/${dashboard.id}`, })), }, placement: { position: 'first' }, }, ]; }); } ``` After the plugin changes the state used by the provider, call `refreshMenu` on the backend: ```ts await adminforth.resource('dashboards').create({ name: 'Sales', }); await adminforth.refreshMenu(adminUser); ``` AdminForth sends a websocket event to the current user, and the frontend refetches the menu without a page reload. Frontend components can also refresh the menu directly: ```ts import { useAdminforth } from '@/adminforth'; const { menu } = useAdminforth(); await menu.refresh(); ``` Dynamic menu items should point to routes that are already available in the SPA. If a provider returns a brand-new custom `component` path that was not known during AdminForth build, the menu item can appear, but the route will not be registered until the app is rebuilt. ## Visibility of menu items You might want to hide some menu items from the menu for some users. To do it use `visible` field in the menu item configuration: ```ts title='./index.ts' { ... menu: [ { label: 'Categories', icon: 'flowbite:folder-duplicate-outline', resourceId: 'categories', //diff-add visible: adminUser => adminUser.dbUser.role === 'admin' }, ], ... } ``` > πŸ‘† Please note that this will just hide menu item for non `admin` users, but resource pages will still be available by direct > URLs. To limit access, you should also use [allowedActions](/docs/tutorial/Customization/limitingAccess/#disable-full-access-to-resource-based-on-logged-in-user-record-or-role) field in the resource configuration in addition to this. ## Gap You can put one or several gaps between menu items: ```ts title='./index.ts' { ... menu: [ { label: 'Posts', icon: 'flowbite:book-open-outline', resourceId: 'posts', }, { type: 'gap', }, { type: 'gap', }, { label: 'Categories', icon: 'flowbite:folder-duplicate-outline', resourceId: 'categories', }, ], ... } ``` ## Divider To split menu items with a line you can use a divider: ```ts title='./index.ts' { ... menu: [ { label: 'Posts', icon: 'flowbite:book-open-outline', resourceId: 'posts', }, { type: 'divider', }, { label: 'Categories', icon: 'flowbite:folder-duplicate-outline', resourceId: 'categories', }, ] ... } ``` ## Heading You can add a heading to the menu: ```ts title='./index.ts' { ... menu: [ { type: 'heading', label: 'Editings', }, { label: 'Posts', icon: 'flowbite:book-open-outline', resourceId: 'posts', }, { label: 'Categories', icon: 'flowbite:folder-duplicate-outline', resourceId: 'categories', }, ], ... } ``` ## Badge You can add a badge near the menu item title (e.g. to get count of unread messages). To do this, you need to add a `badge` field to the menu item configuration: ```ts title='./index.ts' { ... menu: [ { label: 'Posts', icon: 'flowbite:book-open-outline', resourceId: 'posts', badge: async (adminUser: AdminUser) => { return 10 }, badgeTooltip: 'New posts', // explain user what this badge means ... }, ], ... } ``` Badge function is async, but all badges are loaded in "lazy" to not block the menu rendering. ### Refreshing the badges Most times you need to refresh the badge from some backend API or hook. To do this you can do next: 1) Add `itemId` to menu item to identify it: ```ts title='./index.ts' { ... menu: [ { label: 'Posts', icon: 'flowbite:book-open-outline', resourceId: 'posts', //diff-add itemId: 'postsMenuItem', //diff-add badge: async (adminUser: AdminUser, adminForth: IAdminForth) => { //diff-add const newCount = await adminforth.resource('posts').count(Filters.EQ('verified', false)); //diff-add return newCount; }, badgeTooltip: 'Unverified posts', // explain user what this badge means ... }, ], ... } ``` 2) On backend point where you need to refresh the badge, you can publish a message to the websocket topic: ```ts title='./index.ts' { resourceId: 'posts', table: 'posts', hooks: { edit: { //diff-add afterSave: async ({ record, adminUser, resource, adminforth }) => { //diff-add adminforth.refreshMenuBadge('postsMenuItem', adminUser); //diff-add return { ok: true } //diff-add } } } } ``` > πŸ‘† Please note that any `/opentopic/` publish can be listened by anyone without authorization. If count published in this channel might be > a subject of security or privacy concerns, you should add [publish authorization](/docs/tutorial/Customization/websocket/#publish-authorization) to the topic. More rare case when you need to refresh menu item from the frontend component. You can achieve this by calling the next method: ```typescript import { useAdminforth } from '@/adminforth'; const { menu } = useAdminforth(); menu.refreshMenuBadges() ``` ## Avatars If you want your user to have custom avatar you can use avatarUrl: ```ts title='./index.ts' auth: { ... avatarUrl: async (adminUser)=> { return `https://${process.env.STORAGE_PROVIDER_PATH}/${adminUser.dbUser.avatar_path}` }, ... } ``` This syntax can be use to get unique avatar for each user of hardcode avatar, but it makes more sense to use it with [upload plugin](https://adminforth.dev/docs/tutorial/Plugins/upload/#using-plugin-for-uploading-avatar) ## Custom URL You can use the url property to override default navigation. This is useful for linking to pre-filtered lists or external sites. ```ts title='./index.ts' menu: [ { label: 'Posts', icon: 'flowbite:book-open-outline', resourceId: 'posts', //diff-add url: '/resource/aparts?filter__country__in=["DE"]', //diff-add isOpenInNewTab: true // You can also add isOpenInNewTab: true to open the link in a new browser tab }, ], ``` > πŸ‘† Please note start internal URLs with a leading / to ensure correct routing. ## Adding menu items from plugins Plugins can add top-level menu items without mutating the user-defined `config.menu`. This keeps the application menu owned by the app configuration, while plugins can contribute their own entries. Use `registerMenuContribution` from a plugin's `modifyResourceConfig`: ```ts title='./plugins/adminforth-dashboard/index.ts' async modifyResourceConfig(adminforth, resourceConfig) { super.modifyResourceConfig(adminforth, resourceConfig); adminforth.registerMenuContribution({ item: { itemId: 'dashboard', type: 'page', label: 'Dashboard', icon: 'flowbite:chart-pie-solid', path: '/dashboard', component: this.componentPath('Dashboard.vue'), }, placement: { before: { resourceId: 'adminuser' } }, }); } ``` Supported placements: ```ts adminforth.registerMenuContribution({ item: { itemId: 'dashboard', type: 'page', label: 'Dashboard', path: '/dashboard', component: this.componentPath('Dashboard.vue'), }, placement: { position: 'first' }, }); adminforth.registerMenuContribution({ item: { itemId: 'reports', type: 'page', label: 'Reports', path: '/reports', component: this.componentPath('Reports.vue'), }, placement: { after: { resourceId: 'orders' } }, }); ``` `placement` can be: - `{ position: 'first' }` - `{ position: 'last' }` - `{ before: 'usersMenuItemId' }` - `{ after: 'usersMenuItemId' }` - `{ before: { itemId: 'usersMenuItemId' } }` - `{ after: { resourceId: 'adminuser' } }` - `{ before: { path: '/reports' } }` If placement is omitted, or if the target item is not found, AdminForth appends the contributed item to the end of the top-level menu. Plugin menu contributions are additive only: - user-defined `config.menu` is not changed - plugins cannot remove or edit existing menu items through this API - contributed `itemId` must not duplicate an existing top-level menu item - this first version inserts only top-level menu items ### Dynamic menu items from plugin state If a plugin needs to add menu items at runtime, for example after a user clicks a button and creates a new dashboard, register a menu contribution provider. AdminForth calls providers every time it fetches the menu. ```ts title='./plugins/adminforth-dashboard/index.ts' async modifyResourceConfig(adminforth, resourceConfig) { super.modifyResourceConfig(adminforth, resourceConfig); adminforth.registerMenuContributionProvider(async ({ adminUser, adminforth }) => { const dashboards = await adminforth.resource('dashboards').list(); return [ { item: { itemId: 'dashboardsMenu', type: 'group', label: 'Dashboards', icon: 'flowbite:chart-pie-solid', children: dashboards.map((dashboard) => ({ itemId: `dashboard-${dashboard.id}`, type: 'page', label: dashboard.name, path: `/dashboards/${dashboard.id}`, })), }, placement: { position: 'first' }, }, ]; }); } ``` After the plugin changes the state used by the provider, call `refreshMenu` on the backend: ```ts await adminforth.resource('dashboards').create({ name: 'Sales', }); await adminforth.refreshMenu(adminUser); ``` AdminForth sends a websocket event to the current user, and the frontend refetches the menu without a page reload. Frontend components can also refresh the menu directly: ```ts import { useAdminforth } from '@/adminforth'; const { menu } = useAdminforth(); await menu.refresh(); ``` Dynamic menu items should point to routes that are already available in the SPA. If a provider returns a brand-new custom `component` path that was not known during AdminForth build, the menu item can appear, but the route will not be registered until the app is rebuilt. --- ## Data API AdminForth Data API is a minimal set of methods to manipulate the data in the database. With Data API you can make very basic operations like `get`, `list`, `create`, `update`, `delete`, `count` on the resources. ## Motivation Since AdminForth has internal DataSource Connectors with unified & secure interface to work with different databases, we decided why not to expose this interface to you. This allows you to make basic operations on the data with AdminForth without using 3rd party ORMs or writing manual SQL queries. > ☝️ For advanced operations like generating aggregations, joins and other complex queries you should use your own ORM or query builder. ## Usage Basically you just import `Filters`, `Sorts` from the `adminforth` package and call the awaitable methods on the `admin.resource('adminuser')`. ```ts import { Filters, Sorts } from 'adminforth'; ... const admin = new AdminForth({ ... }); // get the resource object await admin.resource('adminuser').get(Filters.EQ('id', '1234')); ``` Here we will show you how to use the Data API with simple examples. ## Get one item from database Signature: ```ts .get( filters: , ): Promise ``` Get item by ID: ```ts const user = await admin.resource('adminuser').get( [Filters.EQ('id', '1234')] ); ``` Check School with name 'Hawkins Elementary' exits in DB ```ts const schoolExists = !!(await admin.resource('schools').get( [Filters.EQ('name', 'Hawkins Elementary')] )); ``` Get user with name 'John' and role not 'SuperAdmin' ```ts const user = await admin.resource('adminuser').get( Filters.EQ('name', 'John'), Filters.NEQ('role', 'SuperAdmin') ); ``` ## Get list of items from database Signature: ```ts .list( filters: , limit: number | null offset: number | null sort: [] ): Promise ``` Get 15 latest users which role is not Admin: ```ts const users = await admin.resource('adminuser').list( [Filters.NEQ('role', 'Admin')], 15, 0, Sorts.DESC('createdAt') ); ``` Get 10 oldest users (with highest age): ```ts const users = await admin.resource('adminuser').list([], 10, 0, Sorts.ASC('age')); ``` Get next page of oldest users: ```ts const users = await admin.resource('adminuser').list([], 10, 10, Sorts.ASC('age')); ``` Get 10 schools, sort by rating first, then oldest by founded year: ```ts const schools = await admin.resource('schools').list( [], 10, 0, [Sorts.DESC('rating'), Sorts.ASC('foundedYear')] ); ``` Get all users that have gmail address AND the ones created not in 2024 ```ts const users = await admin.resource('adminuser').list( Filters.AND( Filters.LIKE('email', '@gmail.com'), Filters.OR( Filters.LT('createdAt', '2024-01-01T00:00:00.000Z'), Filters.GTE('createdAt', '2025-01-01T00:00:00.000Z'), ), ) ); ``` ## Using a raw SQL in queries. Rarely you might want to add condition for some exotic SQL but still want to keep the rest of API. Technically it happened that AdminForth allows you to do this also ```js const minUgcAge = 18; const usersWithNoUgcAccess = await admin.resource('adminuser').list( [ Filters.NEQ('role', 'Admin'), { insecureRawSQL: `(user_meta->>'age') < ${sqlstring.escape(minUgcAge)}` } ], 15, 0, Sorts.DESC('createdAt') ); ``` This will produce next SQL query: ``` SELECT * FROM "adminuser" WHERE "role" != 'Admin' AND (user_meta->>'age') < 18 ORDER BY "createdAt" DESC LIMIT 15 OFFSET 0; ``` Finds users with age less then 18 from meta field which should be a JSONB field in Postgress. ## Create a new item in database Signature: ```ts .create({ }): Promise ``` Returns value representing created item with all fields, including fields which were populated with `fillOnCreate`. Create a new school: ```ts await admin.resource('schools').create({ name: 'Hawkins Elementary', rating: 5, foundedYear: 1950, }); ``` ## Count items in database Signature: ```ts .count( filters: , ): Promise ``` Returns number of items in database which match the filters. Count number of schools with rating above 4: ```ts const schoolsCount = await admin.resource('schools').count(Filters.GT('rating', 4)); ``` Create data for daily report with number of users signed up daily for last 7 days: Note: while this is not the most efficient way to do this, it's a good example of how you can use `count` method to get the data for the report. Plus it still should be fast enough while you have index on `createdAt` field. ```ts const dailyReports = await Promise.all( Array.from({ length: 7 }, (_, i) => { const dateStart = new Date(); dateStart.setDate(dateStart.getDate() - i); dateStart.setHours(0, 0, 0, 0); const dateEnd = new Date(dateStart); dateEnd.setDate(dateEnd.getDate() + 1); return admin.resource('adminuser').count( [Filters.GTE('createdAt', dateStart.toISOString()), Filters.LT('createdAt', dateEnd.toISOString())] ); }) ); ``` ## Update item in database Signature: ```ts .update( primaryKey: string, // value of field marked as primaryKey in resource configuration { } ): Promise ``` Update school rating to 4.8 ```ts await admin.resource('schools').update('1234', { rating: 4.8 }); ``` ## Delete item from database Signature: ```ts .delete( primaryKey: string, // value of field marked as primaryKey in resource configuration ): Promise ``` Delete school with ID '1234' ```ts await admin.resource('schools').delete('1234'); ``` ## Performance considerations Remember that AdminForth never creates an indexes on the database, so it is your responsibility to create them whether you need to speed up the queries created from this Data API or to make AdminForth UI faster. On low data volumes you will not notice the difference in performance with or without indexes, but on high data volumes it might be very and very crucial. Golden rule: create one index per query you are going to use often or where you see the performance issues. For example if you have two queries: ```ts const users = await admin.resource('adminuser').list( [Filters.NEQ('role', 'Admin')], 15, 0, Sorts.DESC('createdAt') ); const users = await admin.resource('adminuser').list( [Filters.EQ('name', 'John'), Filters.NEQ('role', 'SuperAdmin')] ); ``` You have to create two different indexes: ```sql CREATE INDEX idx_users_role ON users(role, createdAt); CREATE INDEX idx_users_name_role ON users(name, role); ``` Create INDEX is just for example, you have to use your migrator / ORM to create indexes in your database. First one covers performance for the first query, second one for the second query. If you did not understand how indexes are created: **get sorted tuple of all fields in filters + all fields in sort, in order they appear in filters and sort**. ## Get aggregated data from database The aggregate method allows you to compute statistical summaries over database records instead of returning raw rows. It is useful for building analytics, dashboards, charts, and reporting endpoints. You can combine: - filters (to narrow down dataset) - aggregates (to compute metrics like count, average, sum, median) - grouping (to split results by field or time periods) This lets you answer questions like: - How many apartments are listed per day? - What is the average price per country? - What is the total revenue per category? ### Available aggregates - Aggregates.count() - Aggregates.countDistinct(field) - Aggregates.avg(field) - Aggregates.sum(field) - Aggregates.min(field) - Aggregates.max(field) - Aggregates.median(field) ### Available grouping - GroupBy.Field(field, as?) - GroupBy.DateTrunc(field, unit, timezone?, as?) You can pass either one grouping rule or an array of grouping rules. When you use a single grouping rule, the grouping value is returned in `group`. When you use several grouping rules, the values are returned in `group1`, `group2`, etc. To use explicit response keys that differ from source field names, pass the optional `as` argument to the grouping constructor. For example, if you want the country group to be returned as `country_name` instead of `country`, pass it as the second `Field` argument. For `DateTrunc`, pass the explicit response key as the fourth argument: ```ts GroupBy.Field('country', 'country_name') GroupBy.DateTrunc('created_at', 'month', 'Europe/Kyiv', 'month_name') ``` Example: ```ts GroupBy.DateTrunc('created_at', 'month', 'Europe/Kyiv') ``` ### Response format Without grouping, each row contains only requested aggregate aliases: ```ts [ { count: number | string, avgPrice?: number | null, sum?: number | null, medianPrice?: number | null, } ] ``` With one grouping rule: ```ts [ { group: string, count?: number | string, avgPrice?: number | null, sum?: number | null, medianPrice?: number | null, } ] ``` With several grouping rules: ```ts [ { group1: string, group2: string, count: number | string, avgPrice?: number | null, } ] ``` With explicit grouping aliases: ```ts [ { country_name: string, month_name: string, count: number | string, uniqueOwners?: number | string, minPrice?: number | null, maxPrice?: number | null, avgPrice?: number | null, } ] ``` ### Get daily apartment stats (count, avg, sum, median) for listed apartments ```ts const rows = await admin.resource('apartments').aggregate( Filters.EQ('listed', true), { count: Aggregates.count(), avgPrice: Aggregates.avg('price'), sum: Aggregates.sum('price'), medianPrice: Aggregates.median('price'), }, GroupBy.DateTrunc('created_at', 'day', 'Europe/Kyiv'), ); ``` What’s happening here: - Filters.EQ('listed', true) β†’ only apartments that are listed (listed = true) - aggregates: count() β†’ number of records in each group avg('price') β†’ average price sum('price') β†’ total price median('price') β†’ median price - GroupBy.DateTrunc('created_at', 'day', 'Europe/Kyiv') β†’ groups data by day (with timezone applied) ### Get apartment stats grouped by country ```ts const rows = await admin.resource('apartments').aggregate( [], { count: Aggregates.count(), avgPrice: Aggregates.avg('price'), sum: Aggregates.sum('price'), medianPrice: Aggregates.median('price'), }, GroupBy.Field('country'), ); ``` What is happening here: - [] β†’ no filters (all records) - GroupBy.Field('country') β†’ grouping by country - same aggregates (count, avg, sum, median) ### Get apartment stats grouped by country and month ```ts const rows = await admin.resource('apartments').aggregate( [], { count: Aggregates.count(), uniqueOwners: Aggregates.countDistinct('owner_id'), minPrice: Aggregates.min('price'), maxPrice: Aggregates.max('price'), avgPrice: Aggregates.avg('price'), }, [ GroupBy.Field('country', 'country_name'), GroupBy.DateTrunc('created_at', 'month', 'Europe/Kyiv', 'month_name'), ], ); ``` What is happening here: - [] β†’ no filters (all records) - GroupBy.Field('country', 'country_name') β†’ groups by the `country` field and returns the value in the `country_name` key - GroupBy.DateTrunc('created_at', 'month', 'Europe/Kyiv', 'month_name') β†’ groups by month and returns the value in the `month_name` key - countDistinct('owner_id') β†’ number of unique owners in each group - min('price') and max('price') β†’ price range in each group - the result has one row per country and month combination --- ## Security Security and privacy if adminforth users is one of the most important aspects of AdminForth. ## How long does user login last? By default after authentication user session lasts for 24 hours. After that user is redirected to login page. You can tweak login cookie expiration time by setting environment `ADMINFORTH_AUTH_EXPIRESIN`. For example to set it to 1 hour: ```bash ADMINFORTH_AUTH_EXPIRESIN=1h ``` Also you can set `auth.rememberMeDuration` in the config to set how long "remember me" logins will last. For example to set it to 7 days: ```ts ./index.ts new AdminForth({ ... auth: { rememberMeDuration: '7d' // '7d' for 7 days, '24h' for 24 hours, '30m' for 30 minutes, etc. } } ``` In this case users who will check "Remember me" checkbox will be logged in for 7 days instead of 24 hours. ## Login rate limits AdminForth rate-limits login attempts by client IP using `auth.rateLimit`. Password login, OAuth login, and passkey login use the same configured limits, but each login method has its own independent rate-limit bucket. By default AdminForth uses: ```ts ['500/5m', '5000/1h', '10000/1d'] ``` You can override it in the app config: ```ts ./index.ts new AdminForth({ ... auth: { rateLimit: ['10/5m', '100/1h', '500/1d'] } }) ``` The format is `requests/period`, where period can use `s`, `m`, `h`, or `d`. Because rate limits are keyed by client IP, configure `auth.clientIpHeader` when AdminForth runs behind a trusted CDN or reverse proxy. See [Trusting client IP addresses](#trusting-client-ip-addresses). > It is important to provide '`auth.clientIpHeader`, because otherwise adminforth will automatically detect client IP in headers and if you don't use proxy, hacker can change IP like `x-forwarded-for: 1.1.1.1` in request headers and skip rate limit ## Password strength AdminForth allows to set validation RegExp based rules for any field. This can be reused for password strength validation. [Getting started](../001-gettingStarted.md) guide suggests you to set next parameters for password field: ```ts ./index.ts minLength: 8, validation: [ AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM, ], ``` So when admin user will create another user, password will be validated against next rules: - At least 8 characters - At least one uppercase letter - At least one lowercase letter - At least one number For improving requirement you might also request special character: ```ts ./index.ts minLength: 8, validation: [ AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM_SPECIAL ], ``` Also you can add custom rules. For example to prevent popular words: ```ts ./index.ts minLength: 8, validation: [ { regExp: '^(?!.*(?:qwerty|password|user|login|qwerty|123456)).*$', message: 'Password cannot contain easily guessed words' }, AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM_SPECIAL, ], ``` All rules defined in password column will be also delivered to [password reset plugin](../09-Plugins/07-email-password-reset.md) if you are using it to ensure that password reset will also respect same rules. ## Trusting client IP addresses Adminforth provides `admin.auth.getClientIp(headers)` function to get client IP address. This function is used for: - Rate limiting in some standard plugins like those who are using OpenAI to protect against abuse - Securely logging user actions e.g. in Audit Log plugin - Can be used by your own code e.g. hooks to write first login/last login IP address to db By default it reads `X-Forwarded-For` header from request headers to determine client IP address. AdminForth does not understand whether this header can be trusted or no. In some cases it might be spoofed by client like this: ``` curl -H "X-Forwarded-For: " http://your-server/api... ``` So you should take additional care to make sure that this header is not spoofed (see below for examples and best practices depending on your setup). ### Using proxing CDNs If you are using proxying CDN like Cloudflare in front of your app, which is probably best approach for security and performance, you should do 3 things: 1) Make sure you see "orange cloud icon" in Cloudflare on domain serving app to proxy all traffic through Cloudflare. 2) Set config auth.clientIpHeader to the header that CDN uses to pass client IP address. For Cloudflare it is `CF-Connecting-IP`: ```ts ./index.ts new AdminForth({ ... auth: { clientIpHeader: 'CF-Connecting-IP' } } ``` 3) Adittionally ensure that your server is only accepting requests from CDN IP addresses (& ranges) and block all other IPs. For Cloudflare this info is here: https://www.cloudflare.com/ips/ . You can set it in firewall e.g. in AWS security group, or in software firewall like UFW. There is even a script which you can use in cron https://github.com/Paul-Reed/cloudflare-ufw (just in case if list of IPs will change in future). ### Using reverse proxy If first client-facing point is reverse proxy like Nginx or Traefik and there is no more proxies like CDN behind it, you should take care of reliably setting `X-Forwarded-For` header to the client IP address. Main trick here is to make sure that proxy strips any `X-Forwarded-For` headers that are already present in the requeqst and hardly overwrites it with client IP address coming from TCP connection (so it can't be spoofed by client). In Traefik to set `X-Forwarded-For` header you should set `forwardedHeaders` in traefik config: ```yaml adminforth: build: ./app environment: - NODE_ENV=production ... labels: //diff-add - "traefik.http.middlewares.sanitize-headers.headers.customRequestHeaders.X-Forwarded-For=$remote_addr" //diff-add # enable middleware //diff-add - "traefik.http.routers.adminforth.middlewares=sanitize-headers" ``` For nginx you should set `proxy_set_header X-Forwarded-For $remote_addr;` in your nginx config: ```nginx server { ... location / { proxy_pass http://localhost:3000; //diff-add proxy_set_header X-Forwarded-For $remote_addr; } } ``` ### Backend-only fields Some fields should never be accessed on frontend. For example, `hashed_password` field which is always created using CLI initial app, should never be passed to frontend due to security reasons. If any user of system can read `hashed_password` of another user, it can lead to account compromise. To eliminate it we have 2 options: 1) Do not list `password_hash` in the `columns` array of the resource. Basic mantra: If AdminForth knows nothing about field it will never pass this field to frontend and will never "touch" it in any way. 2) Define `password_hash` in columns way but set `backendOnly`. This allows adminforth to provide full support for this field in backend but will never pass it to frontend. The second option is more explicit and should be preferred. This option is used by default in CLI-bootstrapped projects: ```ts { name: 'password_hash', type: AdminForthDataTypes.STRING, showIn: { all: false }, backendOnly: true, // will never go to frontend } ``` #### Dynamically hide fields depending on user ACL / role You can use `column.showIn` to show or hide column for user depending on his role. However even if `showIn` value (or value returned by showIn function) is `false`, record value will still go to frontend and will be visible in the Network tab, so advanced user can still access field value. We did it in this way to provide AdminForth developers with ability to quickly use any record field in custom components. However if you need securely hide only certain fields depending on role, you should use `column.backendOnly` and pass function there. Let's consider example: ```ts { name: 'email', type: AdminForthDataTypes.STRING, showIn: { //diff-add all: false, //diff-add list: ({ adminUser }: { adminUser: AdminUser }) => adminUser.dbUser.role === 'superadmin', }, } ``` So if you will configure the email column in user resource like this, only superadmin will be able to see emails, and only in the list view. However, the email will still be present in the record and can be accessed by advanced users through the Network tab. So to completely hide the email field from all users apart superadmins, you should use `column.backendOnly` and pass a function there. ```ts { name: 'email', type: AdminForthDataTypes.STRING, //diff-add backendOnly: ({ adminUser }: { adminUser: AdminUser }) => adminUser.dbUser.role === 'superadmin', showIn: { all: false, list: ({ adminUser }: { adminUser: AdminUser }) => adminUser.dbUser.role === 'superadmin', }, } ``` So if you will configure the email column in user resource like this, only superadmin will be able to see emails, and only in the list view. ## Custom user authorization hook Default user authorization checks that cookie with JWT token is valid, signed and not expired. You can use custom hook to decide whether to allow exections of all default and cusotm API endpoints (wraped by authorize middleware) based on user fields. ```ts title="./index.ts" export const admin = new AdminForth({ ... auth: { adminUserAuthorize: [ async ({adminUser, adminforth, extra}) => { if (adminUser.dbUser.status === 'banned') { return { allowed: false, error: "User is banned" }; } return { allowed: true }; } ] } ... }) ``` Now, if a user’s field `status` is changed to "banned", they won’t be able to perform any actions and moreover will be automatically logged out upon accessing the page. ## RateLimiter for API ### Import ```ts import { RateLimiter } from "adminforth"; ``` ### Usage ```ts import { RateLimiter } from "adminforth"; const UserRateLimiter = new RateLimiter("20/1d"); app.post( `${ADMIN_BASE_URL}/api/some-api/`, admin.express.authorize(async (req: any, res: any) => { const allowed = await UserRateLimiter.consume(req.user.id); if (!allowed) { res.status(429).json({ error: "Rate limit exceeded" }); return; } // your API logic here }) ); ``` ### Limit format "20/1d" This means that a user is allowed to make up to 20 requests within one day, and once this limit is reached, any further requests will be blocked until the 24-hour period resets. ### Supported time units - s β†’ seconds (10s) - m β†’ minutes (5m) - h β†’ hours (1h) - d β†’ days (1d) > ☝ Π‘onsume(key) is used to check whether a specific key such as a userId, IP address, or any other identifier has exceeded its allowed request limit. If the limit has not been reached, it returns true, meaning the request is allowed to proceed. --- ## Standard pages tuning ## Fields Grouping In some cases, you may want to organize data fields into specific groups for better structure and clarity. For example, you could create a "Main Info" group to include columns like title, description, country, and apartment_image. Another group, "Characteristics," could hold attributes such as price, square_meter, number_of_rooms, and listed. Any values without a specified group will be categorized under "Other. ```typescript title="./resources/apartments.ts" export default { ... options: { ... //diff-add fieldGroups: [ //diff-add { //diff-add groupName: 'Main info', //diff-add columns: ['id','title', 'description', 'country'] //diff-add }, //diff-add { //diff-add groupName: 'Characteristics', //diff-add columns: ['price', 'square_meter', 'number_of_rooms', "listed"] //diff-add } //diff-add ], } } ``` Here is how it looks: ![alt text]() You can hide the group title by setting `noTitle` to `true`. ```typescript title="./resources/apartments.ts" export default { ... options: { ... fieldGroups: [ { groupName: 'Main info', columns: ['id','title', 'description', 'country'] //diff-add noTitle: true, }, { groupName: 'Characteristics', columns: ['price', 'square_meter', 'number_of_rooms', "listed"] } ], } } ``` You can also specify on which page you want to create groups. ```typescript title="./resources/apartments.ts" export default { ... options: { //diff-add createFieldGroups: [ //diff-add { //diff-add groupName: 'Main info', //diff-add columns: ['id','title'] //diff-add }, //diff-add { //diff-add groupName: 'Characteristics', //diff-add columns: ['description', 'country', 'price', 'square_meter', 'number_of_rooms', "listed"] //diff-add } //diff-add ], } } ``` ## List ### Default Sorting ```typescript title="./resources/apartments.ts" import { AdminForthSortDirections } from 'adminforth'; ... export default { resourceId: 'aparts', options: { //diff-add defaultSort: { //diff-add columnName: 'created_at', //diff-add direction: AdminForthSortDirections.asc, //diff-add } } } ``` ### Sticky column You can make a column sticky in the list view by setting `listSticky` to `true`. This keeps the column visible when horizontally scrolling through the table, which is particularly useful for important columns like titles or IDs that should always remain in view. ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', ... columns: [ { name: "title", //diff-add listSticky: true, ... }, ... ] } ``` >⚠️ Please note that sticky columns can only be applied to one column per resource. ### Custom list column class You can add a custom CSS class to any list column with `listCssClass`. AdminForth applies it to both the header cell and the data cells for that column. Static Tailwind utility classes used here are collected into the generated Tailwind safelist during bundling, so you can use normal utility strings in the resource config. ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', ... columns: [ { name: "price", listCssClass: "text-right font-semibold min-w-10", }, ... ] } ``` ### Conditional display You can conditionally display columns in forms and views based on the values of other fields in the current record using the `showIf` property. This enables dynamic layouts that automatically adapt to user input, creating more intuitive and context-aware interfaces. ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', columns: [ { name: 'apartment_type', enum: [ { value: 'studio', label: 'Studio' }, { value: 'apartment', label: 'Apartment' }, { value: 'penthouse', label: 'Penthouse' } ] }, { name: 'number_of_rooms', type: AdminForthDataTypes.INTEGER, //diff-add showIf: { apartment_type: { $not: 'studio' } } }, { name: 'has_balcony', type: AdminForthDataTypes.BOOLEAN, //diff-add showIf: { apartment_type: 'penthouse' } } ] } ``` #### Logical Operators Use `$and` and `$or` operators to create complex conditional logic: ```typescript title="./resources/apartments.ts" export default { columns: [ { name: 'premium_features', type: AdminForthDataTypes.JSON, //diff-add showIf: { //diff-add $and: [ //diff-add { price: { $gte: 500000 } }, //diff-add { apartment_type: { $in: ['penthouse', 'apartment'] } } //diff-add ] //diff-add } }, { name: 'discount_reason', type: AdminForthDataTypes.STRING, //diff-add showIf: { //diff-add $or: [ //diff-add { price: { $lt: 100000 } }, //diff-add { listed: false } //diff-add ] //diff-add } } ] } ``` #### Comparison Operators Use various comparison operators for numeric and string fields: ```typescript title="./resources/apartments.ts" export default { columns: [ { name: 'luxury_amenities', //diff-add showIf: { square_meter: { $gt: 100 } } }, { name: 'budget_options', //diff-add showIf: { price: { $lte: 200000 } } }, { name: 'special_offers', //diff-add showIf: { country: { $nin: ['US', 'GB'] } } } ] } ``` #### Array Operators For fields that contain arrays, use array-specific operators: ```typescript title="./resources/apartments.ts" export default { columns: [ { name: 'pet_policy', //diff-add showIf: { amenities: { $includes: 'pet_friendly' } } }, { name: 'security_deposit', //diff-add showIf: { features: { $nincludes: 'furnished' } } } ] } ``` #### Available Operators The following operators are available for use in `showIf` conditions: **Equality Operators:** - `$eq` - Equal to (default if no operator specified) - `{ price: { $eq: 100000 } }` or `{ price: 100000 }` - `$not` - Not equal to - `{ apartment_type: { $not: 'studio' } }` **Comparison Operators:** - `$gt` - Greater than - `{ square_meter: { $gt: 100 } }` - `$gte` - Greater than or equal to - `{ price: { $gte: 500000 } }` - `$lt` - Less than - `{ price: { $lt: 100000 } }` - `$lte` - Less than or equal to - `{ price: { $lte: 200000 } }` **Array Operators:** - `$in` - Value is in array - `{ apartment_type: { $in: ['penthouse', 'apartment'] } }` - `$nin` - Value is not in array - `{ country: { $nin: ['US', 'GB'] } }` - `$includes` - Array includes value - `{ amenities: { $includes: 'pet_friendly' } }` - `$nincludes` - Array does not include value - `{ features: { $nincludes: 'furnished' } }` **Logical Operators:** - `$and` - Logical AND operation - `{ $and: [{ price: { $gte: 500000 } }, { listed: true }] }` - `$or` - Logical OR operation - `{ $or: [{ price: { $lt: 100000 } }, { listed: false }] }` > ⚠️ **Warning**: When using `showIf` with complex conditions, ensure that: > - `$and` and `$or` operators contain arrays of conditions > - `$in` and `$nin` operators contain arrays of values > - `$includes` and `$nincludes` operators are only used on columns marked as arrays (`isArray: { enabled: true }`) ### Page size use `options.listPageSize` to define how many records will be shown on the page ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', options: { ... //diff-add listPageSize: 10, } } ] ``` ### List Page Size Options You can define available pagination sizes using options.listPageSizeOptions. This allows users to choose how many records they want to see per page in the list view. ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', options: { ... listPageSize: 10, // listPageSizeOptions can be a static array //diff-add listPageSizeOptions: [10, 20, 50], // OR a function for dynamic options based on user role //diff-add listPageSizeOptions: ({ adminUser }) => { //diff-add if (adminUser?.dbUser?.role === 'superadmin') { //diff-add return [50, 100, 500]; //diff-add } //diff-add return [10, 20, 50]; //diff-add }, } } ] ``` #### How it works - listPageSize defines the default number of records per page when the list is opened. - listPageSizeOptions defines the available page size options shown to the user. For example: listPageSizeOptions: [10, 20, 50] will allow switching between 10 / 20 / 50 records per page. #### UI behavior Page size switching is implemented via a select dropdown (select input) in the table pagination controls. - User opens the select - Chooses a value (e.g. 20) - Table reloads with the new page size > ☝️Notes If `listPageSizeOptions` is not provided (or resolves to an empty array), the page size select is not shown. The selected value updates the table immediately and triggers a data refetch. Use `listPageSize` to define the initial number of records per page, and `listPageSizeOptions` to define which page sizes the user can switch between. ### Virtual scroll Set `options.listVirtualScrollEnabled` to true to enable virtual scrolling in the table. The default value is false. Enable this option if you need to display a large number of records on a single page. ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', options: { ... //diff-add listVirtualScrollEnabled: true, } } ] ``` Additionally, you can configure `options.listBufferSize` to specify the number of rows to buffer for virtual scrolling. The default value is 30 rows. ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', options: { ... listVirtualScrollEnabled: true, //diff-add listBufferSize: 20, } } ] ``` ### Custom row click action By default, when you click on a record in the list view, the show view will be opened. You can change this behavior by using `options.listTableClickUrl`. To disable any action (don't open show) return null: ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', options: { ... //diff-add listTableClickUrl: async (record, adminUser, resource) => null, } } ] ``` To open a custom page, return URL to the custom page (can start with https://, or relative adminforth path): ```typescript title="./resources/apartments.ts" options: { ... //diff-add listTableClickUrl: async (record, adminUser, resource) => { //diff-add return `https://google.com/search?q=${record.title}`; //diff-add } } ``` If you wish to open the page in a new tab, add `target=_blank` get param to the returned URL: ```typescript title="./resources/apartments.ts" options: { ... //diff-add listTableClickUrl: async (record, adminUser, resource) => { //diff-add return `https://google.com/search?q=${record.name}&target=_blank`; //diff-add } } ``` ### How to open edit instead of show by click in list table By using `options.listTableClickUrl` you can open edit view by clicking record, instead of show view: ```ts options: { ... //diff-add listTableClickUrl: async (record, adminUser, resource) => { //diff-add return `/resource/${resource.resourceId}/edit/${record[pkFileldName]}`; //diff-add } } ``` ### Auto-refresh records `options.listRowsAutoRefreshSeconds` might be used to silently refresh records that are loaded (no new records will be fetched if they appear) ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', hooks: { //diff-add list: { //diff-add afterDatasourceResponse: async ({ response }: { response: any }) => { //diff-add response.forEach((r: any) => { //diff-add // substitute random country on any load //diff-add const countries = [ 'US', 'DE', 'FR', 'GB', 'NL', 'IT', 'ES', 'DK', 'PL', 'UA', //diff-add 'CA', 'AU', 'BR', 'JP', 'CN', 'IN', 'KR', 'TR', 'MX', 'ID'] //diff-add r.country = countries[Math.floor(Math.random() * countries.length)]; //diff-add }) //diff-add return { ok: true, error: "" } //diff-add } //diff-add } }, options: { ... //diff-add listRowsAutoRefreshSeconds: 1, } } ] ``` ![alt text]() ### Move base actions out of three dots menu If you want to move base record actions from the three dots menu, you can add `baseActionsAsQuickIcons`: ```ts options: { ... baseActionsAsQuickIcons: ['edit'], ... } ``` And `edit` action will be available as quick action: ![alt text]() ## Show ### Next record button By default, when a user opens a record from the list view, a **Next** button appears on the show page. It allows navigating through records one by one, respecting the current filters and sorting applied in the list. When the user reaches the last record on the current page, AdminForth automatically fetches the next page and continues navigation seamlessly. To disable the Next button for a resource, set `showNextButton` to `false`: ```typescript title="./resources/apartments.ts" export default { resourceId: 'aparts', options: { //diff-add showNextButton: false, } } ``` > ☝️ The Next button is only shown when the user navigates to the show page from the list view. Opening a record directly via URL will not display the button. ## Creating ### Fill with default values Sometimes you want to generate some field value without asking user to fill it. For example createdAt oftenly store time of creation of the record. You can do this by using `fillOnCreate`: ```typescript title="./resources/apartments.ts" export default { name: 'apartments', fields: [ ... { name: 'created_at', type: AdminForthDataTypes.DATETIME, //diff-add showIn: { //diff-add all: true, //diff-add create: false, // don't show field in create form //diff-add }, //diff-add fillOnCreate: ({ initialRecord, adminUser }) => (new Date()).toISOString(), }, ], }, ... ], ``` Also you can assign adminUser ID by `adminUser.dbUser.id`: ```typescript title="./resources/apartments.ts" export default { name: 'apartments', fields: [ ... { name: 'created_by', type: AdminForthDataTypes.STRING, //diff-add showIn: { //diff-add all: true, //diff-add create: false, // don't show field in create form //diff-add }, //diff-add fillOnCreate: ({ initialRecord, adminUser }) => adminUser.dbUser.id, }, ], }, ... ], ``` > Same effect can be achieved by using [hooks](/docs/tutorial/Customization/hooks/#example-modify-the-created-object-before-it-is-saved-to-the-database). But `fillOnCreate` might be shorter and more readable. ### Suggest default value in create form You can suggest a default value for a field in the create form which user can instantly change even before creating record. This might be used to give user some example value or to suggest some default value. ```typescript title="./resources/apartments.ts" export default { name: 'apartments', fields: [ ... { name: 'description', //diff-add suggestOnCreate: 'Great apartment in the heart of the city', }, ], }, ... ``` A difference between `fillOnCreate` and `suggestOnCreate`: * `fillOnCreate` is called on the backend when the record is saved to a database. Value returned by `fillOnCreate` will be saved to the database. * `suggestOnCreate` is just a single value that will be substituted in create form. User can change it before saving the record. * `fillOnCreate` should be used when `showIn.create` is a `false` value because if it is `true`, the input will be shown in the create form but then(during actual save to db) it will be overwritten by the value returned by `fillOnCreate`. * `suggestOnCreate` should be used with `showIn.create` set to true because if it is not set, the input will not be shown in the create form and default suggestion will not make sense. ### Normalize values before saving Use the column-level `normalize` callback when a value must always be stored in a canonical form. For example, you can trim an email address and make it lowercase: ```typescript title="./resources/adminuser.ts" export default { resourceId: 'adminuser', columns: [ // ... { name: 'email', required: true, isUnique: true, type: AdminForthDataTypes.STRING, //diff-add normalize: (value: string) => value.trim().toLowerCase(), }, ], }; ``` AdminForth applies `normalize` on the backend whenever the column is present in a create or update payload. This includes records saved from the admin UI and records written with the [Data API](/docs/tutorial/Customization/dataApi). Normalization happens before backend validation and `beforeSave` hooks, so both receive the normalized value. When `normalize` is configured on the column selected by `auth.usernameField`, AdminForth also normalizes the submitted username before looking up the user. The [Email Password Reset plugin](/docs/tutorial/Plugins/email-password-reset) applies the normalizer configured on its `emailField` throughout the reset flow as well. In the example above, a user saved as `admin@example.com` can therefore sign in or request a password reset with ` ADMIN@EXAMPLE.COM `. `normalize` does not transform existing database records or general filter/search values. Migrate existing data first if it is not already stored in the same canonical form. ### Link to create form with preset values Sometimes you might need to create a link that will open the create form with some fields pre-filled. For example, you might want to create a link that will open the create form with the realtor_id field pre-filled with the current user's ID. ```html title="./resources/Dashboard.vue ``` ALso if you want to disable ability to change such fields (but keep them as readonly) you can add `readonlyColumns` to the link: ```html title="./resources/Dashboard.vue