feat: monorepo with NestJS API, admin panel and DB-driven website
Restructure the repo into a pnpm + Turborepo monorepo: - apps/web: Nuxt 4 site, now server-rendered and reading all content from the API; pages cached with SWR and purged on content changes - apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived access tokens and rotating httpOnly refresh cookies, roles (ADMIN, EDITOR), catalog, editable site sections, media upload to S3-compatible storage (converted to WebP), contact messages, audit log - apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for products, categories, site sections, messages, media and users - packages/shared: Zod schemas and types shared by API and admin Also: - seed script that loads the original 8 categories / 89 products and section content - Docker images for api (with a one-off migrate service), web, admin, and an nginx gateway for both domains - dev and production compose files, daily database backup service - API e2e tests Removes the committed static out/ build and the old root Dockerfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31076174e2
commit
789d224ea0
@@ -0,0 +1,101 @@
|
||||
import { Body, Controller, Get, HttpCode, Module, Post, Req, Res } from "@nestjs/common";
|
||||
import { JwtModule, type JwtSignOptions } from "@nestjs/jwt";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import {
|
||||
type AuthResponse,
|
||||
type ChangePasswordInput,
|
||||
changePasswordSchema,
|
||||
type LoginInput,
|
||||
loginSchema,
|
||||
} from "@imen/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { env, isProd } from "../config/env.js";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
|
||||
import { ZodPipe } from "../common/zod.pipe.js";
|
||||
import { AuthService, type IssuedTokens } from "./auth.service.js";
|
||||
|
||||
const COOKIE = "imen_rt";
|
||||
const COOKIE_PATH = "/api/admin/auth";
|
||||
|
||||
const ua = (req: Request) => req.headers["user-agent"] ?? "";
|
||||
const readCookie = (req: Request): string | undefined =>
|
||||
(req.cookies as Record<string, string> | undefined)?.[COOKIE];
|
||||
|
||||
@Controller("admin/auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@Throttle({ default: { limit: 8, ttl: 60_000 } })
|
||||
async login(
|
||||
@Body(new ZodPipe(loginSchema)) body: LoginInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthResponse> {
|
||||
return this.respond(res, await this.auth.login(body, ua(req)));
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
return this.respond(res, await this.auth.refresh(readCookie(req), ua(req)));
|
||||
} catch (e) {
|
||||
res.clearCookie(COOKIE, { path: COOKIE_PATH });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
await this.auth.logout(readCookie(req));
|
||||
res.clearCookie(COOKIE, { path: COOKIE_PATH });
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@AdminAuth()
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.me(user.id);
|
||||
}
|
||||
|
||||
@Post("change-password")
|
||||
@HttpCode(204)
|
||||
@AdminAuth()
|
||||
async changePassword(
|
||||
@Body(new ZodPipe(changePasswordSchema)) body: ChangePasswordInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
await this.auth.changePassword(user.id, body);
|
||||
}
|
||||
|
||||
private respond(res: Response, t: IssuedTokens): AuthResponse {
|
||||
res.cookie(COOKIE, t.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProd && env.COOKIE_SECURE,
|
||||
sameSite: "strict",
|
||||
path: COOKIE_PATH,
|
||||
expires: t.refreshExpiresAt,
|
||||
});
|
||||
return { accessToken: t.accessToken, user: t.user };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
secret: env.JWT_SECRET,
|
||||
signOptions: { expiresIn: env.JWT_ACCESS_TTL as JwtSignOptions["expiresIn"] },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
Reference in New Issue
Block a user