feat(admin): fondation auth de l'application d'administration
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>
This commit is contained in:
parent
18d790b1bf
commit
cf8ef26f63
21 changed files with 642 additions and 4 deletions
21
.env.example
21
.env.example
|
|
@ -15,6 +15,27 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (docker-compose.yml), serving both the API and the built frontend.
|
||||||
# APP_PORT=3000
|
# APP_PORT=3000
|
||||||
|
|
||||||
|
# --- Admin application (apps/admin-web + the /admin/* API surface) ---------
|
||||||
|
# All optional: an instance that doesn't run the admin app needs none of
|
||||||
|
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
||||||
|
# leaving it out simply disables every /admin/* route.
|
||||||
|
#
|
||||||
|
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
|
||||||
|
# end-user token can never be replayed against /admin/*. Generate your own
|
||||||
|
# the same way as JWT_SECRET above.
|
||||||
|
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
# Origin apps/admin-web is served from, added to the CORS allow-list.
|
||||||
|
# ADMIN_CORS_ORIGIN=http://localhost:5174
|
||||||
|
# Host port for the Docker `admin-web` service (static nginx serving the
|
||||||
|
# built admin frontend).
|
||||||
|
# ADMIN_WEB_PORT=3001
|
||||||
|
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
|
||||||
|
# --password / --name flags are omitted (e.g. to bootstrap the first admin
|
||||||
|
# from inside the container). Never read by the running server.
|
||||||
|
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||||
|
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||||
|
# ADMIN_INITIAL_NAME=Ops
|
||||||
|
|
||||||
# Optional — only set this to false if THIS deployment is served over
|
# Optional — only set this to false if THIS deployment is served over
|
||||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||||
# requires HTTPS (Secure attribute) as it should for a real deployment;
|
# requires HTTPS (Secure attribute) as it should for a real deployment;
|
||||||
|
|
|
||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -19,6 +19,10 @@ env:
|
||||||
# exercise the success path (matching secret), not just the "unset"
|
# exercise the success path (matching secret), not just the "unset"
|
||||||
# rejection every environment that doesn't set this gets by default.
|
# rejection every environment that doesn't set this gets by default.
|
||||||
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
|
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
|
||||||
|
# Same reasoning — lets admin-auth.test.ts exercise the admin login
|
||||||
|
# success path + the requireAdmin-guarded routes, not just the "no admin
|
||||||
|
# secret configured -> 401" path.
|
||||||
|
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
|
||||||
# Shared between the `test` job's own uvicorn step (below) and apps/api's
|
# Shared between the `test` job's own uvicorn step (below) and apps/api's
|
||||||
# IntentServiceClient — see the `test` job for why this can't be a
|
# IntentServiceClient — see the `test` job for why this can't be a
|
||||||
# `services:` container like postgres above (GitHub Actions can only pull
|
# `services:` container like postgres above (GitHub Actions can only pull
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,10 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# success path (a request with a matching secret); every other test runs
|
# success path (a request with a matching secret); every other test runs
|
||||||
# fine without it. Any value at least 32 chars works locally.
|
# fine without it. Any value at least 32 chars works locally.
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
||||||
|
# Optional — set to run admin-auth.test.ts's login success path and the
|
||||||
|
# requireAdmin-guarded routes (any value at least 32 chars). Left unset,
|
||||||
|
# those cases self-skip and only the "no secret configured -> 401" path
|
||||||
|
# runs. Same "optional in test, fail-closed at runtime" posture as
|
||||||
|
# INTERNAL_WORKER_SECRET above.
|
||||||
|
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "admin_users" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"token_version" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_login_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "admin_users_email_key" ON "admin_users"("email");
|
||||||
|
|
@ -897,3 +897,36 @@ model TechStepTrainingSuggestion {
|
||||||
|
|
||||||
@@map("tech_step_training_suggestion")
|
@@map("tech_step_training_suggestion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Admin application
|
||||||
|
// A separate operations app (usage metrics, microservice monitoring, tech-step
|
||||||
|
// correction triage) — see specs/backend-architecture.md's "admin" section.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
|
||||||
|
/// API surface). Deliberately its **own** table with **no relation** to
|
||||||
|
/// `UserProfile`: admin access is a completely separate concern from being
|
||||||
|
/// an end user of the recipe app — a person can be one, both or neither,
|
||||||
|
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
|
||||||
|
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
|
||||||
|
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
|
||||||
|
/// and (for now) there's no in-app admin-management UI.
|
||||||
|
model AdminUser {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
/// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
|
||||||
|
/// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
/// Display name shown in the admin UI's account menu.
|
||||||
|
name String
|
||||||
|
/// Bumped to invalidate previously-issued admin JWTs — same mechanism as
|
||||||
|
/// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
|
||||||
|
tokenVersion Int @default(0) @map("token_version")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
/// Stamped on every successful login — a cheap "is this account still in
|
||||||
|
/// use" signal for the operator managing admins by hand.
|
||||||
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
|
||||||
|
@@map("admin_users")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { Express, Request, Response } from "express";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { errorLogger } from "./middlewares/error-logger.js";
|
import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
|
import { adminRouter } from "./modules/admin/admin.routes.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||||
|
|
@ -32,13 +33,19 @@ export function createServer(): ExpressServer {
|
||||||
// pipeline (its "finish" listener still fires for a request that never
|
// pipeline (its "finish" listener still fires for a request that never
|
||||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
// makes it past CORS/body-parsing, not just ones that reach a route).
|
||||||
server.addMiddleware(requestLogger);
|
server.addMiddleware(requestLogger);
|
||||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
// Two allowed origins: the main app (`CORS_ORIGIN`) and the separate
|
||||||
|
// admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming
|
||||||
|
// `Origin` against any entry of the list.
|
||||||
|
server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] });
|
||||||
|
|
||||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||||
res.status(200).json({ status: "ok" });
|
res.status(200).json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
|
// Admin application surface (`apps/admin-web`) — its own auth
|
||||||
|
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
|
||||||
|
server.mountRouter("/admin", adminRouter);
|
||||||
server.mountRouter("/house", houseRouter);
|
server.mountRouter("/house", houseRouter);
|
||||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||||
// `requireInternalWorker` on every route within (see that router's own
|
// `requireInternalWorker` on every route within (see that router's own
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,27 @@ const envSchema = z.object({
|
||||||
* every technique detection request failing one at a time.
|
* every technique detection request failing one at a time.
|
||||||
*/
|
*/
|
||||||
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
|
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
|
||||||
|
/**
|
||||||
|
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
|
||||||
|
* — entirely separate from `JWT_SECRET`, so an end-user session token can
|
||||||
|
* never be replayed against `/admin/*` and vice versa. `.optional()`
|
||||||
|
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
|
||||||
|
* all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`)
|
||||||
|
* rejects every request outright when it's unset, so the surface fails
|
||||||
|
* closed, same posture as `INTERNAL_WORKER_SECRET`.
|
||||||
|
*/
|
||||||
|
ADMIN_JWT_SECRET: z
|
||||||
|
.string()
|
||||||
|
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
|
||||||
|
.optional(),
|
||||||
|
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
|
||||||
|
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
|
||||||
|
/** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */
|
||||||
|
ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"),
|
||||||
|
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
|
||||||
|
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
||||||
|
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
||||||
|
ADMIN_INITIAL_NAME: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
59
apps/api/src/lib/admin-jwt.ts
Normal file
59
apps/api/src/lib/admin-jwt.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded contents of an **admin** session JWT, once verified. Deliberately
|
||||||
|
* a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin
|
||||||
|
* app authenticates against its own `AdminUser` table with its own secret
|
||||||
|
* (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session
|
||||||
|
* token are never interchangeable.
|
||||||
|
*/
|
||||||
|
export interface AdminTokenPayload {
|
||||||
|
/** `AdminUser.id` this token authenticates. */
|
||||||
|
adminUserId: number;
|
||||||
|
/** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin JWT secret, or a thrown error if the instance never configured
|
||||||
|
* one. A misconfigured admin deployment surfaces as a loud 500 on login
|
||||||
|
* rather than a silently-unsigned token; a deployment that doesn't run the
|
||||||
|
* admin app at all never reaches here (nothing calls sign/verify), and
|
||||||
|
* `requireAdmin` independently fails closed on the same unset value.
|
||||||
|
*/
|
||||||
|
function adminSecret(): string {
|
||||||
|
if (env.ADMIN_JWT_SECRET === undefined) {
|
||||||
|
throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions");
|
||||||
|
}
|
||||||
|
return env.ADMIN_JWT_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */
|
||||||
|
export function signAdminToken(payload: AdminTokenPayload): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion },
|
||||||
|
adminSecret(),
|
||||||
|
{ expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an admin session JWT's signature/expiry and decodes it back
|
||||||
|
* into an {@link AdminTokenPayload}.
|
||||||
|
*
|
||||||
|
* @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
|
||||||
|
* structurally malformed (missing/wrong-typed claims).
|
||||||
|
*/
|
||||||
|
export function verifyAdminToken(token: string): AdminTokenPayload {
|
||||||
|
const decoded = jwt.verify(token, adminSecret());
|
||||||
|
const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;
|
||||||
|
if (
|
||||||
|
typeof decoded !== "object" ||
|
||||||
|
Number.isNaN(adminUserId) ||
|
||||||
|
typeof decoded.tokenVersion !== "number"
|
||||||
|
) {
|
||||||
|
throw new Error("Malformed admin token payload");
|
||||||
|
}
|
||||||
|
return { adminUserId, tokenVersion: decoded.tokenVersion };
|
||||||
|
}
|
||||||
20
apps/api/src/lib/safe-admin.ts
Normal file
20
apps/api/src/lib/safe-admin.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { AdminUserView } from "@batch-cooking/shared";
|
||||||
|
import type { AdminUser } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes a Prisma `AdminUser` into the {@link AdminUserView} sent to the
|
||||||
|
* admin client — drops `passwordHash` **and** `tokenVersion` (an internal
|
||||||
|
* invalidation counter the client never needs, unlike `SafeUserProfile`
|
||||||
|
* which does expose it), and serializes the two dates to ISO strings. The
|
||||||
|
* one place this security-relevant stripping happens, same role as
|
||||||
|
* `toSafeProfile` (`lib/safe-profile.ts`).
|
||||||
|
*/
|
||||||
|
export function toSafeAdmin(admin: AdminUser): AdminUserView {
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
name: admin.name,
|
||||||
|
createdAt: admin.createdAt.toISOString(),
|
||||||
|
lastLoginAt: admin.lastLoginAt === null ? null : admin.lastLoginAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
71
apps/api/src/middlewares/require-admin.ts
Normal file
71
apps/api/src/middlewares/require-admin.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import { type AdminUserView, ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { prisma } from "../db/prisma.js";
|
||||||
|
import { verifyAdminToken } from "../lib/admin-jwt.js";
|
||||||
|
import { toSafeAdmin } from "../lib/safe-admin.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of `res.locals` once {@link requireAdmin} has run successfully.
|
||||||
|
* Type a handler's response as `Response<unknown, AdminLocals>` to read
|
||||||
|
* `res.locals.adminUser` fully typed, no cast — same `res.locals` (not
|
||||||
|
* global `Request` augmentation) approach as {@link AuthLocals}
|
||||||
|
* (`require-auth.ts`).
|
||||||
|
*/
|
||||||
|
export interface AdminLocals {
|
||||||
|
/** The authenticated admin operator, resolved from the admin session cookie's JWT. */
|
||||||
|
adminUser: AdminUserView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware guarding every `/admin/*` route — the operations app
|
||||||
|
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin
|
||||||
|
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
|
||||||
|
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
|
||||||
|
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
|
||||||
|
* against the database so a stateless JWT can still be invalidated
|
||||||
|
* server-side.
|
||||||
|
*
|
||||||
|
* A completely separate mechanism from {@link requireAuth}, not layered on
|
||||||
|
* it: an end-user session token and an admin session token are never
|
||||||
|
* interchangeable in either direction.
|
||||||
|
*
|
||||||
|
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
|
||||||
|
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
|
||||||
|
* every request is rejected rather than the surface left open — same
|
||||||
|
* posture as `requireInternalWorker`.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||||
|
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
|
||||||
|
* no secret configured. Never distinguishes the reason.
|
||||||
|
*/
|
||||||
|
export async function requireAdmin(
|
||||||
|
req: Request,
|
||||||
|
res: Response<unknown, AdminLocals>,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const token = req.cookies?.[env.ADMIN_COOKIE_NAME];
|
||||||
|
if (typeof token !== "string") {
|
||||||
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = verifyAdminToken(token);
|
||||||
|
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
|
||||||
|
|
||||||
|
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
|
||||||
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.adminUser = toSafeAdmin(admin);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
next(err);
|
||||||
|
} else {
|
||||||
|
// Covers jwt.verify failures and the unset-secret throw from verifyAdminToken.
|
||||||
|
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/api/src/modules/admin/admin-auth.routes.ts
Normal file
51
apps/api/src/modules/admin/admin-auth.routes.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import { adminLoginSchema } from "@batch-cooking/shared";
|
||||||
|
import { type CookieOptions, type Response, Router } from "express";
|
||||||
|
import { env } from "../../config/env.js";
|
||||||
|
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
|
import { adminLogin } from "./admin-auth.service.js";
|
||||||
|
|
||||||
|
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
|
||||||
|
export const adminAuthRouter = Router();
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie options for the admin session — same shape as `auth.routes.ts`'s
|
||||||
|
* end-user cookie (httpOnly, `Secure` in production unless `COOKIE_SECURE`
|
||||||
|
* overrides, `SameSite=Lax`), just written under {@link env.ADMIN_COOKIE_NAME}
|
||||||
|
* so the two sessions never collide in one browser.
|
||||||
|
*/
|
||||||
|
const adminCookieOptions: CookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: SEVEN_DAYS_MS,
|
||||||
|
};
|
||||||
|
|
||||||
|
// `res.clearCookie` sets its own expiry — passing `maxAge` alongside is
|
||||||
|
// deprecated as of Express 4.20, so the logout route reuses the options
|
||||||
|
// minus that one field (same trick as `auth.routes.ts`).
|
||||||
|
const { maxAge: _maxAge, ...clearAdminCookieOptions } = adminCookieOptions;
|
||||||
|
|
||||||
|
/** Verifies admin credentials and starts an admin session. */
|
||||||
|
adminAuthRouter.post(
|
||||||
|
"/login",
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
const input = adminLoginSchema.parse(req.body);
|
||||||
|
const { admin, token } = await adminLogin(input);
|
||||||
|
res.cookie(env.ADMIN_COOKIE_NAME, token, adminCookieOptions);
|
||||||
|
res.status(200).json(admin);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Ends the admin session by clearing the cookie. Stateless JWT — nothing to revoke server-side beyond bumping `tokenVersion` (no UI for that yet). */
|
||||||
|
adminAuthRouter.post("/logout", (_req, res) => {
|
||||||
|
res.clearCookie(env.ADMIN_COOKIE_NAME, clearAdminCookieOptions);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Returns the currently authenticated admin. Behind `requireAdmin` — 401s if there's no valid admin session. */
|
||||||
|
adminAuthRouter.get("/me", requireAdmin, (_req, res: Response<unknown, AdminLocals>) => {
|
||||||
|
res.status(200).json(res.locals.adminUser);
|
||||||
|
});
|
||||||
59
apps/api/src/modules/admin/admin-auth.service.ts
Normal file
59
apps/api/src/modules/admin/admin-auth.service.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import { type AdminLoginInput, type AdminUserView, ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import { env } from "../../config/env.js";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import { signAdminToken } from "../../lib/admin-jwt.js";
|
||||||
|
import { toSafeAdmin } from "../../lib/safe-admin.js";
|
||||||
|
|
||||||
|
/** Result of a successful admin login: the safe admin view plus the signed admin session JWT to set as a cookie. */
|
||||||
|
interface AdminAuthResult {
|
||||||
|
admin: AdminUserView;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same reasoning as `auth.service.ts`'s `hashOptions`: argon2's real
|
||||||
|
// defaults are deliberately expensive; the test suite hashes/verifies
|
||||||
|
// against throwaway data many times per run, so a cheaper cost keeps it
|
||||||
|
// fast without weakening anything real. Never applies outside NODE_ENV=test.
|
||||||
|
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
||||||
|
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||||
|
|
||||||
|
/** Exposed so `src/scripts/create-admin.ts` hashes exactly the same way `login` verifies. */
|
||||||
|
export function hashAdminPassword(password: string): Promise<string> {
|
||||||
|
return argon2.hash(password, hashOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an admin operator's credentials, stamps `lastLoginAt`, and
|
||||||
|
* issues a fresh admin session token.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
|
||||||
|
* or a wrong password — deliberately indistinguishable, same reasoning as
|
||||||
|
* `auth.service.ts`'s `login`.
|
||||||
|
*/
|
||||||
|
export async function adminLogin(input: AdminLoginInput): Promise<AdminAuthResult> {
|
||||||
|
try {
|
||||||
|
const admin = await prisma.adminUser.findUnique({ where: { email: input.email } });
|
||||||
|
|
||||||
|
if (!admin || !(await argon2.verify(admin.passwordHash, input.password))) {
|
||||||
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.adminUser.update({
|
||||||
|
where: { id: admin.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = signAdminToken({
|
||||||
|
adminUserId: updated.id,
|
||||||
|
tokenVersion: updated.tokenVersion,
|
||||||
|
});
|
||||||
|
return { admin: toSafeAdmin(updated), token };
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
||||||
|
// this service layer just isn't allowed a bare `await` per the repo's
|
||||||
|
// async/try-catch convention.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/modules/admin/admin.routes.ts
Normal file
13
apps/api/src/modules/admin/admin.routes.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregator for the admin application's API surface, mounted at `/admin`
|
||||||
|
* in `app.ts`. Every sub-router here is for `apps/admin-web` only —
|
||||||
|
* `/admin/auth` is public (login), everything added later
|
||||||
|
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits
|
||||||
|
* behind `requireAdmin` (`middlewares/require-admin.ts`).
|
||||||
|
*/
|
||||||
|
export const adminRouter = Router();
|
||||||
|
|
||||||
|
adminRouter.use("/auth", adminAuthRouter);
|
||||||
56
apps/api/src/scripts/create-admin.ts
Normal file
56
apps/api/src/scripts/create-admin.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -47,7 +47,8 @@ export async function resetDatabase() {
|
||||||
"planning_item", "planning",
|
"planning_item", "planning",
|
||||||
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
||||||
"recipe", "ingredients", "sources", "unit",
|
"recipe", "ingredients", "sources", "unit",
|
||||||
"user_profiles", "diet", "house"
|
"user_profiles", "diet", "house",
|
||||||
|
"admin_users"
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
`);
|
`);
|
||||||
await seedReferenceData(prisma);
|
await seedReferenceData(prisma);
|
||||||
|
|
|
||||||
149
apps/api/test/admin-auth.test.ts
Normal file
149
apps/api/test/admin-auth.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../src/app.js";
|
||||||
|
import { env } from "../src/config/env.js";
|
||||||
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||||
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
||||||
|
function buildSignupPayload(): SignupInput {
|
||||||
|
const firstName = faker.person.firstName();
|
||||||
|
const lastName = faker.person.lastName();
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */
|
||||||
|
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||||
|
const email = faker.internet.email().toLowerCase();
|
||||||
|
const password = faker.internet.password({ length: 16 });
|
||||||
|
await prisma.adminUser.create({
|
||||||
|
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||||
|
});
|
||||||
|
return { email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */
|
||||||
|
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
||||||
|
|
||||||
|
describe("Admin auth", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /admin/auth/login", () => {
|
||||||
|
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const res = await request(app).post("/admin/auth/login").send({});
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/admin/auth/login")
|
||||||
|
.send({ email: "nobody@example.com", password: "whatever" });
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
||||||
|
const { email } = await seedAdmin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/admin/auth/login")
|
||||||
|
.send({ email, password: "not-the-password" });
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here).
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { email, password } = await seedAdmin();
|
||||||
|
|
||||||
|
const res = await request(app).post("/admin/auth/login").send({ email, password });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.email).to.equal(email);
|
||||||
|
expect(res.body).to.not.have.property("passwordHash");
|
||||||
|
expect(res.body).to.not.have.property("tokenVersion");
|
||||||
|
expect(res.body.lastLoginAt).to.be.a("string");
|
||||||
|
|
||||||
|
const setCookie = res.headers["set-cookie"];
|
||||||
|
expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include(
|
||||||
|
env.ADMIN_COOKIE_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } });
|
||||||
|
expect(stored.lastLoginAt).to.not.equal(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /admin/auth/me", () => {
|
||||||
|
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app).get("/admin/auth/me");
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the admin behind a valid admin session", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { email, password } = await seedAdmin();
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/admin/auth/login").send({ email, password });
|
||||||
|
|
||||||
|
const res = await agent.get("/admin/auth/me");
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.email).to.equal(email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops returning the admin after logout", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { email, password } = await seedAdmin();
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/admin/auth/login").send({ email, password });
|
||||||
|
|
||||||
|
const logoutRes = await agent.post("/admin/auth/logout");
|
||||||
|
expect(logoutRes.status).to.equal(204);
|
||||||
|
|
||||||
|
const meRes = await agent.get("/admin/auth/me");
|
||||||
|
expect(meRes.status).to.equal(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept an end-user session cookie as an admin session", async () => {
|
||||||
|
// An ordinary user logs in (sets the `session` cookie), then tries the
|
||||||
|
// admin surface with that same agent — `requireAdmin` reads a
|
||||||
|
// different cookie entirely, so this must 401 regardless of whether
|
||||||
|
// ADMIN_JWT_SECRET is configured.
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/admin/auth/me");
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -48,6 +48,15 @@ services:
|
||||||
# default: `/internal/tech-steps/*` fails closed rather than open
|
# default: `/internal/tech-steps/*` fails closed rather than open
|
||||||
# for a deployment that doesn't run the worker at all.
|
# for a deployment that doesn't run the worker at all.
|
||||||
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
|
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
|
||||||
|
# Admin application (apps/admin-web + /admin/*). Both unset by default:
|
||||||
|
# `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack
|
||||||
|
# that doesn't run the admin app simply has every /admin/* route 401.
|
||||||
|
# Must be a *different* secret than JWT_SECRET.
|
||||||
|
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
|
||||||
|
# Public origin apps/admin-web is served from, added to the CORS
|
||||||
|
# allow-list alongside the main app. Defaults to the compose
|
||||||
|
# `admin-web` service's mapped host port.
|
||||||
|
ADMIN_CORS_ORIGIN: ${ADMIN_CORS_ORIGIN:-http://localhost:3001}
|
||||||
# Compose network service name, not localhost — same reasoning as
|
# Compose network service name, not localhost — same reasoning as
|
||||||
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
||||||
# tech-step-intent-service is a core dependency (see its own entry
|
# tech-step-intent-service is a core dependency (see its own entry
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,14 @@ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
|
||||||
|
|
||||||
/** Options for {@link ExpressServer.setupCore}. */
|
/** Options for {@link ExpressServer.setupCore}. */
|
||||||
export interface ExpressServerCoreOptions {
|
export interface ExpressServerCoreOptions {
|
||||||
/** Origin allowed by CORS — must match wherever the frontend is served from. */
|
/**
|
||||||
corsOrigin: string;
|
* Origin(s) allowed by CORS — a single origin, or a list when more than
|
||||||
|
* one frontend talks to this API from a different origin (e.g. the main
|
||||||
|
* app plus a separate admin app). Passed straight through to the `cors`
|
||||||
|
* package, which matches an incoming `Origin` against any entry of the
|
||||||
|
* list.
|
||||||
|
*/
|
||||||
|
corsOrigin: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js";
|
||||||
export * from "./data/catalog-labels-fr.js";
|
export * from "./data/catalog-labels-fr.js";
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
export * from "./schemas/account.js";
|
export * from "./schemas/account.js";
|
||||||
|
export * from "./schemas/admin.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
export * from "./schemas/household.js";
|
export * from "./schemas/household.js";
|
||||||
export * from "./schemas/planning.js";
|
export * from "./schemas/planning.js";
|
||||||
|
|
@ -17,6 +18,7 @@ export * from "./schemas/shopping-list.js";
|
||||||
export * from "./schemas/sources.js";
|
export * from "./schemas/sources.js";
|
||||||
export * from "./schemas/tech-step-worker.js";
|
export * from "./schemas/tech-step-worker.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
|
export * from "./types/admin.js";
|
||||||
export * from "./types/household.js";
|
export * from "./types/household.js";
|
||||||
export * from "./types/planning.js";
|
export * from "./types/planning.js";
|
||||||
export * from "./types/preferences.js";
|
export * from "./types/preferences.js";
|
||||||
|
|
|
||||||
18
packages/shared/src/schemas/admin.ts
Normal file
18
packages/shared/src/schemas/admin.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Shared between apps/api (server-side validation, source of truth) and
|
||||||
|
// apps/admin-web (client-side validation for instant feedback). Same
|
||||||
|
// rationale as schemas/auth.ts — one set of rules, French messages surfaced
|
||||||
|
// as-is in the admin login form.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `POST /admin/auth/login`. Deliberately its own schema
|
||||||
|
* (not a re-export of `loginSchema`): the admin surface is a separate
|
||||||
|
* contract from the end-user one even though the shape currently matches.
|
||||||
|
*/
|
||||||
|
export const adminLoginSchema = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email("Email invalide"),
|
||||||
|
password: z.string().min(1, "Le mot de passe est requis"),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
||||||
|
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
|
||||||
16
packages/shared/src/types/admin.ts
Normal file
16
packages/shared/src/types/admin.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Public shape of an admin operator, as returned by `GET /admin/auth/me`
|
||||||
|
* and `POST /admin/auth/login` — never includes the password hash. Mirrors
|
||||||
|
* apps/api's `Omit<PrismaAdminUser, "passwordHash" | "tokenVersion">`,
|
||||||
|
* declared by hand rather than derived from the Prisma type (same reason as
|
||||||
|
* {@link SafeUserProfile}: apps/admin-web must not depend on
|
||||||
|
* `@prisma/client`). `createdAt`/`lastLoginAt` are ISO 8601 strings (JSON
|
||||||
|
* has no date type).
|
||||||
|
*/
|
||||||
|
export interface AdminUserView {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue