Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions adminforth/commands/createApp/templates/.dockerignore.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
.env
{{#if sqliteFile}}
{{ sqliteFile }}
{{/if}}
4 changes: 4 additions & 0 deletions adminforth/commands/createApp/templates/.env.example.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Secrets live in the gitignored .env; this committed file only lists which ones each developer must create.
# create-app already wrote a random ADMINFORTH_SECRET into .env on the machine that scaffolded the project.
# On a fresh clone, generate your own — see "First-time setup" in README.md (openssl rand -hex 32).
ADMINFORTH_SECRET=
5 changes: 4 additions & 1 deletion adminforth/commands/createApp/templates/.env.hbs
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Add only sensitive local environment variables here; non-sensitive local variables should go to .env.local
# Add only sensitive local environment variables here; non-sensitive local variables should go to .env.local
# This file is gitignored. Each developer and each deployment needs its own ADMINFORTH_SECRET.

ADMINFORTH_SECRET={{{adminforthSecret}}}
1 change: 0 additions & 1 deletion adminforth/commands/createApp/templates/.env.local.hbs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Add only non-sensitive local environment variables here so all team members can use them with minimal setup
# For sensitive local environment variables, use .env and explain to team members how to set them, ideally with a .env.example

ADMINFORTH_SECRET=123
NODE_ENV=development
DEBUG_LEVEL=info
AF_DEBUG_LEVEL=info
Expand Down
2 changes: 1 addition & 1 deletion adminforth/commands/createApp/templates/adminuser.ts.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default {
{
name: 'role',
type: AdminForthDataTypes.STRING,
required: true
required: true,
enum: [
{ value: 'superadmin', label: 'Super Admin' },
{ value: 'user', label: 'User' },
Expand Down
15 changes: 13 additions & 2 deletions adminforth/commands/createApp/templates/readme.md.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Install dependencies:
```

{{#if adminUserTableInstructions}}
Prepare the admin users table in your existing database before starting the app. AdminForth uses this table for back-office authentication, and your own migration tool should own this schema change. The schema below is only an example:
Create the admin users table in your database before starting the app: AdminForth needs it for back-office authentication, and no Prisma migrations were generated for this project.

{{{adminUserTableInstructions}}}

Expand All @@ -23,6 +23,13 @@ Create the initial migration and apply it to the database:
```
{{/if}}

First-time setup on a fresh clone: `create-app` wrote a random `ADMINFORTH_SECRET` into `.env`, but that file is gitignored, so every developer and every deployment creates their own (see `.env.example`). The command below only creates `.env` when it does not exist yet:

```bash
# On Windows (PowerShell): node -e "require('fs').writeFileSync('.env', 'ADMINFORTH_SECRET=' + require('crypto').randomBytes(32).toString('hex') + '\n', { flag: 'wx' })"
[ -f .env ] || (umask 077; echo "ADMINFORTH_SECRET=$(openssl rand -hex 32)" > .env)
```

Start the server:

```bash
Expand All @@ -48,8 +55,12 @@ Your colleagues will need to pull the changes and run `{{packageManagerRun}} mig
You have Dockerfile ready for production deployment. You can test the build with:

```bash
# Generate a signing key once and keep it in your secret store (never commit it).
# On Windows without openssl: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
export ADMINFORTH_SECRET="$(openssl rand -hex 32)"

docker build -t {{appName}}-image .
docker run -p 3500:3500 -e ADMINFORTH_SECRET=123 {{#if sqliteFile}}-v $(pwd)/db:/code/db {{/if}}{{appName}}-image
docker run -p 3500:3500 -e ADMINFORTH_SECRET="$ADMINFORTH_SECRET" {{#if sqliteFile}}-v $(pwd)/db:/code/db {{/if}}{{appName}}-image
```

To set non-sensitive environment variables in production, use `.env.prod` file.
Expand Down
69 changes: 56 additions & 13 deletions adminforth/commands/createApp/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Listr } from 'listr2'
import { fileURLToPath, pathToFileURL } from 'url';
import {ConnectionString} from 'connection-string';
import { exec } from 'child_process';
import crypto from 'crypto';

import Handlebars from 'handlebars';
import { promisify } from 'util';
Expand Down Expand Up @@ -152,6 +153,10 @@ async function inspectDatabaseCleanState(options) {
const provider = detectDbProvider(connectionString.protocol);
const dbConnString = connectionString.toString();

// `dbInspected` records whether the answer below is a real observation or a fallback
// (connector unavailable), so the caller can avoid claiming the database is empty.
options.dbInspected = true;

// Fast path for SQLite: a missing file is by definition a brand new database.
// Avoid connecting (which would otherwise create the file) and avoid pulling the
// connector for the common new-project case.
Expand All @@ -171,6 +176,7 @@ async function inspectDatabaseCleanState(options) {
// the normal Prisma flow stays available instead of failing create-app.
console.log(chalk.yellow(`\n⚠️ Could not load the database connector to inspect the database (${error.message}). Continuing as a new database.`));
options.existingDb = false;
options.dbInspected = false;
return;
}

Expand All @@ -180,6 +186,7 @@ async function inspectDatabaseCleanState(options) {
// Connector predates the isDatabaseEmpty() probe (version skew); cannot
// determine emptiness, so assume a new database and keep the Prisma flow.
options.existingDb = false;
options.dbInspected = false;
return;
}

Expand Down Expand Up @@ -247,6 +254,29 @@ export async function promptForMissingOptions(options) {

await inspectDatabaseCleanState(resolvedOptions);

// Say which path was taken: the decision is made by inspecting the database, not by whether --db was passed
const prismaCapable = isPrismaMigrationDbUrl(resolvedOptions.db);
const adminUserSql = generateAdminUserTableInstructions(detectDbProvider(parseConnectionString(resolvedOptions.db).protocol));
const sqlNote = adminUserSql
? 'The generated README and the instructions printed at the end include the SQL for the required adminuser table.'
: 'No table has to be created up front for this database.';
const willAskAboutPrisma = resolvedOptions.includePrismaMigrations === undefined && prismaCapable && !resolvedOptions.existingDb;

if (!resolvedOptions.dbInspected) {
console.log(chalk.yellow('\n🗄️ Continuing as a new database because it could not be inspected.' +
(prismaCapable ? ' Prisma migrations are therefore not offered by default; answer Yes below only if it is in fact empty.' : '') +
` ${sqlNote}`));
} else if (resolvedOptions.existingDb) {
console.log(chalk.cyan(`\n🗄️ The database already contains data, so no Prisma migrations will be generated for it. ${sqlNote}`));
} else if (willAskAboutPrisma) {
console.log(chalk.cyan('\n🗄️ The database is empty. Answer Yes below to let AdminForth manage its schema with Prisma migrations, ' +
`or No to manage it yourself${adminUserSql ? ' (you will get the SQL for the required adminuser table instead)' : ''}.`));
} else if (prismaCapable && resolvedOptions.includePrismaMigrations) {
console.log(chalk.cyan('\n🗄️ The database is empty; AdminForth will manage its schema with Prisma migrations.'));
} else {
console.log(chalk.cyan(`\n🗄️ The database is empty and no Prisma migrations will be generated. ${sqlNote}`));
}

if (
resolvedOptions.includePrismaMigrations === undefined &&
isPrismaMigrationDbUrl(resolvedOptions.db) &&
Expand All @@ -260,7 +290,9 @@ export async function promptForMissingOptions(options) {
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
default: true,
// only recommend Prisma when the database was actually observed to be empty: on a database
// we could not inspect, pressing Enter must not scaffold migrations over existing data
default: resolvedOptions.dbInspected,
}]);
resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations;
} else {
Expand Down Expand Up @@ -432,7 +464,6 @@ async function scaffoldProject(ctx, options, cwd) {
prismaDbUrlProd,
appName,
provider,
existingDb: options.existingDb,
nodeMajor: parseInt(process.versions.node.split('.')[0], 10),
sqliteFile: connectionString.protocol.startsWith('sqlite') ? connectionString.host : null,
});
Expand All @@ -454,9 +485,9 @@ function getPackageManagerTemplateData(useNpm, nodeMajor) {
};
}

async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, options) {
export async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, options) {
const {
dbUrl, prismaDbUrl, appName, provider, existingDb, nodeMajor,
dbUrl, prismaDbUrl, appName, provider, nodeMajor,
dbUrlProd, prismaDbUrlProd, sqliteFile
} = options;
const packageManagerTemplateData = getPackageManagerTemplateData(useNpm, nodeMajor);
Expand Down Expand Up @@ -505,8 +536,9 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations,
prismaDbUrl: resolvedPrismaDbUrl,
appName,
sqliteFile,
existingDb,
adminUserTableInstructions: existingDb ? generateAdminUserTableInstructions(provider) : null,
// whenever Prisma migrations will not manage the schema (same rule as skipPrismaSetup in scaffoldProject),
// the user has to create the adminuser table themselves
adminUserTableInstructions: (!includePrismaMigrations || !prismaDbUrl) ? generateAdminUserTableInstructions(provider) : null,
},
},
{
Expand Down Expand Up @@ -540,10 +572,17 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations,
data: {},
},
{
// We'll write .env using the same content as .env.sample
// gitignored: holds the JWT signing key, unique per developer and per deployment
src: '.env.hbs',
dest: '.env',
data: { dbUrl, prismaDbUrl: resolvedPrismaDbUrl },
data: { adminforthSecret: crypto.randomBytes(32).toString('hex') },
mode: 0o600,
},
{
// committed: tells the next developer which secrets to create locally
src: '.env.example.hbs',
dest: '.env.example',
data: {},
},
{
src: 'adminuser.ts.hbs',
Expand Down Expand Up @@ -635,7 +674,11 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations,
...packageManagerTemplateData,
...task.data,
});
await fs.promises.writeFile(destPath, compiled);
await fs.promises.writeFile(destPath, compiled, task.mode ? { mode: task.mode } : undefined);
if (task.mode) {
// writeFile's mode is masked by the umask and ignored on overwrite; enforce it unconditionally
await fs.promises.chmod(destPath, task.mode);
}
}
}
}
Expand Down Expand Up @@ -678,10 +721,10 @@ async function installDependenciesNpm(ctx, cwd) {
}
}

function generateFinalInstructionsPnpm(skipPrismaSetup, options) {
export function generateFinalInstructionsPnpm(skipPrismaSetup, options) {
let instruction = '⏭️ Run the following commands to get started:\n';
const provider = detectDbProvider(parseConnectionString(options.db).protocol);
const adminUserTableInstructions = options.existingDb ? generateAdminUserTableInstructions(provider) : null;
const adminUserTableInstructions = skipPrismaSetup ? generateAdminUserTableInstructions(provider) : null;
instruction += `
${chalk.dim('// Go to the project directory')}
${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`;
Expand All @@ -706,10 +749,10 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) {
return instruction;
}

function generateFinalInstructionsNpm(skipPrismaSetup, options) {
export function generateFinalInstructionsNpm(skipPrismaSetup, options) {
let instruction = '⏭️ Run the following commands to get started:\n';
const provider = detectDbProvider(parseConnectionString(options.db).protocol);
const adminUserTableInstructions = options.existingDb ? generateAdminUserTableInstructions(provider) : null;
const adminUserTableInstructions = skipPrismaSetup ? generateAdminUserTableInstructions(provider) : null;
instruction += `
${chalk.dim('// Go to the project directory')}
${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`;
Expand Down
15 changes: 8 additions & 7 deletions adminforth/documentation/docs/tutorial/001-gettingStarted.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Use this path when you already have a database and your own schema or migrations
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.
The CLI connects to the database and checks whether it already contains tables — passing `--db` alone does not decide the path. If tables exist, the database is treated as your own: no Prisma schema or migration scripts are generated, and both the CLI output and the generated project README contain the SQL for adding the required `adminuser` table with your own migration tool (MongoDB needs no table up front — the collection is created on first write). If a PostgreSQL, MySQL or SQLite database is still empty, the CLI asks `Include Prisma migrations? >` — answer **No** to keep managing the schema yourself and you get the same `adminuser` SQL; answer **Yes** and AdminForth manages the schema for you (see Path 2). The CLI offers Prisma migrations only for PostgreSQL, MySQL and SQLite, so MongoDB and ClickHouse never get the question and always keep their schema yours. If the CLI cannot load the database connector (for example offline, or the installed connector is too old to inspect a database), it warns, continues as if the database were empty and defaults the question to **No**, so pressing Enter never scaffolds migrations over a database that may already hold data; a database it cannot connect to makes `create-app` fail with the connection error.

After the project is created, navigate into it and generate resources from your existing tables:

Expand Down Expand Up @@ -65,12 +65,12 @@ Once the project is created, navigate into its directory:
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.
For an empty database (the default SQLite file, or an empty SQLite/PostgreSQL/MySQL database passed with `--db`), the CLI asks `Include Prisma migrations? >`, defaulting to **Yes**. Answer **Yes** to have AdminForth scaffold the Prisma schema and migration scripts; answer **No** to manage the schema yourself — the CLI output and the generated README then contain the SQL for the required `adminuser` table.

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`**
* **`--db`** - database connection string. `create-app` accepts `sqlite://`, `postgresql://`, `mongodb://`, `mysql://` and `clickhouse://` URLs. Default value: **`sqlite://.db.sqlite`**

> ☝️ Database Connection String format:
>
Expand All @@ -95,20 +95,21 @@ myadmin/
│ └── 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
├── schema.prisma # Prisma schema file, generated only when you include Prisma migrations
├── 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 # Env vars like tokens, secrets that should not be in version control (ADMINFORTH_SECRET is generated here for you)
├── .env.example # Committed template listing the secrets each developer must create locally
├── .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.
When you answer **Yes** to `Include Prisma migrations? >`, 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:

Expand All @@ -126,7 +127,7 @@ 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.
When no Prisma migrations were generated — the database already had tables, you answered **No**, or the database is MongoDB or ClickHouse — use your own migration tool instead. The CLI output and the generated project README show how to add the required `adminuser` table to your database (MongoDB needs none).

## Run the Server

Expand Down
14 changes: 13 additions & 1 deletion adminforth/documentation/docs/tutorial/01-helloWorld.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,24 @@ Create two files in your project's root directory:
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
```

Generate a signing key into the gitignored `.env` file, readable by you only (AdminForth logs a warning at startup if the secret is shorter than 16 characters; on Windows without openssl use `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`):

```bash
(umask 077; echo "ADMINFORTH_SECRET=$(openssl rand -hex 32)" > .env)
```

Make sure `.env` never reaches the repository or a Docker image:

```bash
echo ".env" >> .gitignore
echo ".env" >> .dockerignore
```

> ☝️ 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.)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ If your field has absolute URLs as text strings you can use `URLs` renderer to r

### Relative Time

To format your date fields to display the elapsed time, you can utilize the RelativeTime renderer.
To format your date fields to display the elapsed time, you can utilize the RelativeTime renderer. Empty or invalid values render as an empty cell.

```ts title='./resources/anyResource.ts'
columns: [
Expand Down
8 changes: 6 additions & 2 deletions adminforth/documentation/docs/tutorial/05-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ docker build -t myadminapp .
And run container with:

```bash
# Generate a signing key once and keep it in your secret store (never commit it).
# On Windows without openssl: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
export ADMINFORTH_SECRET="$(openssl rand -hex 32)"

docker run -p 3500:3500 \
-e ADMINFORTH_SECRET=CHANGEME \
-e ADMINFORTH_SECRET="$ADMINFORTH_SECRET" \
-v $(pwd)/db:/code/db \
myadminapp
```
Expand Down Expand Up @@ -115,7 +119,7 @@ services:
build: ./adminforth-app
environment:
- NODE_ENV=production
- ADMINFORTH_SECRET=!CHANGEME! # ☝️ replace with your secret
- ADMINFORTH_SECRET=${ADMINFORTH_SECRET:?generate one with openssl rand -hex 32} # ☝️ export it in the shell or put it in a gitignored .env next to this compose file; compose refuses to start when it is unset
labels:
- "traefik.enable=true"
- "traefik.http.routers.adminforth.tls=true"
Expand Down
5 changes: 5 additions & 0 deletions adminforth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,11 @@ class AdminForth implements IAdminForth {
ADMINFORTH_SECRET variable is used to sign JWT tokens
`);
}
if (adminforthSecret.length < 16) {
afLogger.warn(`ADMINFORTH_SECRET is too short (${adminforthSecret.length} characters). ` +
'It is the key that signs every auth cookie: a guessable value lets anyone forge a session for any user. ' +
'Generate one with: openssl rand -hex 32');
}
}

async getAllTables(): Promise<{ [dataSourceId: string]: string[] }> {
Expand Down
Loading