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
authRolesaccounts must be stored hashed (bcrypt, or argon2 withuseArgon2). A legacy table that still stores passwords as is needsallowPlaintextPasswords: trueon 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.
filtersFromUserandfiltersbound 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 oncustomQueryare not bounded, since their list applies neither filter./api/confignever sends the database credentials,auth,authRoles,trustedAuth, nor thes3Config/gcpConfig/sftpConfig/publicPathof the columns.- Uploads keep their
public-readACL on S3, but HTML, SVG, XML and JavaScript files are stored withContent-Disposition: attachmenton 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, andSecurewhen the request came over HTTPS (setapp.set("trust proxy", 1)behind a TLS-terminating proxy). Each login gets a new session id;POST /api/logoutlogs out. The responses carryX-Frame-Options: SAMEORIGINandX-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 (
viewscannot contain another folder). roles/readRoleson a folder gate the visibility of the whole group, exactly like they do on a view.hidden: trueon 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):
- Env vars AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ AWS_SESSION_TOKEN), skipped if AWS_PROFILE is set
- Profile AWS_PROFILE (or "default") from ~/.aws/config and ~/.aws/credentials (SSO, static keys, assumed role, credential_process)
- Web identity token: AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN (e.g. EKS)
- ECS container role: AWS_CONTAINER_CREDENTIALS_RELATIVE_URI or AWS_CONTAINER_CREDENTIALS_FULL_URI
- 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, usearn:aws:s3:::<bucket>/*as the resource. - By default, files are uploaded with a
public-readACL, so the bucket must allow ACLs: its Object Ownership must not be "Bucket owner enforced" (otherwiseAccessControlListNotSupported), andBlockPublicAclsmust be off (otherwiseAccessDenied). - With
signedUrlNeeded: true, no ACL is set: onlys3:PutObjectis needed and the bucket can stay private. - If the bucket is encrypted with SSE-KMS, also allow
kms:GenerateDataKeyon 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
parseImportFilein/recordsImportor/subrecordsImportis 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 booleanfalsecannot be imported.- The local-fs upload backend requires
subdirectory(publicPath + undefined concat yields a nonexistent upload dir). - A throwing
editCallbackFunctionin/updateRecordis 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