L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.
Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
(les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
un ref requestSeq.
Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.
Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
`app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.
Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
71 lines
2.8 KiB
TypeScript
71 lines
2.8 KiB
TypeScript
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 UI
|
|
* (`apps/web`'s `/admin/*` routes) 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"));
|
|
}
|
|
}
|
|
}
|