Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.
- Table AdminUser isolee (aucune relation vers UserProfile), migration
20260828120000_admin_user.
- lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET
propre (jamais interchangeable avec JWT_SECRET).
- middlewares/require-admin.ts : cookie admin_session dedie, re-check
tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture
requireInternalWorker). res.locals.adminUser type via AdminLocals.
- modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST
/logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de
signup expose.
- lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash
+ tokenVersion, dates ISO).
- scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou
ADMIN_INITIAL_*).
- CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN +
ADMIN_CORS_ORIGIN.
- Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts
(AdminUserView).
- Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN,
ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml,
ci.yml mis a jour.
- reset-db.ts truncate admin_users.
- Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu /
mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion
dans la reponse), /me derriere requireAdmin, logout, et un cookie
`session` d'utilisateur normal ne donne pas acces a /admin/*.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
56 lines
2.3 KiB
TypeScript
56 lines
2.3 KiB
TypeScript
import { env } from "../config/env.js";
|
|
import { prisma } from "../db/prisma.js";
|
|
import { hashAdminPassword } from "../modules/admin/admin-auth.service.js";
|
|
|
|
/**
|
|
* Creates the first (or an additional) `AdminUser` for the admin
|
|
* application — there is no self-service admin signup, on purpose (see the
|
|
* `AdminUser` model doc comment in schema.prisma).
|
|
*
|
|
* pnpm --filter api exec tsx src/scripts/create-admin.ts \
|
|
* --email=ops@example.com --password='...' --name='Ops'
|
|
*
|
|
* Each flag falls back to the matching `ADMIN_INITIAL_*` env var when
|
|
* omitted, so a deployment can bake the first admin's credentials into its
|
|
* environment and run this once from the container without passing args.
|
|
* Refuses (exit 1) if an `AdminUser` with that email already exists —
|
|
* changing an existing admin's password is a manual DB operation for now,
|
|
* not something this script does.
|
|
*/
|
|
function flag(name: string): string | undefined {
|
|
const prefix = `--${name}=`;
|
|
const arg = process.argv.find((value) => value.startsWith(prefix));
|
|
return arg === undefined ? undefined : arg.slice(prefix.length);
|
|
}
|
|
|
|
async function createAdmin(): Promise<void> {
|
|
const email = (flag("email") ?? env.ADMIN_INITIAL_EMAIL)?.trim().toLowerCase();
|
|
const password = flag("password") ?? env.ADMIN_INITIAL_PASSWORD;
|
|
const name = (flag("name") ?? env.ADMIN_INITIAL_NAME)?.trim();
|
|
|
|
if (!email || !password || !name) {
|
|
throw new Error(
|
|
"Missing required input. Provide --email, --password and --name (or set ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_PASSWORD / ADMIN_INITIAL_NAME).",
|
|
);
|
|
}
|
|
if (password.length < 8) {
|
|
throw new Error("Password must be at least 8 characters.");
|
|
}
|
|
|
|
const existing = await prisma.adminUser.findUnique({ where: { email } });
|
|
if (existing) {
|
|
throw new Error(`An admin with email "${email}" already exists (id ${existing.id}).`);
|
|
}
|
|
|
|
const passwordHash = await hashAdminPassword(password);
|
|
const admin = await prisma.adminUser.create({ data: { email, name, passwordHash } });
|
|
console.info(`Created admin #${admin.id} <${admin.email}> ("${admin.name}").`);
|
|
}
|
|
|
|
createAdmin()
|
|
.then(() => prisma.$disconnect())
|
|
.catch(async (err) => {
|
|
console.error(err instanceof Error ? err.message : err);
|
|
await prisma.$disconnect();
|
|
process.exit(1);
|
|
});
|