@galadrim/galadmin

中文文档
0.3.7发布于 2026/11/12ISCTypeScriptCommonJS近 30 天下载 3177

Galadmin is a tool made by Galadrim that let you setup a pretty back-office in minutes. It works with any MySQL database.

原始使用文档
文档目录18 节
  1. Quick start
  2. Integration in a NestJS app
  3. Integration in an Express app
  4. What is adminConfig?
  5. Minimal example
  6. Configuration
  7. Two-factor authentication (TOTP)
  8. Password change and expiry
  9. Security
  10. Sidebar folders
  11. File upload on S3
  12. File upload on Google Cloud Platform (GCP)
  13. File upload on a remote server (via SFTP)
  14. Local Setup
  15. Testing
  16. Real-database integration tests
  17. Known robustness limitations
  18. Frontend tests

Galadmin

Galadmin is a tool made by Galadrim that let you setup a pretty back-office in minutes. It works with any MySQL database.

Quick start

npm install @galadrim/galadmin

Integration in a NestJS app

Put this piece of code in your src/main.ts file:

import galadmin from '@galadrim/galadmin';
import { Config } from '@galadrim/galadmin/dist/types/config';

const adminConfig: Config = { ... }

app.getHttpAdapter().use('/admin', galadmin(adminConfig));

If you are using Prisma ORM, add the following code to your schema.prisma. Galadmin will create a table in your database, and Prisma won't be able to perform migrations correctly if we don't specify it.

With PostgreSQL

model galadmin_sessions {
  sid    String   @id @db.VarChar(128)
  sess   Json
  expire DateTime
}

With MySQL

model galadmin_sessions {
  session_id String  @id @db.VarChar(128)
  expires    Int     @db.UnsignedInt
  data       String? @db.MediumText
}

Integration in an Express app

const galadmin = require("@galadrim/galadmin").default;

app.use("/admin", galadmin(adminConfig));

What is adminConfig?

adminConfig is the object that contains all the configuration information about your back-office. Its properties are listed below.

Minimal example

const adminConfig = {
  name: "Mon projet",
  // Secrets come from the environment, never from the code:
  // generate the session secret with `openssl rand -hex 32`.
  sessionSecret: process.env.GALADMIN_SESSION_SECRET,
  auth: {
    username: "admin",
    password: process.env.GALADMIN_ADMIN_PASSWORD,
  },
  postgresql: {
    host: "abc.example.com",
    user: "monprojet",
    password: process.env.DB_PASSWORD,
    database: "monprojet",
  },
  // mysql: {
  //   host: "abc.example.com",
  //   user: "root",
  //   password: "password",
  //   database: "monprojet",
  // },
  views: [
    {
      type: "table",
      label: "Fruits",
      tableName: "fruits",
      columns: [
        {
          label: "Nom",
          name: "fruitName",
        },
        {
          label: "Acidité",
          name: "acidity",
        },
      ],
    },
  ],
};

Configuration

const adminConfig = {
  /** Nom du projet (OBLIGATOIRE) */
  name: "Mon projet",

  /**
   * Chaîne de caractères aléatoires qui permet de chiffrer les cookies de session (OBLIGATOIRE).
   * À générer (`openssl rand -hex 32`) et à lire depuis l'environnement, jamais en dur dans le code.
   */
  sessionSecret: process.env.GALADMIN_SESSION_SECRET,

  /** Identifiants permettant d'accéder au back-office (OBLIGATOIRE) */
  auth: {
    /** Nom d'utilisateur */
    username: "admin",

    /** Mot de passe, long et unique, lu depuis l'environnement */
    password: process.env.GALADMIN_ADMIN_PASSWORD,
  },

  /** Identifiants de connexion à la base de données (OBLIGATOIRE) */
  postgresql: {
    host: "abc.example.com",
    user: "monprojet",
    password: process.env.DB_PASSWORD,
    database: "monprojet",
  },
  // mysql: {
  //   host: "abc.example.com",
  //   user: "root",
  //   password: "password",
  //   database: "monprojet",
  // },

  /** Clé API Google Maps pour les champs adresses et l'affichage de cartes */
  googleMapsKey: "______",

  /**
   * Origine autorisée à appeler l'API avec les cookies de session depuis un autre domaine
   * (en-têtes CORS). Aucune par défaut ; "http://localhost:3000" pour le serveur de dev du front.
   */
  corsOrigin: undefined,

  /** Taille maximale, en octets, des fichiers d'un upload ou d'un import (illimitée par défaut) */
  uploadMaxFileSize: 50 * 1024 * 1024,

  /** Désactiver la sélection multiple dans les vues de type table */
  hideMultiselect: true,

  /** Cache l'inscription Made with love by Galadrim */
  hideBranding: true,

  views: [
    {
      /** Vue de type "table" */
      type: "table",

      /** Nom affiché de la vue */
      label: string,

      /** Icône FontAwesome de la vue dans le menu. user affiche l'icône fa-user. */
      icon: "user",

      /** Nom de la table MySQL */
      tableName: "users",

      /** Nombre de résultats par page */
      limit: 100,

      /**
       * Ces champs permettent de personnaliser la requête MySQL qui gère l'affichage de la table.
       * Ils sont tous optionnels.
       *
       * SELECT {selectExpression} FROM {tableName} {join} WHERE {filters} GROUP BY {groupBy} ORDER BY {orderBy} {desc ? "DESC" : ""}
       */
      selectExpression: "*, MAX(age) AS maxAge",
      join: "JOIN activities ON users.activity_id = activities.id",
      groupBy: "activity_id",
      orderBy: "maxAge",
      isDesc: true,
      filters: "age < 50",

      /** Lorsque notClickable est à true, les résultats ne sont pas cliquables. (default: false) */
      notClickable: true,

      /** Rendre possible de créer des enregistrements (default: false) */
      canCreate: true,

      /** Groupe nominal "un nouvel élément" */
      aNewSingular: "un nouvel utilisateur",

      /** Rendre possible de supprimer des enregistrements (default: true) */
      canDelete: true,

      /** Rendre possible de modifier des enregistrements (default: true) */
      canEdit: true,

      /** URL appelée en POST lors de la création d'un nouvel élément, avec l'ID de l'élément créé */
      createCallback: "https://myapi.com/createCallback",

      editCallback: "https://myapi.com/editCallback",
      deleteCallback: "https://myapi.com/deleteCallback",

      columns: [
        {
          /** Nom affiché de la colonne (OBLIGATOIRE) */
          label: "Poids",

          /** Nom de la colonne dans la table MySQL */
          name: "weight",

          /** Formatter la valeur avant de l'afficher */
          formatValue: (user) => `${user.weight} kg`,

          /** Masquer la colonne dans la liste des résultats et ne l'afficher que dans le détail (default: false) */
          hideInTable: true,
        },
      ],
      subviews: [],
      recordViewPanels: [],
    },
  ],
};

Two-factor authentication (TOTP)

Accounts declared in authRoles can use an authenticator app (Google Authenticator, Authy…). The root auth account and trustedAuth are never affected. Without twoFactor, nothing changes.

authRoles: [
  {
    role: "admin",
    table: "Admin",
    usernameField: "username",
    passwordField: "password",
    idField: "id",
    twoFactor: {
      /** Colonne texte nullable qui stocke le secret TOTP ; null = 2FA désactivée pour ce compte (OBLIGATOIRE) */
      secretField: "twoFactorSecret",

      /** Oblige chaque compte à configurer la 2FA à sa prochaine connexion, sans pouvoir la désactiver (default: false) */
      enforce: false,

      /** Nom affiché dans l'application d'authentification (default: config.name) */
      issuer: "Mon projet",

      /** Chiffre les secrets en base (AES-256-GCM). Valeur aléatoire longue, stockée hors de la base. La changer rend les secrets existants illisibles */
      encryptionKey: process.env.GALADMIN_2FA_KEY,

      /** Colonnes nullables entier + date/heure : verrouille le compte 15 minutes après 5 codes erronés */
      failedAttemptsField: "twoFactorFailedAttempts",
      lockedUntilField: "twoFactorLockedUntil",

      /** Colonne bigint nullable : refuse un code déjà utilisé */
      lastUsedStepField: "twoFactorLastUsedStep",
    },
  },
],

When 2FA is optional, users turn it on or off from "Double authentification" in the sidebar menu. To reset a user's 2FA (lost phone), set secretField back to null. Never display secretField in a view. The role's table must be writable (not a SQL view).

Without the lockout columns, a login is only sent back to the password step after 5 wrong codes, so repeated attempts are not throttled per account.

Password change and expiry

Accounts declared in authRoles can change their own password, and passwords can expire. The root auth account and trustedAuth are never affected. Without passwordPolicy, nothing changes. It works with or without twoFactor.

authRoles: [
  {
    role: "admin",
    table: "Admin",
    usernameField: "username",
    passwordField: "password",
    idField: "id",
    useArgon2: true,
    passwordPolicy: {
      /** Ajoute "Changer mon mot de passe" au menu (default: false, toujours actif avec expiry) */
      allowChangeOwn: true,

      /** Vérifie un nouveau mot de passe : renvoie le message affiché à l'utilisateur, ou null s'il est accepté (sync ou async) */
      validate: (password) => (password.length >= 12 ? null : "Le mot de passe doit contenir au moins 12 caractères"),

      expiry: {
        /** Colonne date/heure nullable : date du dernier changement ; null = changement demandé à la prochaine connexion (OBLIGATOIRE) */
        changedAtField: "passwordChangedAt",

        /** Durée de validité d'un mot de passe, en jours (OBLIGATOIRE) */
        maxAgeDays: 365,

        /** Un bandeau invite à changer de mot de passe pendant les N derniers jours (default: 30) */
        warnDays: 30,
      },
    },
  },
],

The change form asks for the current password, refuses the current one as the new one, runs validate, then stores the new password hashed with argon2 when useArgon2 is set, bcrypt otherwise. The current password must be stored hashed: an account whose password is stored as is cannot change it from galadmin.

With expiry, a login whose password is older than maxAgeDays (or whose changedAtField is null) asks for a new password before opening the back-office, after the 2FA code when there is one. Enabling it on an existing table with a null changedAtField makes every account choose a new password at its next login; fill the column with the current date first to avoid it.

validate and changedAtField only apply to passwords changed from galadmin. A password typed in a view is written as is: hash and validate it on your side, and set changedAtField (for example to null to force a change at the next login) when you do.

Security

  • Passwords of authRoles accounts must be stored hashed (bcrypt, or argon2 with useArgon2). A legacy table that still stores passwords as is needs allowPlaintextPasswords: true on its role; even then, a stored value that looks like a hash is never compared as is, and an empty password never logs in. Hash those passwords and drop the option as soon as possible.
  • Login attempts are limited: after 5 failures for a username from one address, its logins are refused for 15 minutes (root account included). The counters live in the process memory.
  • filtersFromUser and filters bound every route that reads or changes one record (/record, /updateRecord, /deleteRecord(s), /customAction, panel rows): a row the list does not show answers 404. Views built on customQuery are not bounded, since their list applies neither filter.
  • /api/config never sends the database credentials, auth, authRoles, trustedAuth, nor the s3Config / gcpConfig / sftpConfig / publicPath of the columns.
  • Uploads keep their public-read ACL on S3, but HTML, SVG, XML and JavaScript files are stored with Content-Disposition: attachment on S3 and GCP, so that opening their URL downloads them. Files written locally or over SFTP are served by your own server: do not serve those types inline from the back-office's domain.
  • Session: the cookie is HttpOnly, SameSite=Lax, and Secure when the request came over HTTPS (set app.set("trust proxy", 1) behind a TLS-terminating proxy). Each login gets a new session id; POST /api/logout logs out. The responses carry X-Frame-Options: SAMEORIGIN and X-Content-Type-Options: nosniff.

Upgrading from 0.3.6: GET /api/logout became POST, /api/adjacentRecordId (unused by the front) is gone, CORS headers need corsOrigin, and passwords stored as is need allowPlaintextPasswords.

Sidebar folders

Views can be grouped in the sidebar with type: "folder" views. A folder is never a page itself: it only groups other views, so the URLs of the grouped views stay exactly the same as if they were declared at the root.

views: [
  {
    type: "folder",

    /** Nom affiché du dossier */
    label: "Catalogue",

    /** Ouvrir le dossier par défaut (default: false).
        Un dossier contenant la vue active s'ouvre automatiquement. */
    defaultOpen: true,

    /** Vues regroupées dans le dossier (un seul niveau de dossiers) */
    views: [
      {
        type: "table",
        label: "Fruits",
        tableName: "fruits",
        columns: [
          { label: "Nom", name: "fruitName" },
          { label: "Acidité", name: "acidity" },
        ],
      },
    ],
  },
]

Notes:

  • Folders only nest one level deep (views cannot contain another folder).
  • roles / readRoles on a folder gate the visibility of the whole group, exactly like they do on a view.
  • hidden: true on a folder hides the whole group from the sidebar; the grouped views remain reachable by their URL, like hidden views.

File upload on S3

Different column types exists, one of them is the FileColumn, allowing the upload of a file onto AWS S3. A basic configuration would be as follow:

...
  columns: [{
    label: 'Image',
    name: 'imageUrl',
    dataType: 'file',
    s3Config: {
      host: process.env.AWS_S3_HOST,
      /** Optional. When omitted, the AWS SDK default credential chain is used. */
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_KEY,
      bucket: process.env.AWS_S3_PODCAST_BUCKET_NAME,
      /** Optional, only needed when your bucket isn't public and you need to generate a signed URL */
      signedUrlNeeded: true,
      /** Optional, only needed when your bucket isn't public and you need to generate a signed URL */
      region: 'eu-west-3',
    },
  }]

When accessKeyId is omitted or empty, the AWS SDK will try to resolve the credentials itself (first match wins):

  1. Env vars AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ AWS_SESSION_TOKEN), skipped if AWS_PROFILE is set
  2. Profile AWS_PROFILE (or "default") from ~/.aws/config and ~/.aws/credentials (SSO, static keys, assumed role, credential_process)
  3. Web identity token: AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN (e.g. EKS)
  4. ECS container role: AWS_CONTAINER_CREDENTIALS_RELATIVE_URI or AWS_CONTAINER_CREDENTIALS_FULL_URI
  5. EC2 instance role via the instance metadata service, unless AWS_EC2_METADATA_DISABLED is set to anything but false

It's recommended to rely on a role rather than have long-lived AWS credentials added to the server: the instance profile role on EC2/Elastic Beanstalk, or the task role (not the execution role) on ECS/Fargate. Add the following permissions to that role:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:PutObject",
      "s3:PutObjectAcl"
    ],
    "Resource": "arn:aws:s3:::<bucket>/<subfolder>/*"
  }]
}
  • Without subfolder, use arn:aws:s3:::<bucket>/* as the resource.
  • By default, files are uploaded with a public-read ACL, so the bucket must allow ACLs: its Object Ownership must not be "Bucket owner enforced" (otherwise AccessControlListNotSupported), and BlockPublicAcls must be off (otherwise AccessDenied).
  • With signedUrlNeeded: true, no ACL is set: only s3:PutObject is needed and the bucket can stay private.
  • If the bucket is encrypted with SSE-KMS, also allow kms:GenerateDataKey on its KMS key.

File upload on Google Cloud Platform (GCP)

You can also use the FileColumn to upload files to Google Cloud Storage. To use GCP instead of AWS S3, configure the gcpConfig as follows:

...
  columns: [{
    label: 'Image',
    name: 'imageUrl',
    dataType: 'file',
    gcpConfig: {
      host: process.env.GCP_STORAGE_HOST,
      bucket: process.env.GCP_BUCKET_NAME,
    },
  }]

You also need to set up the service account credentials in your .env file:

GOOGLE_APPLICATION_CREDENTIALS=service-account-file.json

Make sure the service account JSON file is in your project directory and has the necessary permissions to write to your Google Cloud Storage bucket.

File upload on a remote server (via SFTP)

It is possible to use a FileColumn to allow upload to a remote server via SFTP. A basic configuration would be as follow:

...
  columns: [{
    label: 'Image',
    name: 'imageUrl',
    dataType: 'file',
    publicPath: '/home/ubuntu/project/public',
    subdirectory: 'img',
    sftpConfig: {
      host: process.env.SFTP_HOST,
      user: process.env.SFTP_USER,
      password: process.env.SFTP_PASSWORD,
    },
  }]

Local Setup

A local playground with real MySQL + PostgreSQL containers, seed data and a demo config exercising every feature is available:

npm run playground:db && npm run playground:build
npm run playground:schema && npm run playground:seed
npm run playground          # http://localhost:8080/admin/ (admin / admin)

See playground/README.md for the accounts, the covered features and the DB=pg|mysql|mysql2 backend switch.

Testing

The backend has a regression test suite (Jest + ts-jest + supertest) covering the API routes, the authorization matrix, SQL generation for both MySQL and PostgreSQL dialects, file uploads (S3/GCP/SFTP/local), imports/exports and the login/session middleware. The route-level behavior runs against real database engines (see below); the unit suite covers pure logic and configuration wiring without any database.

npm run typecheck       # check backend TypeScript without emitting build files
npm test                # run the unit suite (no database required)
npm run test:watch      # watch mode
npm run test:coverage   # unit + integration merged report (Docker required)

Real-database integration tests

A second suite (tests/integration/) runs the same routes against real MySQL 8 (both the mysql and mysql2 drivers) and PostgreSQL 16 servers started with testcontainers. It asserts persisted state, not just emitted SQL, and requires a running Docker daemon.

npm run test:integration   # TEST_INTEGRATION=1, jest.integration.config.js
npm run test:all           # unit suite + integration suite

npm test never starts containers. npm run test:integration needs a reachable Docker daemon and fails without one, so a broken Docker setup in CI cannot pass silently.

The CI GitHub Actions workflow runs typechecking, backend unit and integration tests, and frontend tests on pull requests. The deployment workflow reuses the same workflow and only builds and publishes after it passes. To require these checks before merging, configure the CI test check as required in the target branch's protection rules.

Known robustness limitations

The former tests/security-known-gaps.test.ts suite encoded unfixed security and robustness gaps as expected-safe tests. All of its gaps have since been fixed and its tests were folded into the regular suites (routes.*.test.ts, dbManager.test.ts), so no special run is needed anymore — npm test covers them.

A few robustness limitations remain documented (not assertable over HTTP because the error escapes as an unhandled rejection under express 4):

  • A throwing parseImportFile in /recordsImport or /subrecordsImport is awaited OUTSIDE the try/catch of the multiparty callback — the 500 is never sent and the process can crash.
  • formatValueForInsertion(false) returns "NULL", so a boolean false cannot be imported.
  • The local-fs upload backend requires subdirectory (publicPath + undefined concat yields a nonexistent upload dir).
  • A throwing editCallbackFunction in /updateRecord is awaited OUTSIDE any try/catch — the update is committed but the request hangs forever.

Frontend tests

The React front (src/front) has a small smoke suite (CRA / react-scripts test) covering the pure utils, the outside-click handler, the first-view redirect and the unauthenticated App render:

cd src/front && npm test -- --watchAll=false

版本与发布记录

@galadrim/galadmin 共发布 334 个版本,首次发布于 2021/08/11;过去 12 个月发布了 21 个版本。

版本发布日期距今
0.3.7latest2026/11/124 天前
0.3.62026/10/2620 天前
0.3.52026/10/2323 天前
0.3.42026/10/2323 天前
0.3.32026/10/2324 天前
0.3.22026/10/2224 天前

其他发布通道:alpha → 0.3.0-alpha.2.1。

关于 @galadrim/galadmin 的常见问题

@galadrim/galadmin 是什么?

Galadmin is a tool made by Galadrim that let you setup a pretty back-office in minutes. It works with any MySQL database.

如何安装 @galadrim/galadmin?

在项目目录运行 npm install @galadrim/galadmin,或使用 pnpm add / yarn add / bun add。

@galadrim/galadmin 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@galadrim/galadmin 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@galadrim/galadmin 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@galadrim/galadmin 可以商用吗?

@galadrim/galadmin 使用 ISC 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@galadrim/galadmin 的下载量有多少?

根据 npm 官方下载统计,@galadrim/galadmin 在 2026/10/16 – 2026/11/14 这 30 天内被下载 3,177 次,日均约 106 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@galadrim/galadmin 还在维护吗?

最新版本 0.3.7 发布于 2026/11/12(4 天前);至今共发布 334 个版本,其中过去 12 个月发布了 21 个;首次发布于 2021/08/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。