feat: добавить новый backend и cabinet
- добавлен новый Nest backend для auth, projects и project access tokens - добавлена control-plane схема БД и миграция Drizzle - перенесён старый backend в old-backend - добавлен React/Vite cabinet с auth-only входом и Mantine layout - обновлены workspace scripts и lockfile
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
|
||||
После определения роли агент обязан открыть соответствующую инструкцию:
|
||||
|
||||
- `developer` → [DEVELOP.md](./ai/nextjs-style-guide/DEVELOP.md)
|
||||
- `developer` → [DEVELOP.md](./ai/DEVELOP.md)
|
||||
|
||||
Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const MainLayout = (props: MainLayoutProps) => {
|
||||
<AppShell {...rootAttrs} className={cl(styles.root, className)} header={{ height: 72 }} padding="md">
|
||||
<AppShell.Header className={styles.header}>
|
||||
<Group h="100%" justify="space-between" px={{ base: "md", md: "xl" }}>
|
||||
<Link className={styles.brand} to="/" aria-label="Админка платформы изображений">
|
||||
<Link className={styles.brand} to="/" aria-label="Платформа изображений">
|
||||
<ThemeIcon className={styles.brandMark} radius="xl" size={42} variant="light">
|
||||
IP
|
||||
</ThemeIcon>
|
||||
@@ -27,8 +27,6 @@ export const MainLayout = (props: MainLayoutProps) => {
|
||||
Платформа изображений
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Text className={styles.sectionLabel}>Админка</Text>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
|
||||
@@ -34,14 +34,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.main {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -10,14 +10,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@image-platform/database": "workspace:*",
|
||||
"@image-platform/image-config": "workspace:*",
|
||||
"@image-platform/queue": "workspace:*",
|
||||
"@image-platform/storage": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"amqplib": "^1.0.4",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -26,7 +22,6 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { AssetsController } from "./assets/assets.controller"
|
||||
import { AssetsService } from "./assets/assets.service"
|
||||
import { HealthController } from "./health/health.controller"
|
||||
import { DatabaseService } from "./infra/database.service"
|
||||
import { QueueService } from "./infra/queue.service"
|
||||
import { StorageService } from "./infra/storage.service"
|
||||
import { InternalImagesController } from "./internal-images/internal-images.controller"
|
||||
import { InternalImagesService } from "./internal-images/internal-images.service"
|
||||
import { PresetsController } from "./presets/presets.controller"
|
||||
import { ProjectsController } from "./projects/projects.controller"
|
||||
import { ProjectsService } from "./projects/projects.service"
|
||||
import { AuthModule } from "./auth/auth.module"
|
||||
import { HealthModule } from "./health/health.module"
|
||||
import { ProjectAccessTokensModule } from "./project-access-tokens/project-access-tokens.module"
|
||||
import { ProjectsModule } from "./projects/projects.module"
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController, AssetsController, InternalImagesController, PresetsController, ProjectsController],
|
||||
providers: [AssetsService, DatabaseService, InternalImagesService, ProjectsService, QueueService, StorageService],
|
||||
imports: [AuthModule, HealthModule, ProjectsModule, ProjectAccessTokensModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
41
apps/backend/src/auth/auth.controller.ts
Normal file
41
apps/backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from "@nestjs/common"
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger"
|
||||
|
||||
import { AuthService } from "./auth.service"
|
||||
import { CurrentAdmin } from "./decorators/current-admin.decorator"
|
||||
import { LoginRequestDto, LoginResponseDto } from "./dto/login.dto"
|
||||
import { AdminSessionResponseDto } from "./dto/session.dto"
|
||||
import { AdminAuthGuard } from "./guards/admin-auth.guard"
|
||||
import type { AdminSession } from "./types/authenticated-request.type"
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@ApiOperation({
|
||||
summary: "войти как администратор",
|
||||
description: "Проверяет фиксированную пару логин/пароль. По умолчанию используется admin/admin без публичной регистрации.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Администратор авторизован, сессионный токен выдан.", type: LoginResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Логин или пароль неверны." })
|
||||
login(@Body() request: LoginRequestDto): LoginResponseDto {
|
||||
return this.auth.login(request)
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "получить текущую admin-сессию",
|
||||
description: "Возвращает данные администратора из Bearer token.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Admin-сессия активна.", type: AdminSessionResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
getMe(@CurrentAdmin() admin: AdminSession): AdminSessionResponseDto {
|
||||
return {
|
||||
username: admin.username,
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/backend/src/auth/auth.module.ts
Normal file
14
apps/backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { DatabaseModule } from "../infra/database.module"
|
||||
import { AuthController } from "./auth.controller"
|
||||
import { AuthService } from "./auth.service"
|
||||
import { AdminAuthGuard } from "./guards/admin-auth.guard"
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
exports: [AdminAuthGuard, AuthService],
|
||||
imports: [DatabaseModule],
|
||||
providers: [AdminAuthGuard, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
132
apps/backend/src/auth/auth.service.ts
Normal file
132
apps/backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common"
|
||||
import { createHmac, timingSafeEqual } from "node:crypto"
|
||||
|
||||
import type { AdminSession } from "./types/authenticated-request.type"
|
||||
|
||||
type AdminTokenPayload = {
|
||||
exp: number
|
||||
username: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly adminPassword = process.env.ADMIN_PASSWORD ?? "admin"
|
||||
private readonly adminTokenTtlSeconds = parsePositiveInteger(process.env.ADMIN_TOKEN_TTL_SECONDS, 86_400)
|
||||
private readonly adminUsername = process.env.ADMIN_USERNAME ?? "admin"
|
||||
private readonly secret = process.env.ADMIN_AUTH_SECRET ?? "development-admin-auth-secret"
|
||||
|
||||
login(input: { password: string; username: string }) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input.username !== "string" ||
|
||||
typeof input.password !== "string" ||
|
||||
input.username !== this.adminUsername ||
|
||||
input.password !== this.adminPassword
|
||||
) {
|
||||
throw new UnauthorizedException("invalid admin credentials")
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + this.adminTokenTtlSeconds * 1000)
|
||||
const accessToken = this.signAdminToken({
|
||||
exp: Math.floor(expiresAt.getTime() / 1000),
|
||||
username: input.username,
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
tokenType: "Bearer" as const,
|
||||
}
|
||||
}
|
||||
|
||||
verifyAdminToken(token: string): AdminSession {
|
||||
const parts = token.split(".")
|
||||
|
||||
if (parts.length !== 3 || parts[0] !== "admin") {
|
||||
throw new UnauthorizedException("invalid admin token")
|
||||
}
|
||||
|
||||
const [, payloadPart, signature] = parts
|
||||
const expectedSignature = this.sign(payloadPart)
|
||||
|
||||
if (!safeEqual(signature, expectedSignature)) {
|
||||
throw new UnauthorizedException("invalid admin token")
|
||||
}
|
||||
|
||||
const payload = parsePayload(payloadPart)
|
||||
|
||||
if (payload.exp <= Math.floor(Date.now() / 1000)) {
|
||||
throw new UnauthorizedException("admin token expired")
|
||||
}
|
||||
|
||||
return {
|
||||
username: payload.username,
|
||||
}
|
||||
}
|
||||
|
||||
private signAdminToken(payload: AdminTokenPayload) {
|
||||
const payloadPart = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")
|
||||
const signature = this.sign(payloadPart)
|
||||
|
||||
return `admin.${payloadPart}.${signature}`
|
||||
}
|
||||
|
||||
private sign(payloadPart: string) {
|
||||
return createHmac("sha256", this.secret).update(payloadPart).digest("base64url")
|
||||
}
|
||||
}
|
||||
|
||||
function parsePayload(payloadPart: string): AdminTokenPayload {
|
||||
try {
|
||||
const value = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8")) as unknown
|
||||
|
||||
if (!isPayload(value)) {
|
||||
throw new Error("invalid payload")
|
||||
}
|
||||
|
||||
return value
|
||||
} catch {
|
||||
throw new UnauthorizedException("invalid admin token")
|
||||
}
|
||||
}
|
||||
|
||||
function isPayload(value: unknown): value is AdminTokenPayload {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"exp" in value &&
|
||||
"username" in value &&
|
||||
typeof value.exp === "number" &&
|
||||
typeof value.username === "string" &&
|
||||
value.username.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function safeEqual(left: string | undefined, right: string) {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
13
apps/backend/src/auth/decorators/current-admin.decorator.ts
Normal file
13
apps/backend/src/auth/decorators/current-admin.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
|
||||
|
||||
import type { AdminSession, AuthenticatedRequest } from "../types/authenticated-request.type"
|
||||
|
||||
export const CurrentAdmin = createParamDecorator((_data: unknown, context: ExecutionContext): AdminSession => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
|
||||
if (!request.adminSession) {
|
||||
throw new Error("admin session is missing in request context")
|
||||
}
|
||||
|
||||
return request.adminSession
|
||||
})
|
||||
20
apps/backend/src/auth/dto/login.dto.ts
Normal file
20
apps/backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class LoginRequestDto {
|
||||
@ApiProperty({ description: "Логин администратора.", example: "admin" })
|
||||
username!: string
|
||||
|
||||
@ApiProperty({ description: "Пароль администратора.", example: "admin" })
|
||||
password!: string
|
||||
}
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty({ description: "Bearer token сессии администратора.", example: "admin.eyJ1c2VybmFtZSI6ImFkbWluIn0.signature" })
|
||||
accessToken!: string
|
||||
|
||||
@ApiProperty({ description: "Тип токена для HTTP Authorization header.", example: "Bearer" })
|
||||
tokenType!: "Bearer"
|
||||
|
||||
@ApiProperty({ description: "ISO-дата истечения сессии.", example: "2026-05-13T10:00:00.000Z" })
|
||||
expiresAt!: string
|
||||
}
|
||||
6
apps/backend/src/auth/dto/session.dto.ts
Normal file
6
apps/backend/src/auth/dto/session.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class AdminSessionResponseDto {
|
||||
@ApiProperty({ description: "Логин текущего администратора.", example: "admin" })
|
||||
username!: string
|
||||
}
|
||||
32
apps/backend/src/auth/guards/admin-auth.guard.ts
Normal file
32
apps/backend/src/auth/guards/admin-auth.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"
|
||||
|
||||
import { AuthService } from "../auth.service"
|
||||
import type { AuthenticatedRequest } from "../types/authenticated-request.type"
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
const token = readBearerToken(request.headers.authorization)
|
||||
|
||||
request.adminSession = this.auth.verifyAdminToken(token)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function readBearerToken(header: string | undefined): string {
|
||||
if (!header) {
|
||||
throw new UnauthorizedException("authorization header is required")
|
||||
}
|
||||
|
||||
const [type, token] = header.split(" ")
|
||||
|
||||
if (type !== "Bearer" || !token) {
|
||||
throw new UnauthorizedException("bearer token is required")
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
17
apps/backend/src/auth/types/authenticated-request.type.ts
Normal file
17
apps/backend/src/auth/types/authenticated-request.type.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||
import type { Request } from "express"
|
||||
|
||||
export type AdminSession = {
|
||||
username: string
|
||||
}
|
||||
|
||||
export type ProjectAccess = {
|
||||
projectId: string
|
||||
scopes: ProjectAccessTokenScope[]
|
||||
tokenId: string
|
||||
}
|
||||
|
||||
export type AuthenticatedRequest = Request & {
|
||||
adminSession?: AdminSession
|
||||
projectAccess?: ProjectAccess
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class HealthResponseDto {
|
||||
@ApiProperty({ description: "Имя сервиса, который вернул health-check ответ.", example: "image-platform-api" })
|
||||
service!: string
|
||||
@ApiProperty({ description: "Текущий статус backend.", example: "ok" })
|
||||
status!: "ok"
|
||||
|
||||
@ApiProperty({ description: "Текущее состояние сервиса.", example: "ok" })
|
||||
status!: string
|
||||
@ApiProperty({ description: "Название сервиса.", example: "backend" })
|
||||
service!: string
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import { HealthResponseDto } from "./health-response.dto"
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "проверить состояние Backend API",
|
||||
description: "Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests.",
|
||||
summary: "проверить состояние backend",
|
||||
description: "Возвращает простой health-check для runtime и инфраструктурных проверок.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Backend API доступен и отвечает.", type: HealthResponseDto })
|
||||
@ApiOkResponse({ description: "Backend отвечает на запросы.", type: HealthResponseDto })
|
||||
getHealth(): HealthResponseDto {
|
||||
return {
|
||||
service: "image-platform-api",
|
||||
service: "backend",
|
||||
status: "ok",
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/backend/src/health/health.module.ts
Normal file
8
apps/backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { HealthController } from "./health.controller"
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
10
apps/backend/src/infra/database.module.ts
Normal file
10
apps/backend/src/infra/database.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from "@nestjs/common"
|
||||
|
||||
import { DatabaseService } from "./database.service"
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
exports: [DatabaseService],
|
||||
providers: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common"
|
||||
import { createDatabase, createDatabasePool } from "@image-platform/database"
|
||||
import type { Database } from "@image-platform/database"
|
||||
import { createDatabase, createDatabasePool, type Database, type DatabasePool } from "@image-platform/database"
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleDestroy {
|
||||
private readonly pool = createDatabasePool()
|
||||
|
||||
readonly pool: DatabasePool = createDatabasePool()
|
||||
readonly db: Database = createDatabase(this.pool)
|
||||
|
||||
async onModuleDestroy() {
|
||||
|
||||
@@ -10,17 +10,33 @@ async function bootstrap() {
|
||||
app.enableShutdownHooks()
|
||||
|
||||
const openApiConfig = new DocumentBuilder()
|
||||
.setTitle("Image Platform API")
|
||||
.setTitle("Assets Delivery Platform API")
|
||||
.setDescription(
|
||||
"Backend API для управления image assets, metadata в PostgreSQL, S3 variants, RabbitMQ jobs и генерацией через imgproxy.",
|
||||
"Control plane API для авторизации, проектов и токенов доступа. Image-модуль будет переноситься из old-backend отдельным vertical slice.",
|
||||
)
|
||||
.setVersion("0.1.0")
|
||||
.addTag("system", "Системные endpoints для проверки состояния сервиса.")
|
||||
.addTag("assets", "Регистрация и управление исходными изображениями.")
|
||||
.addTag("variants", "Будущие endpoints для управления производными версиями изображений.")
|
||||
.addTag("allowed-hosts", "Будущие endpoints для управления разрешёнными source hosts.")
|
||||
.addTag("internal-images", "Внутренние endpoints, которые вызывает Gateway на cache miss.")
|
||||
.addTag("presets", "Статические presets, custom limits и mock allowlist source hosts.")
|
||||
.addBearerAuth(
|
||||
{
|
||||
bearerFormat: "Admin session token",
|
||||
description: "Токен сессии администратора, полученный через /api/auth/login.",
|
||||
scheme: "bearer",
|
||||
type: "http",
|
||||
},
|
||||
"adminAuth",
|
||||
)
|
||||
.addBearerAuth(
|
||||
{
|
||||
bearerFormat: "Project access token",
|
||||
description: "Токен доступа к проекту для headless API.",
|
||||
scheme: "bearer",
|
||||
type: "http",
|
||||
},
|
||||
"projectAccessToken",
|
||||
)
|
||||
.addTag("system", "Системные endpoints для проверки состояния backend.")
|
||||
.addTag("auth", "Авторизация администратора без публичной регистрации.")
|
||||
.addTag("projects", "Control plane для управления проектами.")
|
||||
.addTag("project-access-tokens", "Токены доступа к проектам для server-side интеграций.")
|
||||
.build()
|
||||
|
||||
const openApiDocument = SwaggerModule.createDocument(app, openApiConfig)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common"
|
||||
|
||||
import type { AuthenticatedRequest, ProjectAccess } from "../../auth/types/authenticated-request.type"
|
||||
|
||||
export const CurrentProjectAccess = createParamDecorator((_data: unknown, context: ExecutionContext): ProjectAccess => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
|
||||
if (!request.projectAccess) {
|
||||
throw new Error("project access context is missing in request")
|
||||
}
|
||||
|
||||
return request.projectAccess
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from "@nestjs/common"
|
||||
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||
|
||||
export const REQUIRED_PROJECT_SCOPES_KEY = "requiredProjectScopes"
|
||||
|
||||
export const RequiredProjectScopes = (...scopes: ProjectAccessTokenScope[]) => {
|
||||
return SetMetadata(REQUIRED_PROJECT_SCOPES_KEY, scopes)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export const PROJECT_ACCESS_TOKEN_SCOPES = [
|
||||
"assets:read",
|
||||
"assets:write",
|
||||
"assets:delete",
|
||||
"presets:read",
|
||||
"presets:write",
|
||||
"builds:read",
|
||||
"builds:write",
|
||||
] as const
|
||||
|
||||
export type ProjectAccessTokenScopeDto = (typeof PROJECT_ACCESS_TOKEN_SCOPES)[number]
|
||||
|
||||
export class CreateProjectAccessTokenRequestDto {
|
||||
@ApiProperty({ description: "Название токена для отображения в кабинете.", example: "Production backend" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({
|
||||
description: "Scopes, которые разрешены этому токену проекта.",
|
||||
enum: PROJECT_ACCESS_TOKEN_SCOPES,
|
||||
example: ["assets:read", "assets:write"],
|
||||
isArray: true,
|
||||
})
|
||||
scopes!: ProjectAccessTokenScopeDto[]
|
||||
}
|
||||
|
||||
export class ProjectAccessTokenResponseDto {
|
||||
@ApiProperty({ description: "UUID токена доступа.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "UUID проекта, к которому привязан токен.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||
projectId!: string
|
||||
|
||||
@ApiProperty({ description: "Название токена.", example: "Production backend" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Безопасный prefix токена для отображения и поиска.", example: "a1b2c3d4e5f6" })
|
||||
tokenPrefix!: string
|
||||
|
||||
@ApiProperty({ description: "Scopes токена.", enum: PROJECT_ACCESS_TOKEN_SCOPES, isArray: true })
|
||||
scopes!: ProjectAccessTokenScopeDto[]
|
||||
|
||||
@ApiProperty({ description: "Статус токена.", enum: ["active", "revoked"], example: "active" })
|
||||
status!: "active" | "revoked"
|
||||
|
||||
@ApiProperty({ description: "ISO-дата последнего использования токена.", example: "2026-05-12T10:00:00.000Z", nullable: true })
|
||||
lastUsedAt!: string | null
|
||||
|
||||
@ApiProperty({ description: "ISO-дата отзыва токена.", example: null, nullable: true })
|
||||
revokedAt!: string | null
|
||||
|
||||
@ApiProperty({ description: "ISO-дата создания токена.", example: "2026-05-12T10:00:00.000Z" })
|
||||
createdAt!: string
|
||||
|
||||
@ApiProperty({ description: "ISO-дата последнего обновления токена.", example: "2026-05-12T10:00:00.000Z" })
|
||||
updatedAt!: string
|
||||
}
|
||||
|
||||
export class CreateProjectAccessTokenResponseDto extends ProjectAccessTokenResponseDto {
|
||||
@ApiProperty({
|
||||
description: "Секрет токена. Показывается только один раз при создании и не хранится в базе в открытом виде.",
|
||||
example: "ip_prj_a1b2c3d4e5f6_secretValue",
|
||||
})
|
||||
secret!: string
|
||||
}
|
||||
|
||||
export class ProjectAccessTokensListResponseDto {
|
||||
@ApiProperty({ description: "Список токенов доступа проекта без секретов.", type: [ProjectAccessTokenResponseDto] })
|
||||
tokens!: ProjectAccessTokenResponseDto[]
|
||||
}
|
||||
|
||||
export class ProjectAccessContextResponseDto {
|
||||
@ApiProperty({ description: "UUID проекта, определённый по Bearer token.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||
projectId!: string
|
||||
|
||||
@ApiProperty({ description: "UUID использованного токена.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991" })
|
||||
tokenId!: string
|
||||
|
||||
@ApiProperty({ description: "Scopes текущего токена.", enum: PROJECT_ACCESS_TOKEN_SCOPES, isArray: true })
|
||||
scopes!: ProjectAccessTokenScopeDto[]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"
|
||||
import { Reflector } from "@nestjs/core"
|
||||
|
||||
import { readBearerToken } from "../../auth/guards/admin-auth.guard"
|
||||
import type { AuthenticatedRequest } from "../../auth/types/authenticated-request.type"
|
||||
import { REQUIRED_PROJECT_SCOPES_KEY } from "../decorators/required-project-scopes.decorator"
|
||||
import { ProjectAccessTokensService } from "../project-access-tokens.service"
|
||||
import type { ProjectAccessTokenScope } from "@image-platform/database"
|
||||
|
||||
@Injectable()
|
||||
export class ProjectAccessTokenGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly projectAccessTokens: ProjectAccessTokensService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
||||
const token = readBearerToken(request.headers.authorization)
|
||||
const projectAccess = await this.projectAccessTokens.authenticate(token)
|
||||
const requiredScopes = this.reflector.getAllAndOverride<ProjectAccessTokenScope[]>(REQUIRED_PROJECT_SCOPES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
])
|
||||
|
||||
if (requiredScopes?.some((scope) => !projectAccess.scopes.includes(scope))) {
|
||||
throw new ForbiddenException("project access token does not have required scopes")
|
||||
}
|
||||
|
||||
request.projectAccess = projectAccess
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common"
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger"
|
||||
|
||||
import { AdminAuthGuard } from "../auth/guards/admin-auth.guard"
|
||||
import { CurrentProjectAccess } from "./decorators/current-project-access.decorator"
|
||||
import { RequiredProjectScopes } from "./decorators/required-project-scopes.decorator"
|
||||
import {
|
||||
CreateProjectAccessTokenRequestDto,
|
||||
CreateProjectAccessTokenResponseDto,
|
||||
ProjectAccessContextResponseDto,
|
||||
ProjectAccessTokenResponseDto,
|
||||
ProjectAccessTokensListResponseDto,
|
||||
} from "./dto/project-access-token.dto"
|
||||
import { ProjectAccessTokenGuard } from "./guards/project-access-token.guard"
|
||||
import { ProjectAccessTokensService } from "./project-access-tokens.service"
|
||||
import type { ProjectAccess } from "../auth/types/authenticated-request.type"
|
||||
|
||||
@ApiTags("project-access-tokens")
|
||||
@Controller()
|
||||
export class ProjectAccessTokensController {
|
||||
constructor(private readonly projectAccessTokens: ProjectAccessTokensService) {}
|
||||
|
||||
@Post("projects/:projectId/access-tokens")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "создать токен доступа к проекту",
|
||||
description:
|
||||
"Создаёт server-side токен для headless API. Секрет возвращается только в этом ответе, в базе хранится только hash.",
|
||||
})
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiCreatedResponse({ description: "Токен создан, секрет показан один раз.", type: CreateProjectAccessTokenResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректные name, scopes или projectId." })
|
||||
@ApiConflictResponse({ description: "Сгенерированный prefix токена уже существует." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
createToken(
|
||||
@Param("projectId") projectId: string,
|
||||
@Body() request: CreateProjectAccessTokenRequestDto,
|
||||
): Promise<CreateProjectAccessTokenResponseDto> {
|
||||
return this.projectAccessTokens.createToken(projectId, request)
|
||||
}
|
||||
|
||||
@Get("projects/:projectId/access-tokens")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "получить токены доступа проекта",
|
||||
description: "Возвращает список токенов проекта без секретов. Для UI показывается только prefix и metadata.",
|
||||
})
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiOkResponse({ description: "Список токенов проекта возвращён.", type: ProjectAccessTokensListResponseDto })
|
||||
@ApiBadRequestResponse({ description: "projectId не является UUID." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
listTokens(@Param("projectId") projectId: string): Promise<ProjectAccessTokensListResponseDto> {
|
||||
return this.projectAccessTokens.listTokens(projectId)
|
||||
}
|
||||
|
||||
@Delete("projects/:projectId/access-tokens/:tokenId")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@ApiOperation({
|
||||
summary: "отозвать токен доступа проекта",
|
||||
description: "Переводит токен в revoked. После отзыва он больше не даёт доступ к project-scoped API.",
|
||||
})
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiParam({ description: "UUID токена доступа.", example: "c7d3f2a5-9968-41b5-9869-e9535f62f991", name: "tokenId" })
|
||||
@ApiOkResponse({ description: "Токен отозван.", type: ProjectAccessTokenResponseDto })
|
||||
@ApiBadRequestResponse({ description: "projectId или tokenId не является UUID." })
|
||||
@ApiNotFoundResponse({ description: "Проект или токен не найден." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
revokeToken(
|
||||
@Param("projectId") projectId: string,
|
||||
@Param("tokenId") tokenId: string,
|
||||
): Promise<ProjectAccessTokenResponseDto> {
|
||||
return this.projectAccessTokens.revokeToken(projectId, tokenId)
|
||||
}
|
||||
|
||||
@Get("project-access/me")
|
||||
@UseGuards(ProjectAccessTokenGuard)
|
||||
@RequiredProjectScopes("assets:read")
|
||||
@ApiBearerAuth("projectAccessToken")
|
||||
@ApiOperation({
|
||||
summary: "проверить токен доступа к проекту",
|
||||
description: "Диагностический endpoint для headless-клиентов. Возвращает projectId и scopes текущего project access token.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Project access token активен и имеет scope assets:read.", type: ProjectAccessContextResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Project access token отсутствует, отозван или некорректен." })
|
||||
@ApiForbiddenResponse({ description: "Project access token не имеет требуемого scope." })
|
||||
getProjectAccess(@CurrentProjectAccess() projectAccess: ProjectAccess): ProjectAccessContextResponseDto {
|
||||
return projectAccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { AuthModule } from "../auth/auth.module"
|
||||
import { DatabaseModule } from "../infra/database.module"
|
||||
import { ProjectsModule } from "../projects/projects.module"
|
||||
import { ProjectAccessTokenGuard } from "./guards/project-access-token.guard"
|
||||
import { ProjectAccessTokensController } from "./project-access-tokens.controller"
|
||||
import { ProjectAccessTokensService } from "./project-access-tokens.service"
|
||||
|
||||
@Module({
|
||||
controllers: [ProjectAccessTokensController],
|
||||
exports: [ProjectAccessTokenGuard, ProjectAccessTokensService],
|
||||
imports: [AuthModule, DatabaseModule, ProjectsModule],
|
||||
providers: [ProjectAccessTokenGuard, ProjectAccessTokensService],
|
||||
})
|
||||
export class ProjectAccessTokensModule {}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"
|
||||
import { projectAccessTokens, projects, type ProjectAccessTokenScope } from "@image-platform/database"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
|
||||
import { DatabaseService } from "../infra/database.service"
|
||||
import { ProjectsService } from "../projects/projects.service"
|
||||
import { PROJECT_ACCESS_TOKEN_SCOPES, type ProjectAccessTokenScopeDto } from "./dto/project-access-token.dto"
|
||||
import type {
|
||||
CreateProjectAccessTokenRequestDto,
|
||||
CreateProjectAccessTokenResponseDto,
|
||||
ProjectAccessTokenResponseDto,
|
||||
ProjectAccessTokensListResponseDto,
|
||||
} from "./dto/project-access-token.dto"
|
||||
|
||||
@Injectable()
|
||||
export class ProjectAccessTokensService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly projectsService: ProjectsService,
|
||||
) {}
|
||||
|
||||
async createToken(projectId: string, request: CreateProjectAccessTokenRequestDto): Promise<CreateProjectAccessTokenResponseDto> {
|
||||
await this.projectsService.loadActiveProject(projectId)
|
||||
|
||||
if (!request) {
|
||||
throw new BadRequestException("request body is required")
|
||||
}
|
||||
|
||||
const name = normalizeTokenName(request.name)
|
||||
const scopes = normalizeScopes(request.scopes)
|
||||
const generated = generateToken()
|
||||
|
||||
try {
|
||||
const [token] = await this.database.db
|
||||
.insert(projectAccessTokens)
|
||||
.values({
|
||||
name,
|
||||
projectId,
|
||||
scopes,
|
||||
tokenHash: hashToken(generated.secret),
|
||||
tokenPrefix: generated.prefix,
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (!token) {
|
||||
throw new Error("failed to create project access token")
|
||||
}
|
||||
|
||||
return {
|
||||
...mapTokenResponse(token),
|
||||
secret: generated.secret,
|
||||
}
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException("project access token prefix already exists")
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async listTokens(projectId: string): Promise<ProjectAccessTokensListResponseDto> {
|
||||
await this.projectsService.loadActiveProject(projectId)
|
||||
|
||||
const rows = await this.database.db
|
||||
.select()
|
||||
.from(projectAccessTokens)
|
||||
.where(eq(projectAccessTokens.projectId, projectId))
|
||||
.orderBy(desc(projectAccessTokens.createdAt))
|
||||
|
||||
return {
|
||||
tokens: rows.map(mapTokenResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async revokeToken(projectId: string, tokenId: string): Promise<ProjectAccessTokenResponseDto> {
|
||||
await this.projectsService.loadActiveProject(projectId)
|
||||
assertUuid(tokenId, "tokenId")
|
||||
|
||||
const [token] = await this.database.db
|
||||
.update(projectAccessTokens)
|
||||
.set({ revokedAt: new Date(), status: "revoked", updatedAt: new Date() })
|
||||
.where(and(eq(projectAccessTokens.id, tokenId), eq(projectAccessTokens.projectId, projectId)))
|
||||
.returning()
|
||||
|
||||
if (!token) {
|
||||
throw new NotFoundException("project access token not found")
|
||||
}
|
||||
|
||||
return mapTokenResponse(token)
|
||||
}
|
||||
|
||||
async authenticate(secret: string) {
|
||||
const prefix = parseTokenPrefix(secret)
|
||||
const [row] = await this.database.db
|
||||
.select({
|
||||
projectId: projectAccessTokens.projectId,
|
||||
projectStatus: projects.status,
|
||||
scopes: projectAccessTokens.scopes,
|
||||
status: projectAccessTokens.status,
|
||||
tokenHash: projectAccessTokens.tokenHash,
|
||||
tokenId: projectAccessTokens.id,
|
||||
})
|
||||
.from(projectAccessTokens)
|
||||
.innerJoin(projects, eq(projects.id, projectAccessTokens.projectId))
|
||||
.where(eq(projectAccessTokens.tokenPrefix, prefix))
|
||||
.limit(1)
|
||||
|
||||
if (!row || row.status !== "active" || row.projectStatus !== "active") {
|
||||
throw new UnauthorizedException("project access token is invalid")
|
||||
}
|
||||
|
||||
if (!safeEqual(hashToken(secret), row.tokenHash)) {
|
||||
throw new UnauthorizedException("project access token is invalid")
|
||||
}
|
||||
|
||||
await this.database.db
|
||||
.update(projectAccessTokens)
|
||||
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(projectAccessTokens.id, row.tokenId))
|
||||
|
||||
return {
|
||||
projectId: row.projectId,
|
||||
scopes: row.scopes,
|
||||
tokenId: row.tokenId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapTokenResponse(row: typeof projectAccessTokens.$inferSelect): ProjectAccessTokenResponseDto {
|
||||
return {
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
id: row.id,
|
||||
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
||||
name: row.name,
|
||||
projectId: row.projectId,
|
||||
revokedAt: row.revokedAt?.toISOString() ?? null,
|
||||
scopes: row.scopes,
|
||||
status: row.status,
|
||||
tokenPrefix: row.tokenPrefix,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
const prefix = randomBytes(6).toString("hex")
|
||||
const entropy = randomBytes(32).toString("base64url")
|
||||
|
||||
return {
|
||||
prefix,
|
||||
secret: `ip_prj_${prefix}_${entropy}`,
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(secret: string) {
|
||||
return createHash("sha256").update(secret).digest("hex")
|
||||
}
|
||||
|
||||
function parseTokenPrefix(secret: string) {
|
||||
const match = /^ip_prj_([a-f0-9]{12})_[A-Za-z0-9_-]{32,}$/.exec(secret)
|
||||
|
||||
if (!match?.[1]) {
|
||||
throw new UnauthorizedException("project access token is invalid")
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function normalizeTokenName(value: string) {
|
||||
if (typeof value !== "string") {
|
||||
throw new BadRequestException("token name must be a string")
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
|
||||
if (!normalized || normalized.length > 120) {
|
||||
throw new BadRequestException("token name must be 1-120 chars")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: ProjectAccessTokenScopeDto[]): ProjectAccessTokenScope[] {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||
throw new BadRequestException("token scopes must not be empty")
|
||||
}
|
||||
|
||||
const uniqueScopes = [...new Set(scopes)]
|
||||
|
||||
for (const scope of uniqueScopes) {
|
||||
if (!PROJECT_ACCESS_TOKEN_SCOPES.includes(scope)) {
|
||||
throw new BadRequestException(`unsupported token scope: ${scope}`)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueScopes
|
||||
}
|
||||
|
||||
function assertUuid(value: string, name: string) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new BadRequestException(`${name} must be a uuid`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
|
||||
}
|
||||
34
apps/backend/src/projects/dto/projects.dto.ts
Normal file
34
apps/backend/src/projects/dto/projects.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class CreateProjectRequestDto {
|
||||
@ApiProperty({ description: "Человекочитаемое название проекта.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Опциональный slug проекта. Если не передан, backend создаст его из имени.", example: "demo-shop", required: false })
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export class ProjectResponseDto {
|
||||
@ApiProperty({ description: "Внутренний UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f" })
|
||||
id!: string
|
||||
|
||||
@ApiProperty({ description: "Публичный slug проекта для UI и человекочитаемых ссылок.", example: "demo-shop" })
|
||||
slug!: string
|
||||
|
||||
@ApiProperty({ description: "Название проекта.", example: "Demo Shop" })
|
||||
name!: string
|
||||
|
||||
@ApiProperty({ description: "Статус проекта.", enum: ["active", "disabled"], example: "active" })
|
||||
status!: "active" | "disabled"
|
||||
|
||||
@ApiProperty({ description: "ISO-дата создания проекта.", example: "2026-05-12T10:00:00.000Z" })
|
||||
createdAt!: string
|
||||
|
||||
@ApiProperty({ description: "ISO-дата последнего обновления проекта.", example: "2026-05-12T10:00:00.000Z" })
|
||||
updatedAt!: string
|
||||
}
|
||||
|
||||
export class ProjectsListResponseDto {
|
||||
@ApiProperty({ description: "Список проектов control plane.", type: [ProjectResponseDto] })
|
||||
projects!: ProjectResponseDto[]
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import { BadRequestException } from "@nestjs/common"
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
export function normalizeProjectSlug(value: string) {
|
||||
if (typeof value !== "string") {
|
||||
throw new BadRequestException("project slug must be a string")
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9_-]{2,63}$/.test(normalized)) {
|
||||
throw new BadRequestException("project slug must be 3-64 chars and contain lowercase letters, digits, _ or -")
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(normalized)) {
|
||||
throw new BadRequestException("project slug must be 3-64 chars and contain lowercase letters, numbers or hyphens")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function createProjectSlug(name: string) {
|
||||
const slug = name
|
||||
const base = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9_-]+/g, "-")
|
||||
.replaceAll(/^-+|-+$/g, "")
|
||||
.replace(/[^a-z0-9]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
|
||||
return normalizeProjectSlug(slug || "project")
|
||||
const safeBase = base || "project"
|
||||
const suffix = createHash("sha1").update(name).digest("hex").slice(0, 8)
|
||||
|
||||
return normalizeProjectSlug(`${safeBase}-${suffix}`)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from "@nestjs/common"
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger"
|
||||
|
||||
import { AssetsListResponseDto } from "../assets/asset-response.dto"
|
||||
import { AssetsService } from "../assets/assets.service"
|
||||
import { CreateAssetRequestDto, CreateAssetResponseDto } from "../assets/create-asset.dto"
|
||||
import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto"
|
||||
import { AdminAuthGuard } from "../auth/guards/admin-auth.guard"
|
||||
import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./dto/projects.dto"
|
||||
import { ProjectsService } from "./projects.service"
|
||||
|
||||
@ApiTags("projects")
|
||||
@ApiBearerAuth("adminAuth")
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller("projects")
|
||||
export class ProjectsController {
|
||||
constructor(
|
||||
private readonly assets: AssetsService,
|
||||
private readonly projects: ProjectsService,
|
||||
) {}
|
||||
constructor(private readonly projects: ProjectsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "получить список проектов",
|
||||
description: "Возвращает проекты верхнего уровня для главной страницы admin.",
|
||||
description: "Возвращает проекты control plane. Проект является областью изоляции assets, presets, builds и access tokens.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Список проектов возвращён.", type: ProjectsListResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
listProjects(): Promise<ProjectsListResponseDto> {
|
||||
return this.projects.listProjects()
|
||||
}
|
||||
@@ -38,59 +37,27 @@ export class ProjectsController {
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: "создать проект",
|
||||
description: "Создаёт проект, внутри которого admin управляет assets и source versions.",
|
||||
description: "Создаёт проект как верхнеуровневую область изоляции. Проект не является image-сущностью.",
|
||||
})
|
||||
@ApiCreatedResponse({ description: "Проект создан.", type: ProjectResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректные name или slug." })
|
||||
@ApiConflictResponse({ description: "Проект с таким slug уже существует." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
createProject(@Body() request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
|
||||
return this.projects.createProject(request)
|
||||
}
|
||||
|
||||
@Get(":projectSlug")
|
||||
@Get(":projectId")
|
||||
@ApiOperation({
|
||||
summary: "получить проект по slug",
|
||||
description: "Возвращает metadata проекта для project-level страницы admin.",
|
||||
summary: "получить проект по id",
|
||||
description: "Возвращает metadata активного проекта по внутреннему UUID.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiParam({ description: "UUID проекта.", example: "6ef5b7a8-9894-49c4-bf71-70f053e0830f", name: "projectId" })
|
||||
@ApiOkResponse({ description: "Проект найден.", type: ProjectResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
getProject(@Param("projectSlug") projectSlug: string): Promise<ProjectResponseDto> {
|
||||
return this.projects.getProject(projectSlug)
|
||||
}
|
||||
|
||||
@Get(":projectSlug/assets")
|
||||
@ApiOperation({
|
||||
summary: "получить assets проекта",
|
||||
description: "Возвращает assets, созданные внутри выбранного проекта.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiQuery({ description: "Максимальное количество assets в ответе.", example: 50, name: "limit", required: false })
|
||||
@ApiQuery({ description: "Смещение для простого paging.", example: 0, name: "offset", required: false })
|
||||
@ApiOkResponse({ description: "Список assets проекта возвращён.", type: AssetsListResponseDto })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
listProjectAssets(
|
||||
@Param("projectSlug") projectSlug: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("offset") offset?: string,
|
||||
): Promise<AssetsListResponseDto> {
|
||||
return this.assets.listAssets({ limit, offset, projectSlug })
|
||||
}
|
||||
|
||||
@Post(":projectSlug/assets")
|
||||
@ApiOperation({
|
||||
summary: "создать asset в проекте",
|
||||
description: "Создаёт asset и первую source version внутри выбранного проекта.",
|
||||
})
|
||||
@ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" })
|
||||
@ApiCreatedResponse({ description: "Asset проекта создан.", type: CreateAssetResponseDto })
|
||||
@ApiBadRequestResponse({ description: "Некорректный sourceUrl, publicId или source host запрещён настройками." })
|
||||
@ApiConflictResponse({ description: "Asset с таким publicId уже существует." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден." })
|
||||
createProjectAsset(
|
||||
@Param("projectSlug") projectSlug: string,
|
||||
@Body() request: CreateAssetRequestDto,
|
||||
): Promise<CreateAssetResponseDto> {
|
||||
return this.assets.createAsset(request, projectSlug)
|
||||
@ApiBadRequestResponse({ description: "projectId не является UUID." })
|
||||
@ApiNotFoundResponse({ description: "Проект не найден или отключён." })
|
||||
@ApiUnauthorizedResponse({ description: "Admin token отсутствует, истёк или некорректен." })
|
||||
getProject(@Param("projectId") projectId: string): Promise<ProjectResponseDto> {
|
||||
return this.projects.getProject(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/backend/src/projects/projects.module.ts
Normal file
14
apps/backend/src/projects/projects.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { AuthModule } from "../auth/auth.module"
|
||||
import { DatabaseModule } from "../infra/database.module"
|
||||
import { ProjectsController } from "./projects.controller"
|
||||
import { ProjectsService } from "./projects.service"
|
||||
|
||||
@Module({
|
||||
controllers: [ProjectsController],
|
||||
exports: [ProjectsService],
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
providers: [ProjectsService],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
@@ -1,36 +1,39 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"
|
||||
import { imageAssets, imageProjects } from "@image-platform/database"
|
||||
import { count, desc, eq, inArray } from "drizzle-orm"
|
||||
import { projects } from "@image-platform/database"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
|
||||
import { DatabaseService } from "../infra/database.service"
|
||||
import { createProjectSlug, normalizeProjectSlug } from "./project-slug"
|
||||
import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto"
|
||||
import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./dto/projects.dto"
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async listProjects(): Promise<ProjectsListResponseDto> {
|
||||
const projects = await this.database.db.select().from(imageProjects).orderBy(desc(imageProjects.createdAt))
|
||||
const assetsCountByProjectId = await this.countAssetsByProjectIds(projects.map((project) => project.id))
|
||||
const rows = await this.database.db.select().from(projects).orderBy(desc(projects.createdAt))
|
||||
|
||||
return {
|
||||
projects: projects.map((project) => mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)),
|
||||
projects: rows.map(mapProjectResponse),
|
||||
}
|
||||
}
|
||||
|
||||
async createProject(request: CreateProjectRequestDto): Promise<ProjectResponseDto> {
|
||||
if (!request) {
|
||||
throw new BadRequestException("request body is required")
|
||||
}
|
||||
|
||||
const name = normalizeProjectName(request.name)
|
||||
const slug = request.slug ? normalizeProjectSlug(request.slug) : createProjectSlug(name)
|
||||
|
||||
try {
|
||||
const [project] = await this.database.db.insert(imageProjects).values({ name, slug }).returning()
|
||||
const [project] = await this.database.db.insert(projects).values({ name, slug }).returning()
|
||||
|
||||
if (!project) {
|
||||
throw new Error("failed to create project")
|
||||
}
|
||||
|
||||
return mapProjectResponse(project, 0)
|
||||
return mapProjectResponse(project)
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException("project slug already exists")
|
||||
@@ -40,20 +43,16 @@ export class ProjectsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getProject(slug: string): Promise<ProjectResponseDto> {
|
||||
const project = await this.loadProject(slug)
|
||||
const assetsCountByProjectId = await this.countAssetsByProjectIds([project.id])
|
||||
async getProject(projectId: string): Promise<ProjectResponseDto> {
|
||||
const project = await this.loadActiveProject(projectId)
|
||||
|
||||
return mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)
|
||||
return mapProjectResponse(project)
|
||||
}
|
||||
|
||||
async loadProject(slug: string) {
|
||||
const normalizedSlug = normalizeProjectSlug(slug)
|
||||
const [project] = await this.database.db
|
||||
.select()
|
||||
.from(imageProjects)
|
||||
.where(eq(imageProjects.slug, normalizedSlug))
|
||||
.limit(1)
|
||||
async loadActiveProject(projectId: string) {
|
||||
assertUuid(projectId, "projectId")
|
||||
|
||||
const [project] = await this.database.db.select().from(projects).where(eq(projects.id, projectId)).limit(1)
|
||||
|
||||
if (!project || project.status !== "active") {
|
||||
throw new NotFoundException("project not found")
|
||||
@@ -61,28 +60,10 @@ export class ProjectsService {
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
private async countAssetsByProjectIds(projectIds: string[]) {
|
||||
if (projectIds.length === 0) {
|
||||
return new Map<string, number>()
|
||||
}
|
||||
|
||||
const rows = await this.database.db
|
||||
.select({
|
||||
assetsCount: count(imageAssets.id),
|
||||
projectId: imageAssets.projectId,
|
||||
})
|
||||
.from(imageAssets)
|
||||
.where(inArray(imageAssets.projectId, projectIds))
|
||||
.groupBy(imageAssets.projectId)
|
||||
|
||||
return new Map(rows.flatMap((row) => (row.projectId ? [[row.projectId, row.assetsCount]] : [])))
|
||||
}
|
||||
}
|
||||
|
||||
function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount: number): ProjectResponseDto {
|
||||
function mapProjectResponse(row: typeof projects.$inferSelect): ProjectResponseDto {
|
||||
return {
|
||||
assetsCount,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -93,6 +74,10 @@ function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount:
|
||||
}
|
||||
|
||||
function normalizeProjectName(value: string) {
|
||||
if (typeof value !== "string") {
|
||||
throw new BadRequestException("project name must be a string")
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
|
||||
if (!normalized || normalized.length > 120) {
|
||||
@@ -102,6 +87,12 @@ function normalizeProjectName(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function assertUuid(value: string, name: string) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new BadRequestException(`${name} must be a uuid`)
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "23505"
|
||||
}
|
||||
|
||||
12
apps/cabinet/index.html
Normal file
12
apps/cabinet/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Assets Cabinet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
apps/cabinet/package.json
Normal file
36
apps/cabinet/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@image-platform/cabinet",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"codegen:backend-api": "npx @gromlab/api-codegen@latest -i http://localhost:3001/docs-json -o src/infra/backend-api/generated -n backend-api.generated",
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 5174",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.1.1",
|
||||
"@mantine/form": "^9.1.1",
|
||||
"@mantine/hooks": "^9.1.1",
|
||||
"@mantine/notifications": "^9.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"swr": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-global-data": "^4.0.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss-custom-media": "^12.0.1",
|
||||
"postcss-nesting": "^14.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
10
apps/cabinet/postcss.config.mjs
Normal file
10
apps/cabinet/postcss.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@csstools/postcss-global-data': {
|
||||
files: ['src/shared/styles/media.css'],
|
||||
},
|
||||
'postcss-custom-media': {},
|
||||
'postcss-nesting': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
23
apps/cabinet/src/app/app-router.tsx
Normal file
23
apps/cabinet/src/app/app-router.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { LoginScreen } from 'screens/login'
|
||||
import { SessionScreen } from 'screens/session'
|
||||
|
||||
import { AuthenticatedShell } from './authenticated-shell'
|
||||
import { ProtectedRoute } from './protected-route'
|
||||
|
||||
export const AppRouter = () => (
|
||||
<Routes>
|
||||
<Route element={<LoginScreen />} path="/login" />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AuthenticatedShell>
|
||||
<SessionScreen />
|
||||
</AuthenticatedShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/"
|
||||
/>
|
||||
<Route element={<Navigate replace to="/" />} path="*" />
|
||||
</Routes>
|
||||
)
|
||||
17
apps/cabinet/src/app/app.tsx
Normal file
17
apps/cabinet/src/app/app.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { SWRConfig } from 'swr'
|
||||
import { ThemeProvider } from 'infra/theme'
|
||||
|
||||
import { AppRouter } from './app-router'
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<SWRConfig value={{ shouldRetryOnError: false }}>
|
||||
<ThemeProvider>
|
||||
<AppRouter />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
40
apps/cabinet/src/app/authenticated-shell.tsx
Normal file
40
apps/cabinet/src/app/authenticated-shell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Loader, Center } from '@mantine/core'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { authFactory } from 'business/auth'
|
||||
import { MainLayout } from 'layouts/main'
|
||||
|
||||
import type { AuthenticatedShellProps } from './types/authenticated-shell-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Обёртка защищённого layout кабинета.
|
||||
*
|
||||
* Используется для:
|
||||
* - передачи admin-сессии в MainLayout
|
||||
* - выполнения выхода из кабинета
|
||||
*/
|
||||
export const AuthenticatedShell = (props: AuthenticatedShellProps) => {
|
||||
const { children } = props
|
||||
const navigate = useNavigate()
|
||||
const session = auth.useAdminSession()
|
||||
const logoutAction = auth.useLogout()
|
||||
|
||||
const handleLogout = () => {
|
||||
void logoutAction.logout().then(() => navigate('/login', { replace: true }))
|
||||
}
|
||||
|
||||
if (!session.session) {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout username={session.session.username} onLogout={handleLogout}>
|
||||
{children}
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
18
apps/cabinet/src/app/main.tsx
Normal file
18
apps/cabinet/src/app/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'shared/styles/global.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { App } from './app'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Root element #root not found')
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
33
apps/cabinet/src/app/protected-route.tsx
Normal file
33
apps/cabinet/src/app/protected-route.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Loader, Center } from '@mantine/core'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { authFactory } from 'business/auth'
|
||||
|
||||
import type { ProtectedRouteProps } from './types/protected-route-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Guard защищённых маршрутов кабинета.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки admin-сессии через SWR
|
||||
* - перенаправления неавторизованного пользователя на вход
|
||||
*/
|
||||
export const ProtectedRoute = (props: ProtectedRouteProps) => {
|
||||
const { children } = props
|
||||
const session = auth.useAdminSession()
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session.isAuthenticated) {
|
||||
return <Navigate replace to="/login" />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Пропсы AuthenticatedShell.
|
||||
*/
|
||||
export type AuthenticatedShellProps = {
|
||||
/** Контент защищённого маршрута. */
|
||||
children: ReactNode
|
||||
}
|
||||
9
apps/cabinet/src/app/types/protected-route-props.type.ts
Normal file
9
apps/cabinet/src/app/types/protected-route-props.type.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Пропсы ProtectedRoute.
|
||||
*/
|
||||
export type ProtectedRouteProps = {
|
||||
/** Защищённый route element. */
|
||||
children: ReactNode
|
||||
}
|
||||
15
apps/cabinet/src/business/auth/auth.factory.ts
Normal file
15
apps/cabinet/src/business/auth/auth.factory.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useAdminSession } from './hooks/use-admin-session.hook'
|
||||
import { useLogin } from './hooks/use-login.hook'
|
||||
import { useLogout } from './hooks/use-logout.hook'
|
||||
import type { AuthFactory } from './types/auth-factory.type'
|
||||
|
||||
/**
|
||||
* Создаёт runtime API бизнес-модуля Auth.
|
||||
*/
|
||||
export const authFactory: AuthFactory = () => {
|
||||
return {
|
||||
useAdminSession,
|
||||
useLogin,
|
||||
useLogout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getAdminToken, useGetAdminSession } from 'infra/backend-api'
|
||||
|
||||
import type { AdminSession } from '../types/auth-api.type'
|
||||
|
||||
/**
|
||||
* Состояние текущей admin-сессии.
|
||||
*/
|
||||
export const useAdminSession = (): AdminSession => {
|
||||
const hasToken = Boolean(getAdminToken())
|
||||
const sessionQuery = useGetAdminSession({
|
||||
shouldRetryOnError: false,
|
||||
})
|
||||
|
||||
return {
|
||||
error: sessionQuery.error,
|
||||
isAuthenticated: Boolean(sessionQuery.data),
|
||||
isLoading: hasToken && sessionQuery.isLoading,
|
||||
session: sessionQuery.data ?? null,
|
||||
}
|
||||
}
|
||||
40
apps/cabinet/src/business/auth/hooks/use-login.hook.ts
Normal file
40
apps/cabinet/src/business/auth/hooks/use-login.hook.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import { backendApi, getAdminSessionKey, setAdminToken } from 'infra/backend-api'
|
||||
|
||||
import { toError } from '../lib/to-error'
|
||||
import type { LoginAction, LoginInput } from '../types/auth-api.type'
|
||||
|
||||
/**
|
||||
* Сценарий входа администратора.
|
||||
*/
|
||||
export const useLogin = (): LoginAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false)
|
||||
|
||||
const login = async (input: LoginInput) => {
|
||||
setError(null)
|
||||
setIsLoggingIn(true)
|
||||
|
||||
try {
|
||||
const session = await backendApi.auth.login(input)
|
||||
setAdminToken(session.accessToken)
|
||||
await mutate(getAdminSessionKey())
|
||||
|
||||
return session
|
||||
} catch (caughtError) {
|
||||
const nextError = toError(caughtError)
|
||||
setError(nextError)
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsLoggingIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
isLoggingIn,
|
||||
login,
|
||||
}
|
||||
}
|
||||
20
apps/cabinet/src/business/auth/hooks/use-logout.hook.ts
Normal file
20
apps/cabinet/src/business/auth/hooks/use-logout.hook.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useSWRConfig } from 'swr'
|
||||
import { clearAdminToken, getAdminSessionKey } from 'infra/backend-api'
|
||||
|
||||
import type { LogoutAction } from '../types/auth-api.type'
|
||||
|
||||
/**
|
||||
* Сценарий выхода администратора.
|
||||
*/
|
||||
export const useLogout = (): LogoutAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const logout = async () => {
|
||||
clearAdminToken()
|
||||
await mutate(getAdminSessionKey(), undefined, { revalidate: false })
|
||||
}
|
||||
|
||||
return {
|
||||
logout,
|
||||
}
|
||||
}
|
||||
3
apps/cabinet/src/business/auth/index.ts
Normal file
3
apps/cabinet/src/business/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { authFactory } from './auth.factory'
|
||||
export type { AdminSession, AuthApi, LoginAction, LoginInput, LogoutAction } from './types/auth-api.type'
|
||||
export type { AuthFactory } from './types/auth-factory.type'
|
||||
7
apps/cabinet/src/business/auth/lib/to-error.ts
Normal file
7
apps/cabinet/src/business/auth/lib/to-error.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const toError = (value: unknown) => {
|
||||
if (value instanceof Error) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Error(String(value))
|
||||
}
|
||||
29
apps/cabinet/src/business/auth/types/auth-api.type.ts
Normal file
29
apps/cabinet/src/business/auth/types/auth-api.type.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from 'infra/backend-api'
|
||||
|
||||
export type LoginInput = LoginRequestDto
|
||||
|
||||
export type AdminSession = {
|
||||
error?: Error
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
session: AdminSessionResponseDto | null
|
||||
}
|
||||
|
||||
export type LoginAction = {
|
||||
error: Error | null
|
||||
isLoggingIn: boolean
|
||||
login: (input: LoginInput) => Promise<LoginResponseDto>
|
||||
}
|
||||
|
||||
export type LogoutAction = {
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный runtime API бизнес-модуля Auth.
|
||||
*/
|
||||
export type AuthApi = {
|
||||
useAdminSession: () => AdminSession
|
||||
useLogin: () => LoginAction
|
||||
useLogout: () => LogoutAction
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { AuthApi } from './auth-api.type'
|
||||
|
||||
export type AuthFactory = () => AuthApi
|
||||
77
apps/cabinet/src/infra/backend-api/client.ts
Normal file
77
apps/cabinet/src/infra/backend-api/client.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { getAdminToken } from './token-storage'
|
||||
import type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from './types/backend-api.type'
|
||||
|
||||
type RequestOptions = {
|
||||
body?: unknown
|
||||
isAuthorized?: boolean
|
||||
method?: 'GET' | 'POST'
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_API_BASE_URL ?? '/api'
|
||||
|
||||
export const backendApi = {
|
||||
auth: {
|
||||
login: (body: LoginRequestDto) => {
|
||||
return request<LoginResponseDto>('/auth/login', { body, method: 'POST' })
|
||||
},
|
||||
me: () => {
|
||||
return request<AdminSessionResponseDto>('/auth/me', { isAuthorized: true })
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
headers: buildHeaders(options),
|
||||
method: options.method ?? 'GET',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function buildHeaders(options: RequestOptions) {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
|
||||
if (options.body) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (options.isAuthorized) {
|
||||
const token = getAdminToken()
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const value = (await response.json()) as unknown
|
||||
|
||||
if (isRecord(value) && typeof value.message === 'string') {
|
||||
return value.message
|
||||
}
|
||||
|
||||
if (isRecord(value) && Array.isArray(value.message)) {
|
||||
return value.message.join(', ')
|
||||
}
|
||||
} catch {
|
||||
return `request failed with status ${response.status}`
|
||||
}
|
||||
|
||||
return `request failed with status ${response.status}`
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
1
apps/cabinet/src/infra/backend-api/hooks/index.ts
Normal file
1
apps/cabinet/src/infra/backend-api/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getAdminSessionKey, useGetAdminSession } from './use-get-admin-session.hook'
|
||||
@@ -0,0 +1,18 @@
|
||||
import useSWR from 'swr'
|
||||
import type { SWRConfiguration } from 'swr'
|
||||
|
||||
import { backendApi } from '../client'
|
||||
import { getAdminToken } from '../token-storage'
|
||||
import type { AdminSessionResponseDto } from '../types/backend-api.type'
|
||||
|
||||
export const getAdminSessionKey = () => ['backend-api', 'auth', 'me'] as const
|
||||
|
||||
/**
|
||||
* Получение текущей admin-сессии.
|
||||
*/
|
||||
export const useGetAdminSession = (config?: SWRConfiguration<AdminSessionResponseDto>) => {
|
||||
const key = getAdminToken() ? getAdminSessionKey() : null
|
||||
const fetcher = () => backendApi.auth.me()
|
||||
|
||||
return useSWR<AdminSessionResponseDto>(key, fetcher, config)
|
||||
}
|
||||
4
apps/cabinet/src/infra/backend-api/index.ts
Normal file
4
apps/cabinet/src/infra/backend-api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { backendApi } from './client'
|
||||
export { clearAdminToken, getAdminToken, setAdminToken } from './token-storage'
|
||||
export * from './hooks'
|
||||
export type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from './types/backend-api.type'
|
||||
13
apps/cabinet/src/infra/backend-api/token-storage.ts
Normal file
13
apps/cabinet/src/infra/backend-api/token-storage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const ADMIN_TOKEN_KEY = 'image-platform.cabinet.admin-token'
|
||||
|
||||
export const getAdminToken = () => {
|
||||
return window.localStorage.getItem(ADMIN_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export const setAdminToken = (token: string) => {
|
||||
window.localStorage.setItem(ADMIN_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export const clearAdminToken = () => {
|
||||
window.localStorage.removeItem(ADMIN_TOKEN_KEY)
|
||||
}
|
||||
14
apps/cabinet/src/infra/backend-api/types/backend-api.type.ts
Normal file
14
apps/cabinet/src/infra/backend-api/types/backend-api.type.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type LoginRequestDto = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type LoginResponseDto = {
|
||||
accessToken: string
|
||||
tokenType: 'Bearer'
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export type AdminSessionResponseDto = {
|
||||
username: string
|
||||
}
|
||||
2
apps/cabinet/src/infra/theme/index.ts
Normal file
2
apps/cabinet/src/infra/theme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ThemeProvider } from './theme-provider'
|
||||
export type { ThemeProviderProps } from './types/theme-provider-props.type'
|
||||
22
apps/cabinet/src/infra/theme/theme-provider.tsx
Normal file
22
apps/cabinet/src/infra/theme/theme-provider.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
|
||||
import type { ThemeProviderProps } from './types/theme-provider-props.type'
|
||||
|
||||
/**
|
||||
* Провайдер визуальной темы кабинета.
|
||||
*
|
||||
* Используется для:
|
||||
* - подключения Mantine theme
|
||||
* - подключения контейнера уведомлений
|
||||
*/
|
||||
export const ThemeProvider = (props: ThemeProviderProps) => {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="light">
|
||||
<Notifications position="top-right" />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Пропсы ThemeProvider.
|
||||
*/
|
||||
export type ThemeProviderProps = {
|
||||
/** Контент приложения. */
|
||||
children: ReactNode
|
||||
}
|
||||
2
apps/cabinet/src/layouts/main/index.ts
Normal file
2
apps/cabinet/src/layouts/main/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MainLayout } from './main.layout'
|
||||
export type { MainLayoutProps } from './types/main-layout-props.type'
|
||||
82
apps/cabinet/src/layouts/main/main.layout.tsx
Normal file
82
apps/cabinet/src/layouts/main/main.layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { AppShell, Burger, Button, Container, Group, NavLink, Stack, Text, ThemeIcon } from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import cl from 'clsx'
|
||||
|
||||
import styles from './styles/main-layout.module.css'
|
||||
import type { MainLayoutProps } from './types/main-layout-props.type'
|
||||
|
||||
/**
|
||||
* Основной layout кабинета с Mantine AppShell.
|
||||
*
|
||||
* Используется для:
|
||||
* - оборачивания защищённых маршрутов кабинета
|
||||
* - отображения шапки, сайдбара и футера
|
||||
*/
|
||||
export const MainLayout = (props: MainLayoutProps) => {
|
||||
const { children, className, onLogout, username, ...rootAttrs } = props
|
||||
const [isNavbarOpened, navbar] = useDisclosure(false)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
{...rootAttrs}
|
||||
className={cl(styles.root, className)}
|
||||
footer={{ height: 48 }}
|
||||
header={{ height: 72 }}
|
||||
navbar={{ breakpoint: 'md', collapsed: { mobile: !isNavbarOpened }, width: 280 }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" justify="space-between" px={{ base: 'md', md: 'xl' }}>
|
||||
<Group gap="md">
|
||||
<Burger hiddenFrom="md" opened={isNavbarOpened} size="sm" onClick={navbar.toggle} />
|
||||
|
||||
<Group gap="sm" aria-label="Assets Delivery Platform">
|
||||
<ThemeIcon radius="xl" size={42} variant="light">
|
||||
AD
|
||||
</ThemeIcon>
|
||||
<Text fw={700} visibleFrom="sm">
|
||||
Assets Delivery
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{username}
|
||||
</Text>
|
||||
<Button color="gray" variant="light" onClick={onLogout}>
|
||||
Выйти
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<Stack gap="sm">
|
||||
<Text c="dimmed" fw={700} size="xs" tt="uppercase">
|
||||
Навигация
|
||||
</Text>
|
||||
<NavLink active label="Вход выполнен" />
|
||||
<NavLink disabled label="Проекты появятся позже" />
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Container py="xl" size="lg">
|
||||
{children}
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Footer>
|
||||
<Group h="100%" justify="space-between" px={{ base: 'md', md: 'xl' }}>
|
||||
<Text c="dimmed" size="sm">
|
||||
Control plane
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
REST + SWR
|
||||
</Text>
|
||||
</Group>
|
||||
</AppShell.Footer>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры MainLayout.
|
||||
*/
|
||||
export type MainLayoutParams = {
|
||||
/** Контент текущего маршрута. */
|
||||
children: ReactNode
|
||||
/** Имя авторизованного администратора. */
|
||||
username: string
|
||||
/** Обработчик выхода из кабинета. */
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type MainLayoutProps = RootAttrs & MainLayoutParams
|
||||
2
apps/cabinet/src/screens/login/index.ts
Normal file
2
apps/cabinet/src/screens/login/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LoginScreen } from './login.screen'
|
||||
export type { LoginScreenProps } from './types/login-screen-props.type'
|
||||
106
apps/cabinet/src/screens/login/login.screen.tsx
Normal file
106
apps/cabinet/src/screens/login/login.screen.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { useForm } from '@mantine/form'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import cl from 'clsx'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { authFactory } from 'business/auth'
|
||||
|
||||
import styles from './styles/login.module.css'
|
||||
import type { LoginScreenProps } from './types/login-screen-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Экран входа администратора.
|
||||
*
|
||||
* Используется для:
|
||||
* - получения admin Bearer token
|
||||
* - входа в защищенный layout кабинета
|
||||
*/
|
||||
export const LoginScreen = (props: LoginScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const navigate = useNavigate()
|
||||
const session = auth.useAdminSession()
|
||||
const loginAction = auth.useLogin()
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password: 'admin',
|
||||
username: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
if (session.isAuthenticated) {
|
||||
return <Navigate replace to="/" />
|
||||
}
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
try {
|
||||
await loginAction.login(values)
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Admin-сессия активна.',
|
||||
title: 'Вход выполнен',
|
||||
})
|
||||
navigate('/', { replace: true })
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message: error instanceof Error ? error.message : 'Не удалось войти.',
|
||||
title: 'Ошибка входа',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Center mih="100vh" p="md">
|
||||
<Box w="100%" maw={460}>
|
||||
<Stack gap="lg">
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon radius="xl" size={52} variant="light">
|
||||
AD
|
||||
</ThemeIcon>
|
||||
<Title order={1} ta="center">
|
||||
Assets Delivery
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
Вход в кабинет управления платформой.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Paper p="xl" radius="lg" shadow="sm" withBorder>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
{loginAction.error ? (
|
||||
<Alert color="red" radius="lg" title="Вход не выполнен">
|
||||
{loginAction.error.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<TextInput label="Логин" placeholder="admin" required {...form.getInputProps('username')} />
|
||||
<PasswordInput label="Пароль" placeholder="admin" required {...form.getInputProps('password')} />
|
||||
|
||||
<Button loading={loginAction.isLoggingIn} type="submit">
|
||||
Войти
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Center>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
3
apps/cabinet/src/screens/login/styles/login.module.css
Normal file
3
apps/cabinet/src/screens/login/styles/login.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры LoginScreen.
|
||||
*/
|
||||
export type LoginScreenParams = Record<string, never>
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||
|
||||
export type LoginScreenProps = RootAttrs & LoginScreenParams
|
||||
2
apps/cabinet/src/screens/session/index.ts
Normal file
2
apps/cabinet/src/screens/session/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SessionScreen } from './session.screen'
|
||||
export type { SessionScreenProps } from './types/session-screen-props.type'
|
||||
52
apps/cabinet/src/screens/session/session.screen.tsx
Normal file
52
apps/cabinet/src/screens/session/session.screen.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Badge, Group, Paper, Stack, Text, ThemeIcon, Title } from '@mantine/core'
|
||||
import cl from 'clsx'
|
||||
import { authFactory } from 'business/auth'
|
||||
|
||||
import styles from './styles/session.module.css'
|
||||
import type { SessionScreenProps } from './types/session-screen-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Экран активной admin-сессии.
|
||||
*
|
||||
* Используется для:
|
||||
* - подтверждения успешного входа
|
||||
* - отображения базового состояния кабинета до продуктовых экранов
|
||||
*/
|
||||
export const SessionScreen = (props: SessionScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const session = auth.useAdminSession()
|
||||
|
||||
return (
|
||||
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Paper p="xl" radius="lg" shadow="sm" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Group gap="md">
|
||||
<ThemeIcon color="green" radius="xl" size={48} variant="light">
|
||||
OK
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={1}>Вход выполнен</Title>
|
||||
<Text c="dimmed">Admin-сессия получена через новый backend.</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Badge color="green" variant="light">
|
||||
active
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text>
|
||||
Пользователь: <strong>{session.session?.username ?? 'admin'}</strong>
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed">
|
||||
Продуктовые экраны не подключены. Следующим шагом сюда можно добавить проекты и токены доступа к проектам.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры SessionScreen.
|
||||
*/
|
||||
export type SessionScreenParams = Record<string, never>
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||
|
||||
export type SessionScreenProps = RootAttrs & SessionScreenParams
|
||||
3
apps/cabinet/src/shared/styles/global.css
Normal file
3
apps/cabinet/src/shared/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "@mantine/core/styles.css";
|
||||
@import "@mantine/notifications/styles.css";
|
||||
@import "./variables.css";
|
||||
17
apps/cabinet/src/shared/styles/media.css
Normal file
17
apps/cabinet/src/shared/styles/media.css
Normal file
@@ -0,0 +1,17 @@
|
||||
/* Ширина - Mobile First (min-width), кроме --xs (max-width). */
|
||||
@custom-media --xs (max-width: 35.9375rem);
|
||||
@custom-media --sm (min-width: 36rem);
|
||||
@custom-media --md (min-width: 48rem);
|
||||
@custom-media --lg (min-width: 62rem);
|
||||
@custom-media --xl (min-width: 75rem);
|
||||
@custom-media --2xl (min-width: 88rem);
|
||||
@custom-media --3xl (min-width: 120rem);
|
||||
|
||||
/* Высота - min-height. */
|
||||
@custom-media --h-xs (min-height: 41.6875rem);
|
||||
@custom-media --h-sm (min-height: 43.875rem);
|
||||
@custom-media --h-md (min-height: 50.625rem);
|
||||
@custom-media --h-lg (min-height: 56.25rem);
|
||||
@custom-media --h-xl (min-height: 62.5rem);
|
||||
@custom-media --h-2xl (min-height: 68.75rem);
|
||||
@custom-media --h-3xl (min-height: 75rem);
|
||||
12
apps/cabinet/src/shared/styles/variables.css
Normal file
12
apps/cabinet/src/shared/styles/variables.css
Normal file
@@ -0,0 +1,12 @@
|
||||
:root {
|
||||
--color-primary: #228be6;
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-hover: #f8f9fa;
|
||||
--color-text: #1a1b1e;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--radius-1: 4px;
|
||||
--radius-2: 8px;
|
||||
}
|
||||
1
apps/cabinet/src/vite-env.d.ts
vendored
Normal file
1
apps/cabinet/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
33
apps/cabinet/tsconfig.app.json
Normal file
33
apps/cabinet/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"app/*": ["src/app/*"],
|
||||
"layouts/*": ["src/layouts/*"],
|
||||
"screens/*": ["src/screens/*"],
|
||||
"widgets/*": ["src/widgets/*"],
|
||||
"business/*": ["src/business/*"],
|
||||
"infra/*": ["src/infra/*"],
|
||||
"ui/*": ["src/ui/*"],
|
||||
"shared/*": ["src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
apps/cabinet/tsconfig.json
Normal file
4
apps/cabinet/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
15
apps/cabinet/tsconfig.node.json
Normal file
15
apps/cabinet/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
31
apps/cabinet/vite.config.ts
Normal file
31
apps/cabinet/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const srcPath = (path: string) => fileURLToPath(new URL(`./src/${path}`, import.meta.url))
|
||||
const backendProxyTarget = process.env.CABINET_BACKEND_PROXY_TARGET ?? 'http://localhost:3001'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
app: srcPath('app'),
|
||||
layouts: srcPath('layouts'),
|
||||
screens: srcPath('screens'),
|
||||
widgets: srcPath('widgets'),
|
||||
business: srcPath('business'),
|
||||
infra: srcPath('infra'),
|
||||
ui: srcPath('ui'),
|
||||
shared: srcPath('shared'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
changeOrigin: true,
|
||||
target: backendProxyTarget,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
8
apps/old-backend/nest-cli.json
Normal file
8
apps/old-backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
35
apps/old-backend/package.json
Normal file
35
apps/old-backend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@image-platform/old-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "node --env-file-if-exists=../../.env ./node_modules/@nestjs/cli/bin/nest.js start --watch",
|
||||
"start": "node --env-file-if-exists=../../.env dist/main.js",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@image-platform/database": "workspace:*",
|
||||
"@image-platform/image-config": "workspace:*",
|
||||
"@image-platform/queue": "workspace:*",
|
||||
"@image-platform/storage": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"amqplib": "^1.0.4",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
19
apps/old-backend/src/app.module.ts
Normal file
19
apps/old-backend/src/app.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from "@nestjs/common"
|
||||
|
||||
import { AssetsController } from "./assets/assets.controller"
|
||||
import { AssetsService } from "./assets/assets.service"
|
||||
import { HealthController } from "./health/health.controller"
|
||||
import { DatabaseService } from "./infra/database.service"
|
||||
import { QueueService } from "./infra/queue.service"
|
||||
import { StorageService } from "./infra/storage.service"
|
||||
import { InternalImagesController } from "./internal-images/internal-images.controller"
|
||||
import { InternalImagesService } from "./internal-images/internal-images.service"
|
||||
import { PresetsController } from "./presets/presets.controller"
|
||||
import { ProjectsController } from "./projects/projects.controller"
|
||||
import { ProjectsService } from "./projects/projects.service"
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController, AssetsController, InternalImagesController, PresetsController, ProjectsController],
|
||||
providers: [AssetsService, DatabaseService, InternalImagesService, ProjectsService, QueueService, StorageService],
|
||||
})
|
||||
export class AppModule {}
|
||||
9
apps/old-backend/src/health/health-response.dto.ts
Normal file
9
apps/old-backend/src/health/health-response.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger"
|
||||
|
||||
export class HealthResponseDto {
|
||||
@ApiProperty({ description: "Имя сервиса, который вернул health-check ответ.", example: "image-platform-api" })
|
||||
service!: string
|
||||
|
||||
@ApiProperty({ description: "Текущее состояние сервиса.", example: "ok" })
|
||||
status!: string
|
||||
}
|
||||
21
apps/old-backend/src/health/health.controller.ts
Normal file
21
apps/old-backend/src/health/health.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get } from "@nestjs/common"
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"
|
||||
|
||||
import { HealthResponseDto } from "./health-response.dto"
|
||||
|
||||
@ApiTags("system")
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "проверить состояние Backend API",
|
||||
description: "Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests.",
|
||||
})
|
||||
@ApiOkResponse({ description: "Backend API доступен и отвечает.", type: HealthResponseDto })
|
||||
getHealth(): HealthResponseDto {
|
||||
return {
|
||||
service: "image-platform-api",
|
||||
status: "ok",
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/old-backend/src/infra/database.service.ts
Normal file
14
apps/old-backend/src/infra/database.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common"
|
||||
import { createDatabase, createDatabasePool } from "@image-platform/database"
|
||||
import type { Database } from "@image-platform/database"
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleDestroy {
|
||||
private readonly pool = createDatabasePool()
|
||||
|
||||
readonly db: Database = createDatabase(this.pool)
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.pool.end()
|
||||
}
|
||||
}
|
||||
40
apps/old-backend/src/main.ts
Normal file
40
apps/old-backend/src/main.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NestFactory } from "@nestjs/core"
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"
|
||||
|
||||
import { AppModule } from "./app.module"
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule)
|
||||
|
||||
app.setGlobalPrefix("api")
|
||||
app.enableShutdownHooks()
|
||||
|
||||
const openApiConfig = new DocumentBuilder()
|
||||
.setTitle("Image Platform API")
|
||||
.setDescription(
|
||||
"Backend API для управления image assets, metadata в PostgreSQL, S3 variants, RabbitMQ jobs и генерацией через imgproxy.",
|
||||
)
|
||||
.setVersion("0.1.0")
|
||||
.addTag("system", "Системные endpoints для проверки состояния сервиса.")
|
||||
.addTag("assets", "Регистрация и управление исходными изображениями.")
|
||||
.addTag("variants", "Будущие endpoints для управления производными версиями изображений.")
|
||||
.addTag("allowed-hosts", "Будущие endpoints для управления разрешёнными source hosts.")
|
||||
.addTag("internal-images", "Внутренние endpoints, которые вызывает Gateway на cache miss.")
|
||||
.addTag("presets", "Статические presets, custom limits и mock allowlist source hosts.")
|
||||
.build()
|
||||
|
||||
const openApiDocument = SwaggerModule.createDocument(app, openApiConfig)
|
||||
|
||||
SwaggerModule.setup("docs", app, openApiDocument, {
|
||||
jsonDocumentUrl: "docs-json",
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
},
|
||||
})
|
||||
|
||||
const port = Number.parseInt(process.env.BACKEND_PORT ?? process.env.API_PORT ?? "3001", 10)
|
||||
|
||||
await app.listen(port)
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user