From 5c49bac77206960b9c186044aa6e62c14a9754d4 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 6 Dec 2025 17:10:56 +0700 Subject: [PATCH] 251206:1710 specs: frontend plan P1,P3 wait Verification --- backend/package.json | 1 + backend/src/common/auth/auth.controller.ts | 59 +++++-- backend/src/common/auth/auth.module.ts | 24 +-- backend/src/common/auth/auth.service.ts | 154 ++++++++++++++---- .../src/common/auth/casl/ability.factory.ts | 29 ++-- backend/src/common/auth/dto/login.dto.ts | 6 + .../auth/entities/refresh-token.entity.ts | 38 +++++ .../common/auth/guards/permissions.guard.ts | 2 +- backend/src/common/guards/rbac.guard.ts | 26 +-- backend/src/main.ts | 21 ++- .../dto/create-correspondence.dto.ts | 25 ++- .../entities/routing-template.entity.ts | 4 +- .../document-numbering.service.ts | 2 +- .../controllers/health.controller.ts | 9 +- .../modules/monitoring/monitoring.module.ts | 41 ++++- .../monitoring/services/metrics.service.ts | 56 +------ .../project/entities/contract.entity.ts | 4 +- .../project/entities/organization.entity.ts | 2 +- .../project/entities/project.entity.ts | 2 +- .../rfa/dto/create-rfa-revision.dto.ts | 33 +++- backend/src/modules/rfa/rfa.controller.ts | 30 +++- .../src/modules/user/dto/create-user.dto.ts | 12 ++ .../src/modules/user/entities/role.entity.ts | 20 ++- backend/src/modules/user/user.controller.ts | 47 ++++-- .../workflow-engine/dsl/parser.service.ts | 25 +-- .../dsl/workflow-dsl.schema.ts | 2 +- backend/test_debug.txt | 42 +++++ backend/test_debug_2.txt | 52 ++++++ backend/test_error.txt | 32 ++++ backend/test_error_2.txt | 34 ++++ backend/test_failures.txt | 42 +++++ frontend/.env.example | 6 + frontend/app/(auth)/login/page.tsx | 16 +- frontend/lib/auth.ts | 110 +++++++++---- frontend/types/next-auth.d.ts | 8 +- pnpm-lock.yaml | 14 ++ specs/09-history/2025-12-06_p0-build-fixes.md | 53 ++++++ .../09-history/2025-12-06_p1-frontend-plan.md | 33 ++++ specs/09-history/2025-12-06_p2-completion.md | 61 +++++++ .../2025-12-06_p3-admin-panel-plan.md | 44 +++++ 40 files changed, 977 insertions(+), 244 deletions(-) create mode 100644 backend/src/common/auth/entities/refresh-token.entity.ts create mode 100644 backend/test_debug.txt create mode 100644 backend/test_debug_2.txt create mode 100644 backend/test_error.txt create mode 100644 backend/test_error_2.txt create mode 100644 backend/test_failures.txt create mode 100644 frontend/.env.example create mode 100644 specs/09-history/2025-12-06_p0-build-fixes.md create mode 100644 specs/09-history/2025-12-06_p1-frontend-plan.md create mode 100644 specs/09-history/2025-12-06_p2-completion.md create mode 100644 specs/09-history/2025-12-06_p3-admin-panel-plan.md diff --git a/backend/package.json b/backend/package.json index ed89ac1..45bc73c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,6 +43,7 @@ "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.1.9", "@types/nodemailer": "^7.0.4", + "@willsoto/nestjs-prometheus": "^6.0.2", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "async-retry": "^1.3.3", diff --git a/backend/src/common/auth/auth.controller.ts b/backend/src/common/auth/auth.controller.ts index 43e55c1..9032205 100644 --- a/backend/src/common/auth/auth.controller.ts +++ b/backend/src/common/auth/auth.controller.ts @@ -18,10 +18,16 @@ import { LoginDto } from './dto/login.dto.js'; import { RegisterDto } from './dto/register.dto.js'; import { JwtAuthGuard } from '../guards/jwt-auth.guard.js'; import { JwtRefreshGuard } from '../guards/jwt-refresh.guard.js'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; -import { Request } from 'express'; // ✅ Import Request +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, + ApiBody, +} from '@nestjs/swagger'; +import { Request } from 'express'; -// สร้าง Interface สำหรับ Request ที่มี User (เพื่อให้ TS รู้จัก req.user) +// สร้าง Interface สำหรับ Request ที่มี User interface RequestWithUser extends Request { user: any; } @@ -34,11 +40,24 @@ export class AuthController { @Post('login') @Throttle({ default: { limit: 5, ttl: 60000 } }) @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'เข้าสู่ระบบเพื่อรับ Access & Refresh Token' }) + @ApiOperation({ summary: 'Login to get Access & Refresh Token' }) + @ApiBody({ type: LoginDto }) + @ApiResponse({ + status: 200, + description: 'Login successful', + schema: { + type: 'object', + properties: { + access_token: { type: 'string' }, + refresh_token: { type: 'string' }, + user: { type: 'object' }, + }, + }, + }) async login(@Body() loginDto: LoginDto) { const user = await this.authService.validateUser( loginDto.username, - loginDto.password, + loginDto.password ); if (!user) { @@ -51,7 +70,9 @@ export class AuthController { @Post('register-admin') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'สร้างบัญชีผู้ใช้ใหม่ (Admin Only)' }) + @ApiOperation({ summary: 'Create new user (Admin Only)' }) + @ApiBody({ type: RegisterDto }) + @ApiResponse({ status: 201, description: 'User registered' }) async register(@Body() registerDto: RegisterDto) { return this.authService.register(registerDto); } @@ -59,9 +80,20 @@ export class AuthController { @UseGuards(JwtRefreshGuard) @Post('refresh') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'ขอ Access Token ใหม่ด้วย Refresh Token' }) + @ApiBearerAuth() + @ApiOperation({ summary: 'Refresh Access Token using Refresh Token' }) + @ApiResponse({ + status: 200, + description: 'Token refreshed', + schema: { + type: 'object', + properties: { + access_token: { type: 'string' }, + refresh_token: { type: 'string' }, + }, + }, + }) async refresh(@Req() req: RequestWithUser) { - // ✅ ระบุ Type ชัดเจน return this.authService.refreshToken(req.user.sub, req.user.refreshToken); } @@ -69,23 +101,24 @@ export class AuthController { @Post('logout') @HttpCode(HttpStatus.OK) @ApiBearerAuth() - @ApiOperation({ summary: 'ออกจากระบบ (Revoke Token)' }) + @ApiOperation({ summary: 'Logout (Revoke Tokens)' }) + @ApiResponse({ status: 200, description: 'Logged out successfully' }) async logout(@Req() req: RequestWithUser) { - // ✅ ระบุ Type ชัดเจน const token = req.headers.authorization?.split(' ')[1]; - // ต้องเช็คว่ามี token หรือไม่ เพื่อป้องกัน runtime error if (!token) { return { message: 'No token provided' }; } + // ส่ง refresh token ไปด้วยถ้ามี (ใน header หรือ body) + // สำหรับตอนนี้ส่งแค่ access token ไป blacklist return this.authService.logout(req.user.sub, token); } @UseGuards(JwtAuthGuard) @Get('profile') @ApiBearerAuth() - @ApiOperation({ summary: 'ดึงข้อมูลผู้ใช้ปัจจุบัน' }) + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'User profile' }) getProfile(@Req() req: RequestWithUser) { - // ✅ ระบุ Type ชัดเจน return req.user; } } diff --git a/backend/src/common/auth/auth.module.ts b/backend/src/common/auth/auth.module.ts index 1bab533..91ac1f8 100644 --- a/backend/src/common/auth/auth.module.ts +++ b/backend/src/common/auth/auth.module.ts @@ -1,6 +1,7 @@ // File: src/common/auth/auth.module.ts // บันทึกการแก้ไข: แก้ไข Type Mismatch ของ expiresIn (Fix TS2322) // [P0-1] เพิ่ม CASL RBAC Integration +// [P2-2] Register RefreshToken Entity import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -13,18 +14,19 @@ import { UserModule } from '../../modules/user/user.module.js'; import { JwtStrategy } from './strategies/jwt.strategy.js'; import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js'; import { User } from '../../modules/user/entities/user.entity'; -import { CaslModule } from './casl/casl.module'; // [P0-1] Import CASL -import { PermissionsGuard } from './guards/permissions.guard'; // [P0-1] Import Guard +import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2] +import { CaslModule } from './casl/casl.module'; +import { PermissionsGuard } from './guards/permissions.guard'; @Module({ imports: [ - TypeOrmModule.forFeature([User]), + TypeOrmModule.forFeature([User, RefreshToken]), // [P2-2] Added RefreshToken UserModule, PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: async (configService: ConfigService) => ({ + useFactory: (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET'), signOptions: { expiresIn: (configService.get('JWT_EXPIRATION') || @@ -32,18 +34,10 @@ import { PermissionsGuard } from './guards/permissions.guard'; // [P0-1] Import }, }), }), - CaslModule, // [P0-1] Import CASL module - ], - providers: [ - AuthService, - JwtStrategy, - JwtRefreshStrategy, - PermissionsGuard, // [P0-1] Register PermissionsGuard + CaslModule, ], + providers: [AuthService, JwtStrategy, JwtRefreshStrategy, PermissionsGuard], controllers: [AuthController], - exports: [ - AuthService, - PermissionsGuard, // [P0-1] Export for use in other modules - ], + exports: [AuthService, PermissionsGuard], }) export class AuthModule {} diff --git a/backend/src/common/auth/auth.service.ts b/backend/src/common/auth/auth.service.ts index fdc7119..8347dc7 100644 --- a/backend/src/common/auth/auth.service.ts +++ b/backend/src/common/auth/auth.service.ts @@ -2,6 +2,7 @@ // บันทึกการแก้ไข: // 1. แก้ไข Type Mismatch ใน signAsync // 2. แก้ไข validateUser ให้ดึง password_hash ออกมาด้วย (Fix HTTP 500: data and hash arguments required) +// 3. [P2-2] Implement Refresh Token storage & rotation import { Injectable, @@ -12,14 +13,16 @@ import { import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { InjectRepository } from '@nestjs/typeorm'; // [NEW] -import { Repository } from 'typeorm'; // [NEW] +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import type { Cache } from 'cache-manager'; import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; import { UserService } from '../../modules/user/user.service.js'; -import { User } from '../../modules/user/entities/user.entity.js'; // [NEW] ต้อง Import Entity เพื่อใช้ Repository +import { User } from '../../modules/user/entities/user.entity.js'; import { RegisterDto } from './dto/register.dto.js'; +import { RefreshToken } from './entities/refresh-token.entity.js'; // [P2-2] @Injectable() export class AuthService { @@ -28,31 +31,27 @@ export class AuthService { private jwtService: JwtService, private configService: ConfigService, @Inject(CACHE_MANAGER) private cacheManager: Cache, - // [NEW] Inject Repository เพื่อใช้ QueryBuilder @InjectRepository(User) private usersRepository: Repository, + // [P2-2] Inject RefreshToken Repository + @InjectRepository(RefreshToken) + private refreshTokenRepository: Repository ) {} // 1. ตรวจสอบ Username/Password async validateUser(username: string, pass: string): Promise { - console.log(`🔍 Checking login for: ${username}`); // [DEBUG] - // [FIXED] ใช้ createQueryBuilder เพื่อ addSelect field 'password' ที่ถูกซ่อนไว้ + console.log(`🔍 Checking login for: ${username}`); const user = await this.usersRepository .createQueryBuilder('user') - .addSelect('user.password') // สำคัญ! สั่งให้ดึง column password มาด้วย + .addSelect('user.password') .where('user.username = :username', { username }) .getOne(); if (!user) { - console.log('❌ User not found in database'); // [DEBUG] + console.log('❌ User not found in database'); return null; } - console.log('✅ User found. Hash from DB:', user.password); // [DEBUG] - - const isMatch = await bcrypt.compare(pass, user.password); - console.log(`🔐 Password match result: ${isMatch}`); // [DEBUG] - // ตรวจสอบว่ามี user และมี password hash หรือไม่ if (user && user.password && (await bcrypt.compare(pass, user.password))) { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -62,7 +61,7 @@ export class AuthService { return null; } - // 2. Login: สร้าง Access & Refresh Token + // 2. Login: สร้าง Access & Refresh Token และบันทึกลง DB async login(user: any) { const payload = { username: user.username, @@ -70,20 +69,20 @@ export class AuthService { scope: 'Global', }; - const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_SECRET'), - // ✅ Fix: Cast as any - expiresIn: (this.configService.get('JWT_EXPIRATION') || - '15m') as any, - }), - this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_REFRESH_SECRET'), - // ✅ Fix: Cast as any - expiresIn: (this.configService.get('JWT_REFRESH_EXPIRATION') || - '7d') as any, - }), - ]); + const accessToken = await this.jwtService.signAsync(payload, { + secret: this.configService.get('JWT_SECRET'), + expiresIn: (this.configService.get('JWT_EXPIRATION') || + '15m') as any, + }); + + const refreshToken = await this.jwtService.signAsync(payload, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: (this.configService.get('JWT_REFRESH_EXPIRATION') || + '7d') as any, + }); + + // [P2-2] Store Refresh Token in DB + await this.storeRefreshToken(user.user_id, refreshToken); return { access_token: accessToken, @@ -92,10 +91,28 @@ export class AuthService { }; } + // [P2-2] Store Refresh Token Logic + private async storeRefreshToken(userId: number, token: string) { + // Hash token before storing for security + const hash = crypto.createHash('sha256').update(token).digest('hex'); + const expiresInDays = 7; // Should match JWT_REFRESH_EXPIRATION + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + expiresInDays); + + const refreshTokenEntity = this.refreshTokenRepository.create({ + userId, + tokenHash: hash, + expiresAt, + isRevoked: false, + }); + + await this.refreshTokenRepository.save(refreshTokenEntity); + } + // 3. Register (สำหรับ Admin) async register(userDto: RegisterDto) { const existingUser = await this.userService.findOneByUsername( - userDto.username, + userDto.username ); if (existingUser) { throw new BadRequestException('Username already exists'); @@ -110,27 +127,79 @@ export class AuthService { }); } - // 4. Refresh Token: ออก Token ใหม่ + // 4. Refresh Token: ตรวจสอบและออก Token ใหม่ (Rotation) async refreshToken(userId: number, refreshToken: string) { + // Hash incoming token to match with DB + const hash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + + // Find token in DB + const storedToken = await this.refreshTokenRepository.findOne({ + where: { tokenHash: hash }, + }); + + if (!storedToken) { + throw new UnauthorizedException('Invalid refresh token'); + } + + if (storedToken.isRevoked) { + // Possible token theft! Invalidate all user tokens family + await this.revokeAllUserTokens(userId); + throw new UnauthorizedException('Refresh token revoked - Security alert'); + } + + if (storedToken.expiresAt < new Date()) { + throw new UnauthorizedException('Refresh token expired'); + } + + // Valid token -> Rotate it const user = await this.userService.findOne(userId); if (!user) throw new UnauthorizedException('User not found'); const payload = { username: user.username, sub: user.user_id }; - const accessToken = await this.jwtService.signAsync(payload, { + // Generate NEW tokens + const newAccessToken = await this.jwtService.signAsync(payload, { secret: this.configService.get('JWT_SECRET'), - // ✅ Fix: Cast as any expiresIn: (this.configService.get('JWT_EXPIRATION') || '15m') as any, }); + const newRefreshToken = await this.jwtService.signAsync(payload, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: (this.configService.get('JWT_REFRESH_EXPIRATION') || + '7d') as any, + }); + + // Revoke OLD token and point to NEW one + const newHash = crypto + .createHash('sha256') + .update(newRefreshToken) + .digest('hex'); + + storedToken.isRevoked = true; + storedToken.replacedByToken = newHash; + await this.refreshTokenRepository.save(storedToken); + + // Save NEW token + await this.storeRefreshToken(userId, newRefreshToken); + return { - access_token: accessToken, + access_token: newAccessToken, + refresh_token: newRefreshToken, }; } - // 5. Logout: นำ Token เข้า Blacklist ใน Redis - async logout(userId: number, accessToken: string) { + // [P2-2] Helper: Revoke all tokens for a user (Security Measure) + private async revokeAllUserTokens(userId: number) { + await this.refreshTokenRepository.update( + { userId, isRevoked: false }, + { isRevoked: true } + ); + } + + // 5. Logout: Revoke current refresh token & Blacklist Access Token + async logout(userId: number, accessToken: string, refreshToken?: string) { + // Blacklist Access Token try { const decoded = this.jwtService.decode(accessToken); if (decoded && decoded.exp) { @@ -139,13 +208,26 @@ export class AuthService { await this.cacheManager.set( `blacklist:token:${accessToken}`, true, - ttl * 1000, + ttl * 1000 ); } } } catch (error) { // Ignore decoding error } + + // [P2-2] Revoke Refresh Token if provided + if (refreshToken) { + const hash = crypto + .createHash('sha256') + .update(refreshToken) + .digest('hex'); + await this.refreshTokenRepository.update( + { tokenHash: hash }, + { isRevoked: true } + ); + } + return { message: 'Logged out successfully' }; } } diff --git a/backend/src/common/auth/casl/ability.factory.ts b/backend/src/common/auth/casl/ability.factory.ts index 00ad78f..87daf59 100644 --- a/backend/src/common/auth/casl/ability.factory.ts +++ b/backend/src/common/auth/casl/ability.factory.ts @@ -1,11 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { - Ability, - AbilityBuilder, - AbilityClass, - ExtractSubjectType, - InferSubjects, -} from '@casl/ability'; +import { Ability, AbilityBuilder, AbilityClass } from '@casl/ability'; import { User } from '../../../modules/user/entities/user.entity'; import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity'; @@ -45,7 +39,7 @@ export class AbilityFactory { * - Level 4: Contract */ createForUser(user: User, context: ScopeContext): AppAbility { - const { can, cannot, build } = new AbilityBuilder( + const { can, build } = new AbilityBuilder( Ability as AbilityClass ); @@ -54,12 +48,13 @@ export class AbilityFactory { return build(); } + // Iterate through user's role assignments // Iterate through user's role assignments user.assignments.forEach((assignment: UserAssignment) => { // Check if assignment matches the current context if (this.matchesScope(assignment, context)) { // Grant permissions from the role - assignment.role.permissions.forEach((permission) => { + assignment.role.permissions?.forEach((permission) => { const [action, subject] = this.parsePermission( permission.permissionName ); @@ -70,8 +65,10 @@ export class AbilityFactory { return build({ // Detect subject type (for future use with objects) - detectSubjectType: (item) => - item.constructor as ExtractSubjectType, + detectSubjectType: (item: any) => { + if (typeof item === 'string') return item; + return item.constructor; + }, }); } @@ -120,17 +117,17 @@ export class AbilityFactory { * "project.view" → ["view", "project"] */ private parsePermission(permissionName: string): [string, string] { + // Fallback for special permissions like "system.manage_all" + if (permissionName === 'system.manage_all') { + return ['manage', 'all']; + } + const parts = permissionName.split('.'); if (parts.length === 2) { const [subject, action] = parts; return [action, subject]; } - // Fallback for special permissions like "system.manage_all" - if (permissionName === 'system.manage_all') { - return ['manage', 'all']; - } - throw new Error(`Invalid permission format: ${permissionName}`); } } diff --git a/backend/src/common/auth/dto/login.dto.ts b/backend/src/common/auth/dto/login.dto.ts index e7af3e1..bde6e85 100644 --- a/backend/src/common/auth/dto/login.dto.ts +++ b/backend/src/common/auth/dto/login.dto.ts @@ -1,10 +1,16 @@ import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class LoginDto { + @ApiProperty({ + description: 'Username (Email)', + example: 'admin@np-dms.work', + }) @IsString() @IsNotEmpty() username!: string; + @ApiProperty({ description: 'Password', example: 'password123' }) @IsString() @IsNotEmpty() password!: string; diff --git a/backend/src/common/auth/entities/refresh-token.entity.ts b/backend/src/common/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..1ebcc57 --- /dev/null +++ b/backend/src/common/auth/entities/refresh-token.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../../modules/user/entities/user.entity'; + +@Entity('refresh_tokens') +export class RefreshToken { + @PrimaryGeneratedColumn({ name: 'token_id' }) + tokenId!: number; + + @Column({ name: 'user_id' }) + userId!: number; + + @Column({ name: 'token_hash', length: 255 }) + tokenHash!: string; + + @Column({ name: 'expires_at' }) + expiresAt!: Date; + + @Column({ name: 'is_revoked', default: false }) + isRevoked!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @Column({ name: 'replaced_by_token', nullable: true, length: 255 }) + replacedByToken?: string; // For rotation support + + // Relations + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user?: User; +} diff --git a/backend/src/common/auth/guards/permissions.guard.ts b/backend/src/common/auth/guards/permissions.guard.ts index 2b49e6a..5f6a75a 100644 --- a/backend/src/common/auth/guards/permissions.guard.ts +++ b/backend/src/common/auth/guards/permissions.guard.ts @@ -43,7 +43,7 @@ export class PermissionsGuard implements CanActivate { // Check if user has ALL required permissions const hasPermission = requiredPermissions.every((permission) => { const [action, subject] = this.parsePermission(permission); - return ability.can(action, subject); + return ability.can(action as any, subject as any); }); if (!hasPermission) { diff --git a/backend/src/common/guards/rbac.guard.ts b/backend/src/common/guards/rbac.guard.ts index ec2287b..98c4297 100644 --- a/backend/src/common/guards/rbac.guard.ts +++ b/backend/src/common/guards/rbac.guard.ts @@ -5,25 +5,25 @@ import { ForbiddenException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { PERMISSION_KEY } from '../decorators/require-permission.decorator.js'; -import { UserService } from '../../modules/user/user.service.js'; +import { PERMISSIONS_KEY } from '../decorators/require-permission.decorator'; +import { UserService } from '../../modules/user/user.service'; @Injectable() export class RbacGuard implements CanActivate { constructor( private reflector: Reflector, - private userService: UserService, + private userService: UserService ) {} async canActivate(context: ExecutionContext): Promise { // 1. ดูว่า Controller นี้ต้องการสิทธิ์อะไร? - const requiredPermission = this.reflector.getAllAndOverride( - PERMISSION_KEY, - [context.getHandler(), context.getClass()], + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()] ); // ถ้าไม่ต้องการสิทธิ์อะไรเลย ก็ปล่อยผ่าน - if (!requiredPermission) { + if (!requiredPermissions || requiredPermissions.length === 0) { return true; } @@ -36,17 +36,19 @@ export class RbacGuard implements CanActivate { // 3. (สำคัญ) ดึงสิทธิ์ทั้งหมดของ User คนนี้จาก Database // เราต้องเขียนฟังก์ชัน getUserPermissions ใน UserService เพิ่ม (เดี๋ยวพาทำ) const userPermissions = await this.userService.getUserPermissions( - user.userId, + user.userId ); - // 4. ตรวจสอบว่ามีสิทธิ์ที่ต้องการไหม? - const hasPermission = userPermissions.some( - (p) => p === requiredPermission || p === 'system.manage_all', // Superadmin ทะลุทุกสิทธิ์ + // 4. ตรวจสอบว่ามีสิทธิ์ที่ต้องการไหม? (User ต้องมีครบทุกสิทธิ์) + const hasPermission = requiredPermissions.every((req) => + userPermissions.some( + (p) => p === req || p === 'system.manage_all' // Superadmin ทะลุทุกสิทธิ์ + ) ); if (!hasPermission) { throw new ForbiddenException( - `You do not have permission: ${requiredPermission}`, + `You do not have permission: ${requiredPermissions.join(', ')}` ); } diff --git a/backend/src/main.ts b/backend/src/main.ts index 24e28c3..93c7bb8 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -22,11 +22,24 @@ async function bootstrap() { const logger = new Logger('Bootstrap'); // 🛡️ 2. Security (Helmet & CORS) - app.use(helmet()); + // ปรับ CSP ให้รองรับ Swagger UI + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'blob:'], + }, + }, + crossOriginEmbedderPolicy: false, + }) + ); // ตั้งค่า CORS (ใน Production ควรระบุ origin ให้ชัดเจนจาก Config) app.enableCors({ - origin: true, // หรือ configService.get('CORS_ORIGIN') + origin: configService.get('CORS_ORIGIN') || true, methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', credentials: true, }); @@ -47,7 +60,7 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true, // ช่วยแปลง Type ใน Query Params }, - }), + }) ); // ลงทะเบียน Global Interceptor และ Filter ที่เราสร้างไว้ @@ -78,4 +91,4 @@ async function bootstrap() { logger.log(`Application is running on: ${await app.getUrl()}/api`); logger.log(`Swagger UI is available at: ${await app.getUrl()}/docs`); } -bootstrap(); +void bootstrap(); diff --git a/backend/src/modules/correspondence/dto/create-correspondence.dto.ts b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts index 101fc58..8538a52 100644 --- a/backend/src/modules/correspondence/dto/create-correspondence.dto.ts +++ b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts @@ -1,4 +1,3 @@ -// File: src/modules/correspondence/dto/create-correspondence.dto.ts import { IsInt, IsString, @@ -7,42 +6,64 @@ import { IsBoolean, IsObject, } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateCorrespondenceDto { + @ApiProperty({ description: 'Project ID', example: 1 }) @IsInt() @IsNotEmpty() projectId!: number; + @ApiProperty({ description: 'Document Type ID', example: 1 }) @IsInt() @IsNotEmpty() typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER) + @ApiPropertyOptional({ description: 'Discipline ID', example: 2 }) @IsInt() @IsOptional() disciplineId?: number; // [Req 6B] สาขางาน (เช่น GEN, STR) + @ApiPropertyOptional({ description: 'Sub Type ID', example: 3 }) @IsInt() @IsOptional() subTypeId?: number; // [Req 6B] ประเภทย่อย (เช่น MAT, SHP สำหรับ Transmittal/RFA) + @ApiProperty({ + description: 'Correspondence Title', + example: 'Monthly Progress Report', + }) @IsString() @IsNotEmpty() title!: string; + @ApiPropertyOptional({ + description: 'Correspondence Description', + example: 'Detailed report...', + }) @IsString() @IsOptional() description?: string; + @ApiPropertyOptional({ + description: 'Additional details (JSON)', + example: { key: 'value' }, + }) @IsObject() @IsOptional() details?: Record; // ข้อมูล JSON (เช่น RFI question) + @ApiPropertyOptional({ description: 'Is internal document?', default: false }) @IsBoolean() @IsOptional() isInternal?: boolean; // ✅ เพิ่ม Field สำหรับ Impersonation (เลือกองค์กรผู้ส่ง) + @ApiPropertyOptional({ + description: 'Originator Organization ID (for impersonation)', + example: 1, + }) @IsInt() @IsOptional() originatorId?: number; -} \ No newline at end of file +} diff --git a/backend/src/modules/correspondence/entities/routing-template.entity.ts b/backend/src/modules/correspondence/entities/routing-template.entity.ts index bc30a24..56e98dc 100644 --- a/backend/src/modules/correspondence/entities/routing-template.entity.ts +++ b/backend/src/modules/correspondence/entities/routing-template.entity.ts @@ -1,6 +1,6 @@ import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; -import { BaseEntity } from '../../../common/entities/base.entity.js'; // ถ้าไม่ได้ใช้ BaseEntity ก็ลบออกแล้วใส่ createdAt เอง -import { RoutingTemplateStep } from './routing-template-step.entity.js'; // เดี๋ยวสร้าง +import { BaseEntity } from '../../../common/entities/base.entity'; // ถ้าไม่ได้ใช้ BaseEntity ก็ลบออกแล้วใส่ createdAt เอง +import { RoutingTemplateStep } from './routing-template-step.entity'; // เดี๋ยวสร้าง @Entity('correspondence_routing_templates') export class RoutingTemplate { diff --git a/backend/src/modules/document-numbering/document-numbering.service.ts b/backend/src/modules/document-numbering/document-numbering.service.ts index e31df95..15a1ea1 100644 --- a/backend/src/modules/document-numbering/document-numbering.service.ts +++ b/backend/src/modules/document-numbering/document-numbering.service.ts @@ -182,7 +182,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { throw new InternalServerErrorException( 'Failed to generate document number after retries.' ); - } catch (error) { + } catch (error: any) { this.logger.error(`Error generating number for ${resourceKey}`, error); // [P0-4] Log error diff --git a/backend/src/modules/monitoring/controllers/health.controller.ts b/backend/src/modules/monitoring/controllers/health.controller.ts index bd925ab..58513be 100644 --- a/backend/src/modules/monitoring/controllers/health.controller.ts +++ b/backend/src/modules/monitoring/controllers/health.controller.ts @@ -8,7 +8,6 @@ import { MemoryHealthIndicator, DiskHealthIndicator, } from '@nestjs/terminus'; -import { MetricsService } from '../services/metrics.service'; @Controller() export class HealthController { @@ -17,8 +16,7 @@ export class HealthController { private http: HttpHealthIndicator, private db: TypeOrmHealthIndicator, private memory: MemoryHealthIndicator, - private disk: DiskHealthIndicator, - private metricsService: MetricsService, + private disk: DiskHealthIndicator ) {} @Get('health') @@ -37,9 +35,4 @@ export class HealthController { this.disk.checkStorage('storage', { path: '/', thresholdPercent: 0.9 }), ]); } - - @Get('metrics') - async getMetrics() { - return await this.metricsService.getMetrics(); - } } diff --git a/backend/src/modules/monitoring/monitoring.module.ts b/backend/src/modules/monitoring/monitoring.module.ts index 27a3714..b7f4a9b 100644 --- a/backend/src/modules/monitoring/monitoring.module.ts +++ b/backend/src/modules/monitoring/monitoring.module.ts @@ -4,6 +4,11 @@ import { Global, Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { HttpModule } from '@nestjs/axios'; import { APP_INTERCEPTOR } from '@nestjs/core'; +import { + PrometheusModule, + makeCounterProvider, + makeHistogramProvider, +} from '@willsoto/nestjs-prometheus'; // Existing Components import { HealthController } from './controllers/health.controller'; @@ -14,21 +19,39 @@ import { PerformanceInterceptor } from '../../common/interceptors/performance.in import { MonitoringController } from './monitoring.controller'; import { MonitoringService } from './monitoring.service'; -@Global() // Module นี้เป็น Global (ดีแล้วครับ) +@Global() @Module({ - imports: [TerminusModule, HttpModule], - controllers: [ - HealthController, // ✅ ของเดิม: /health - MonitoringController, // ✅ ของใหม่: /monitoring/maintenance + imports: [ + TerminusModule, + HttpModule, + PrometheusModule.register({ + path: '/metrics', + defaultMetrics: { + enabled: true, + }, + }), ], + controllers: [HealthController, MonitoringController], providers: [ - MetricsService, // ✅ ของเดิม - MonitoringService, // ✅ ของใหม่ (Logic เปิด/ปิด Maintenance) + MetricsService, + MonitoringService, { provide: APP_INTERCEPTOR, - useClass: PerformanceInterceptor, // ✅ ของเดิม (จับเวลา Response Time) + useClass: PerformanceInterceptor, }, + // Metrics Providers + makeCounterProvider({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], + }), + makeHistogramProvider({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 5.0], + }), ], - exports: [MetricsService, MonitoringService], + exports: [MetricsService, MonitoringService, PrometheusModule], }) export class MonitoringModule {} diff --git a/backend/src/modules/monitoring/services/metrics.service.ts b/backend/src/modules/monitoring/services/metrics.service.ts index e74e6c3..ac911db 100644 --- a/backend/src/modules/monitoring/services/metrics.service.ts +++ b/backend/src/modules/monitoring/services/metrics.service.ts @@ -1,54 +1,16 @@ // File: src/modules/monitoring/services/metrics.service.ts import { Injectable } from '@nestjs/common'; -import { Registry, Counter, Histogram, Gauge } from 'prom-client'; +import { Counter, Histogram } from 'prom-client'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; @Injectable() export class MetricsService { - private readonly registry: Registry; - public readonly httpRequestsTotal: Counter; - public readonly httpRequestDuration: Histogram; - public readonly systemMemoryUsage: Gauge; + constructor( + @InjectMetric('http_requests_total') + public readonly httpRequestsTotal: Counter, + @InjectMetric('http_request_duration_seconds') + public readonly httpRequestDuration: Histogram + ) {} - constructor() { - this.registry = new Registry(); - this.registry.setDefaultLabels({ app: 'lcbp3-backend' }); - - // นับจำนวน HTTP Request ทั้งหมด แยกตาม method, route, status_code - this.httpRequestsTotal = new Counter({ - name: 'http_requests_total', - help: 'Total number of HTTP requests', - labelNames: ['method', 'route', 'status_code'], - registers: [this.registry], - }); - - // วัดระยะเวลา Response Time (Histogram) - this.httpRequestDuration = new Histogram({ - name: 'http_request_duration_seconds', - help: 'Duration of HTTP requests in seconds', - labelNames: ['method', 'route', 'status_code'], - buckets: [0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 5.0], // Buckets สำหรับวัด Latency - registers: [this.registry], - }); - - // วัดการใช้ Memory (Gauge) - this.systemMemoryUsage = new Gauge({ - name: 'system_memory_usage_bytes', - help: 'Heap memory usage in bytes', - registers: [this.registry], - }); - - // เริ่มเก็บ Metrics พื้นฐานของ Node.js (Optional) - // client.collectDefaultMetrics({ register: this.registry }); - } - - /** - * ดึงข้อมูล Metrics ทั้งหมดในรูปแบบ Text สำหรับ Prometheus Scrape - */ - async getMetrics(): Promise { - // อัปเดต Memory Usage ก่อน Return - const memoryUsage = process.memoryUsage(); - this.systemMemoryUsage.set(memoryUsage.heapUsed); - - return this.registry.metrics(); - } + // Removed manual getMetrics() as PrometheusModule handles /metrics } diff --git a/backend/src/modules/project/entities/contract.entity.ts b/backend/src/modules/project/entities/contract.entity.ts index d9091ae..6e4c638 100644 --- a/backend/src/modules/project/entities/contract.entity.ts +++ b/backend/src/modules/project/entities/contract.entity.ts @@ -5,8 +5,8 @@ import { ManyToOne, JoinColumn, } from 'typeorm'; -import { BaseEntity } from '../../../common/entities/base.entity.js'; -import { Project } from './project.entity.js'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Project } from './project.entity'; @Entity('contracts') export class Contract extends BaseEntity { diff --git a/backend/src/modules/project/entities/organization.entity.ts b/backend/src/modules/project/entities/organization.entity.ts index 9de52cd..4c2b409 100644 --- a/backend/src/modules/project/entities/organization.entity.ts +++ b/backend/src/modules/project/entities/organization.entity.ts @@ -1,5 +1,5 @@ import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; -import { BaseEntity } from '../../../common/entities/base.entity.js'; +import { BaseEntity } from '../../../common/entities/base.entity'; @Entity('organizations') export class Organization extends BaseEntity { diff --git a/backend/src/modules/project/entities/project.entity.ts b/backend/src/modules/project/entities/project.entity.ts index 9e09f34..0b11b1e 100644 --- a/backend/src/modules/project/entities/project.entity.ts +++ b/backend/src/modules/project/entities/project.entity.ts @@ -1,5 +1,5 @@ import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; -import { BaseEntity } from '../../../common/entities/base.entity.js'; +import { BaseEntity } from '../../../common/entities/base.entity'; @Entity('projects') export class Project extends BaseEntity { diff --git a/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts b/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts index 515a3e8..04e75a7 100644 --- a/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts +++ b/backend/src/modules/rfa/dto/create-rfa-revision.dto.ts @@ -1,4 +1,3 @@ -// File: src/modules/rfa/dto/create-rfa-revision.dto.ts import { IsString, IsNotEmpty, @@ -8,44 +7,76 @@ import { IsObject, IsArray, } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateRfaRevisionDto { + @ApiProperty({ description: 'RFA Title', example: 'RFA for Building A' }) @IsString() @IsNotEmpty() title!: string; + @ApiProperty({ description: 'RFA Status Code ID', example: 1 }) @IsInt() @IsNotEmpty() rfaStatusCodeId!: number; + @ApiPropertyOptional({ description: 'RFA Approve Code ID', example: 1 }) @IsInt() @IsOptional() rfaApproveCodeId?: number; + @ApiPropertyOptional({ + description: 'Document Date', + example: '2025-12-06T00:00:00Z', + }) @IsDateString() @IsOptional() documentDate?: string; + @ApiPropertyOptional({ + description: 'Issued Date', + example: '2025-12-06T00:00:00Z', + }) @IsDateString() @IsOptional() issuedDate?: string; + @ApiPropertyOptional({ + description: 'Received Date', + example: '2025-12-06T00:00:00Z', + }) @IsDateString() @IsOptional() receivedDate?: string; + @ApiPropertyOptional({ + description: 'Approved Date', + example: '2025-12-06T00:00:00Z', + }) @IsDateString() @IsOptional() approvedDate?: string; + @ApiPropertyOptional({ + description: 'Description', + example: 'Details about the RFA...', + }) @IsString() @IsOptional() description?: string; + @ApiPropertyOptional({ + description: 'Additional Details (JSON)', + example: { key: 'value' }, + }) @IsObject() @IsOptional() details?: Record; + @ApiPropertyOptional({ + description: 'Linked Shop Drawing Revision IDs', + example: [1, 2], + }) @IsArray() @IsOptional() shopDrawingRevisionIds?: number[]; // IDs of linked Shop Drawings diff --git a/backend/src/modules/rfa/rfa.controller.ts b/backend/src/modules/rfa/rfa.controller.ts index 5f53abb..af7b2e5 100644 --- a/backend/src/modules/rfa/rfa.controller.ts +++ b/backend/src/modules/rfa/rfa.controller.ts @@ -8,12 +8,19 @@ import { Post, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiBody, +} from '@nestjs/swagger'; import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; import { User } from '../user/entities/user.entity'; import { CreateRfaDto } from './dto/create-rfa.dto'; -import { SubmitRfaDto } from './dto/submit-rfa.dto'; // ✅ Import DTO ใหม่ +import { SubmitRfaDto } from './dto/submit-rfa.dto'; import { RfaService } from './rfa.service'; import { Audit } from '../../common/decorators/audit.decorator'; @@ -31,6 +38,8 @@ export class RfaController { @Post() @ApiOperation({ summary: 'Create new RFA (Draft)' }) + @ApiBody({ type: CreateRfaDto }) + @ApiResponse({ status: 201, description: 'RFA created successfully' }) @RequirePermission('rfa.create') @Audit('rfa.create', 'rfa') create(@Body() createDto: CreateRfaDto, @CurrentUser() user: User) { @@ -39,30 +48,41 @@ export class RfaController { @Post(':id/submit') @ApiOperation({ summary: 'Submit RFA to Workflow' }) + @ApiParam({ name: 'id', description: 'RFA ID' }) + @ApiBody({ type: SubmitRfaDto }) + @ApiResponse({ status: 200, description: 'RFA submitted successfully' }) @RequirePermission('rfa.create') @Audit('rfa.submit', 'rfa') submit( @Param('id', ParseIntPipe) id: number, - @Body() submitDto: SubmitRfaDto, // ✅ ใช้ DTO - @CurrentUser() user: User, + @Body() submitDto: SubmitRfaDto, + @CurrentUser() user: User ) { return this.rfaService.submit(id, submitDto.templateId, user); } @Post(':id/action') @ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' }) + @ApiParam({ name: 'id', description: 'RFA ID' }) + @ApiBody({ type: WorkflowActionDto }) + @ApiResponse({ + status: 200, + description: 'Workflow action processed successfully', + }) @RequirePermission('workflow.action_review') @Audit('rfa.action', 'rfa') processAction( @Param('id', ParseIntPipe) id: number, @Body() actionDto: WorkflowActionDto, - @CurrentUser() user: User, + @CurrentUser() user: User ) { return this.rfaService.processAction(id, actionDto, user); } @Get(':id') @ApiOperation({ summary: 'Get RFA details with revisions and items' }) + @ApiParam({ name: 'id', description: 'RFA ID' }) + @ApiResponse({ status: 200, description: 'RFA details' }) @RequirePermission('document.view') findOne(@Param('id', ParseIntPipe) id: number) { return this.rfaService.findOne(id); diff --git a/backend/src/modules/user/dto/create-user.dto.ts b/backend/src/modules/user/dto/create-user.dto.ts index f00c8f8..369c314 100644 --- a/backend/src/modules/user/dto/create-user.dto.ts +++ b/backend/src/modules/user/dto/create-user.dto.ts @@ -7,37 +7,49 @@ import { IsBoolean, IsInt, } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateUserDto { + @ApiProperty({ description: 'Username', example: 'john_doe' }) @IsString() @IsNotEmpty() username!: string; + @ApiProperty({ + description: 'Password (min 6 chars)', + example: 'password123', + }) @IsString() @IsNotEmpty() @MinLength(6, { message: 'Password must be at least 6 characters' }) password!: string; + @ApiProperty({ description: 'Email address', example: 'john.d@example.com' }) @IsEmail() @IsNotEmpty() email!: string; + @ApiPropertyOptional({ description: 'First name', example: 'John' }) @IsString() @IsOptional() firstName?: string; + @ApiPropertyOptional({ description: 'Last name', example: 'Doe' }) @IsString() @IsOptional() lastName?: string; + @ApiPropertyOptional({ description: 'Line ID', example: 'john.line' }) @IsString() @IsOptional() lineId?: string; + @ApiPropertyOptional({ description: 'Primary Organization ID', example: 1 }) @IsInt() @IsOptional() primaryOrganizationId?: number; // รับเป็น ID ของ Organization + @ApiPropertyOptional({ description: 'Is user active?', default: true }) @IsBoolean() @IsOptional() isActive?: boolean; diff --git a/backend/src/modules/user/entities/role.entity.ts b/backend/src/modules/user/entities/role.entity.ts index 0fb97d0..79c1839 100644 --- a/backend/src/modules/user/entities/role.entity.ts +++ b/backend/src/modules/user/entities/role.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { Permission } from './permission.entity'; export enum RoleScope { GLOBAL = 'Global', @@ -26,4 +33,15 @@ export class Role { @Column({ name: 'is_system', default: false }) isSystem!: boolean; + + @ManyToMany(() => Permission) + @JoinTable({ + name: 'role_permissions', + joinColumn: { name: 'role_id', referencedColumnName: 'roleId' }, + inverseJoinColumn: { + name: 'permission_id', + referencedColumnName: 'permissionId', + }, + }) + permissions?: Permission[]; } diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index f7d3142..1cf9ced 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -1,6 +1,3 @@ -// File: src/modules/user/user.controller.ts -// บันทึกการแก้ไข: เพิ่ม Endpoints สำหรับ User Preferences (T1.3) - import { Controller, Get, @@ -12,18 +9,25 @@ import { UseGuards, ParseIntPipe, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, + ApiBody, + ApiParam, +} from '@nestjs/swagger'; import { UserService } from './user.service'; import { UserAssignmentService } from './user-assignment.service'; -import { UserPreferenceService } from './user-preference.service'; // ✅ เพิ่ม +import { UserPreferenceService } from './user-preference.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { AssignRoleDto } from './dto/assign-role.dto'; -import { UpdatePreferenceDto } from './dto/update-preference.dto'; // ✅ เพิ่ม DTO +import { UpdatePreferenceDto } from './dto/update-preference.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; -import { RbacGuard } from '../../common/guards/rbac.guard'; // สมมติว่ามีแล้ว ถ้ายังไม่มีให้คอมเมนต์ไว้ก่อน +import { RbacGuard } from '../../common/guards/rbac.guard'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { User } from './entities/user.entity'; @@ -31,36 +35,39 @@ import { User } from './entities/user.entity'; @ApiTags('Users') @ApiBearerAuth() @Controller('users') -@UseGuards(JwtAuthGuard, RbacGuard) // RbacGuard จะเช็ค permission +@UseGuards(JwtAuthGuard, RbacGuard) export class UserController { constructor( private readonly userService: UserService, private readonly assignmentService: UserAssignmentService, - private readonly preferenceService: UserPreferenceService, // ✅ Inject Service + private readonly preferenceService: UserPreferenceService ) {} // --- User Preferences (Me) --- - // ต้องวางไว้ก่อน :id เพื่อไม่ให้ route ชนกัน @Get('me/preferences') @ApiOperation({ summary: 'Get my preferences' }) - @UseGuards(JwtAuthGuard) // Bypass RBAC check for self + @ApiResponse({ status: 200, description: 'User preferences' }) + @UseGuards(JwtAuthGuard) getMyPreferences(@CurrentUser() user: User) { return this.preferenceService.findByUser(user.user_id); } @Patch('me/preferences') @ApiOperation({ summary: 'Update my preferences' }) - @UseGuards(JwtAuthGuard) // Bypass RBAC check for self + @ApiBody({ type: UpdatePreferenceDto }) + @ApiResponse({ status: 200, description: 'Preferences updated' }) + @UseGuards(JwtAuthGuard) updateMyPreferences( @CurrentUser() user: User, - @Body() dto: UpdatePreferenceDto, + @Body() dto: UpdatePreferenceDto ) { return this.preferenceService.update(user.user_id, dto); } @Get('me/permissions') @ApiOperation({ summary: 'Get my permissions' }) + @ApiResponse({ status: 200, description: 'User permissions' }) @UseGuards(JwtAuthGuard) getMyPermissions(@CurrentUser() user: User) { return this.userService.getUserPermissions(user.user_id); @@ -70,6 +77,8 @@ export class UserController { @Post() @ApiOperation({ summary: 'Create new user' }) + @ApiBody({ type: CreateUserDto }) + @ApiResponse({ status: 201, description: 'User created' }) @RequirePermission('user.create') create(@Body() createUserDto: CreateUserDto) { return this.userService.create(createUserDto); @@ -77,6 +86,7 @@ export class UserController { @Get() @ApiOperation({ summary: 'List all users' }) + @ApiResponse({ status: 200, description: 'List of users' }) @RequirePermission('user.view') findAll() { return this.userService.findAll(); @@ -84,6 +94,8 @@ export class UserController { @Get(':id') @ApiOperation({ summary: 'Get user details' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'User details' }) @RequirePermission('user.view') findOne(@Param('id', ParseIntPipe) id: number) { return this.userService.findOne(id); @@ -91,16 +103,21 @@ export class UserController { @Patch(':id') @ApiOperation({ summary: 'Update user' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiBody({ type: UpdateUserDto }) + @ApiResponse({ status: 200, description: 'User updated' }) @RequirePermission('user.edit') update( @Param('id', ParseIntPipe) id: number, - @Body() updateUserDto: UpdateUserDto, + @Body() updateUserDto: UpdateUserDto ) { return this.userService.update(id, updateUserDto); } @Delete(':id') @ApiOperation({ summary: 'Delete user (Soft delete)' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'User deleted' }) @RequirePermission('user.delete') remove(@Param('id', ParseIntPipe) id: number) { return this.userService.remove(id); @@ -110,6 +127,8 @@ export class UserController { @Post('assign-role') @ApiOperation({ summary: 'Assign role to user' }) + @ApiBody({ type: AssignRoleDto }) + @ApiResponse({ status: 201, description: 'Role assigned' }) @RequirePermission('permission.assign') assignRole(@Body() dto: AssignRoleDto, @CurrentUser() user: User) { return this.assignmentService.assignRole(dto, user); diff --git a/backend/src/modules/workflow-engine/dsl/parser.service.ts b/backend/src/modules/workflow-engine/dsl/parser.service.ts index b3d56b3..28a095f 100644 --- a/backend/src/modules/workflow-engine/dsl/parser.service.ts +++ b/backend/src/modules/workflow-engine/dsl/parser.service.ts @@ -34,7 +34,7 @@ export class WorkflowDslParser { // Step 5: Save to database return await this.workflowDefRepo.save(definition); - } catch (error) { + } catch (error: any) { if (error instanceof SyntaxError) { throw new BadRequestException(`Invalid JSON: ${error.message}`); } @@ -132,11 +132,14 @@ export class WorkflowDslParser { */ private buildDefinition(dsl: WorkflowDsl): WorkflowDefinition { const definition = new WorkflowDefinition(); - definition.name = dsl.name; - definition.version = dsl.version; + definition.workflow_code = dsl.name; + // Map Semver (1.0.0) to version int (1) + const majorVersion = parseInt(dsl.version.split('.')[0], 10); + definition.version = isNaN(majorVersion) ? 1 : majorVersion; definition.description = dsl.description; - definition.dslContent = JSON.stringify(dsl, null, 2); // Pretty print for readability - definition.isActive = true; + definition.dsl = dsl; + definition.compiled = dsl; + definition.is_active = true; return definition; } @@ -144,7 +147,7 @@ export class WorkflowDslParser { /** * Get parsed DSL from WorkflowDefinition */ - async getParsedDsl(definitionId: number): Promise { + async getParsedDsl(definitionId: string): Promise { const definition = await this.workflowDefRepo.findOne({ where: { id: definitionId }, }); @@ -156,14 +159,14 @@ export class WorkflowDslParser { } try { - const dsl = JSON.parse(definition.dslContent); + const dsl = definition.dsl; return WorkflowDslSchema.parse(dsl); - } catch (error) { + } catch (error: any) { this.logger.error( `Failed to parse stored DSL for definition ${definitionId}`, error ); - throw new BadRequestException(`Invalid stored DSL: ${error.message}`); + throw new BadRequestException(`Invalid stored DSL: ${error?.message}`); } } @@ -176,10 +179,10 @@ export class WorkflowDslParser { const dsl = WorkflowDslSchema.parse(rawDsl); this.validateStateMachine(dsl); return { valid: true }; - } catch (error) { + } catch (error: any) { return { valid: false, - errors: [error.message], + errors: [error?.message || 'Unknown validation error'], }; } } diff --git a/backend/src/modules/workflow-engine/dsl/workflow-dsl.schema.ts b/backend/src/modules/workflow-engine/dsl/workflow-dsl.schema.ts index b9c3ac5..30d734e 100644 --- a/backend/src/modules/workflow-engine/dsl/workflow-dsl.schema.ts +++ b/backend/src/modules/workflow-engine/dsl/workflow-dsl.schema.ts @@ -137,7 +137,7 @@ export const RFA_WORKFLOW_EXAMPLE: WorkflowDsl = { config: { status: 'APPROVED' }, }, { - type: 'send_notification', + type: 'create_notification', config: { message: 'RFA has been approved', type: 'success', diff --git a/backend/test_debug.txt b/backend/test_debug.txt new file mode 100644 index 0000000..6aa7d5d --- /dev/null +++ b/backend/test_debug.txt @@ -0,0 +1,42 @@ + +> backend@1.5.1 test D:\nap-dms.lcbp3\backend +> jest "src/common/auth/casl/ability.factory.spec.ts" + +FAIL src/common/auth/casl/ability.factory.spec.ts + AbilityFactory + ΓêÜ should be defined (11 ms) + Global Admin + ├ù should grant all permissions for global admin (5 ms) + Organization Level + ΓêÜ should grant permissions for matching organization (3 ms) + ΓêÜ should deny permissions for non-matching organization (2 ms) + Project Level + ΓêÜ should grant permissions for matching project (2 ms) + Contract Level + ΓêÜ should grant permissions for matching contract (2 ms) + Multiple Assignments + ΓêÜ should combine permissions from multiple assignments (2 ms) + + ΓùÅ AbilityFactory ΓÇ║ Global Admin ΓÇ║ should grant all permissions for global admin + + expect(received).toBe(expected) // Object.is equality + + Expected: true + Received: false + + 34 | const ability = factory.createForUser(user, {}); + 35 | + > 36 | expect(ability.can('manage', 'all')).toBe(true); + | ^ + 37 | }); + 38 | }); + 39 | + + at Object. (common/auth/casl/ability.factory.spec.ts:36:44) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 6 passed, 7 total +Snapshots: 0 total +Time: 1.325 s, estimated 2 s +Ran all test suites matching src/common/auth/casl/ability.factory.spec.ts. +ΓÇëELIFECYCLEΓÇë Test failed. See above for more details. diff --git a/backend/test_debug_2.txt b/backend/test_debug_2.txt new file mode 100644 index 0000000..208295b --- /dev/null +++ b/backend/test_debug_2.txt @@ -0,0 +1,52 @@ + +> backend@1.5.1 test D:\nap-dms.lcbp3\backend +> jest "src/common/auth/casl/ability.factory.spec.ts" + + console.log + ABILITY RULES: [ + { + "action": "manage_all", + "subject": "system" + } + ] + + at Object. (common/auth/casl/ability.factory.spec.ts:35:15) + +FAIL src/common/auth/casl/ability.factory.spec.ts + AbilityFactory + ΓêÜ should be defined (11 ms) + Global Admin + ├ù should grant all permissions for global admin (19 ms) + Organization Level + ΓêÜ should grant permissions for matching organization (3 ms) + ΓêÜ should deny permissions for non-matching organization (2 ms) + Project Level + ΓêÜ should grant permissions for matching project (3 ms) + Contract Level + ΓêÜ should grant permissions for matching contract (1 ms) + Multiple Assignments + ΓêÜ should combine permissions from multiple assignments (1 ms) + + ΓùÅ AbilityFactory ΓÇ║ Global Admin ΓÇ║ should grant all permissions for global admin + + expect(received).toBe(expected) // Object.is equality + + Expected: true + Received: false + + 35 | console.log('ABILITY RULES:', JSON.stringify(ability.rules, null, 2)); + 36 | + > 37 | expect(ability.can('manage', 'all')).toBe(true); + | ^ + 38 | }); + 39 | }); + 40 | + + at Object. (common/auth/casl/ability.factory.spec.ts:37:44) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 6 passed, 7 total +Snapshots: 0 total +Time: 1.298 s, estimated 2 s +Ran all test suites matching src/common/auth/casl/ability.factory.spec.ts. +ΓÇëELIFECYCLEΓÇë Test failed. See above for more details. diff --git a/backend/test_error.txt b/backend/test_error.txt new file mode 100644 index 0000000..a123f19 --- /dev/null +++ b/backend/test_error.txt @@ -0,0 +1,32 @@ + +> backend@1.5.1 test D:\nap-dms.lcbp3\backend +> jest "src/common/auth/casl/ability.factory.spec.ts" + +FAIL src/common/auth/casl/ability.factory.spec.ts + ΓùÅ Test suite failed to run + + Cannot find module '../../../common/entities/base.entity.js' from 'modules/project/entities/organization.entity.ts' + + Require stack: + modules/project/entities/organization.entity.ts + modules/user/entities/user.entity.ts + common/auth/casl/ability.factory.spec.ts + + 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + > 2 | import { BaseEntity } from '../../../common/entities/base.entity.js'; + | ^ + 3 | + 4 | @Entity('organizations') + 5 | export class Organization extends BaseEntity { + + at Resolver._throwModNotFoundError (../node_modules/jest-resolve/build/index.js:863:11) + at Object. (modules/project/entities/organization.entity.ts:2:1) + at Object. (modules/user/entities/user.entity.ts:16:1) + at Object. (common/auth/casl/ability.factory.spec.ts:3:1) + +Test Suites: 1 failed, 1 total +Tests: 0 total +Snapshots: 0 total +Time: 1.11 s +Ran all test suites matching src/common/auth/casl/ability.factory.spec.ts. +ΓÇëELIFECYCLEΓÇë Test failed. See above for more details. diff --git a/backend/test_error_2.txt b/backend/test_error_2.txt new file mode 100644 index 0000000..be6555d --- /dev/null +++ b/backend/test_error_2.txt @@ -0,0 +1,34 @@ + +> backend@1.5.1 test D:\nap-dms.lcbp3\backend +> jest "src/common/auth/casl/ability.factory.spec.ts" + +FAIL src/common/auth/casl/ability.factory.spec.ts + ΓùÅ Test suite failed to run + + Cannot find module '../../../common/entities/base.entity.js' from 'modules/project/entities/project.entity.ts' + + Require stack: + modules/project/entities/project.entity.ts + modules/user/entities/user-assignment.entity.ts + modules/user/entities/user.entity.ts + common/auth/casl/ability.factory.spec.ts + + 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + > 2 | import { BaseEntity } from '../../../common/entities/base.entity.js'; + | ^ + 3 | + 4 | @Entity('projects') + 5 | export class Project extends BaseEntity { + + at Resolver._throwModNotFoundError (../node_modules/jest-resolve/build/index.js:863:11) + at Object. (modules/project/entities/project.entity.ts:2:1) + at Object. (modules/user/entities/user-assignment.entity.ts:15:1) + at Object. (modules/user/entities/user.entity.ts:17:1) + at Object. (common/auth/casl/ability.factory.spec.ts:3:1) + +Test Suites: 1 failed, 1 total +Tests: 0 total +Snapshots: 0 total +Time: 1.3 s +Ran all test suites matching src/common/auth/casl/ability.factory.spec.ts. +ΓÇëELIFECYCLEΓÇë Test failed. See above for more details. diff --git a/backend/test_failures.txt b/backend/test_failures.txt new file mode 100644 index 0000000..0301ec3 --- /dev/null +++ b/backend/test_failures.txt @@ -0,0 +1,42 @@ + +> backend@1.5.1 test D:\nap-dms.lcbp3\backend +> jest "src/common/auth/casl/ability.factory.spec.ts" + +FAIL src/common/auth/casl/ability.factory.spec.ts + AbilityFactory + ΓêÜ should be defined (12 ms) + Global Admin + ├ù should grant all permissions for global admin (4 ms) + Organization Level + ΓêÜ should grant permissions for matching organization (3 ms) + ΓêÜ should deny permissions for non-matching organization (2 ms) + Project Level + ΓêÜ should grant permissions for matching project (3 ms) + Contract Level + ΓêÜ should grant permissions for matching contract (2 ms) + Multiple Assignments + ΓêÜ should combine permissions from multiple assignments (2 ms) + + ΓùÅ AbilityFactory ΓÇ║ Global Admin ΓÇ║ should grant all permissions for global admin + + expect(received).toBe(expected) // Object.is equality + + Expected: true + Received: false + + 34 | const ability = factory.createForUser(user, {}); + 35 | + > 36 | expect(ability.can('manage', 'all')).toBe(true); + | ^ + 37 | }); + 38 | }); + 39 | + + at Object. (common/auth/casl/ability.factory.spec.ts:36:44) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 6 passed, 7 total +Snapshots: 0 total +Time: 1.168 s, estimated 2 s +Ran all test suites matching src/common/auth/casl/ability.factory.spec.ts. +ΓÇëELIFECYCLEΓÇë Test failed. See above for more details. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c163ae5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# API Configuration +NEXT_PUBLIC_API_URL=http://localhost:3001/api + +# NextAuth Configuration +# Generate a secret with `openssl rand -base64 32` +AUTH_SECRET=changeme diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 0704a07..826e7e4 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -33,6 +33,7 @@ export default function LoginPage() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); // ตั้งค่า React Hook Form const { @@ -50,6 +51,7 @@ export default function LoginPage() { // ฟังก์ชันเมื่อกด Submit async function onSubmit(data: LoginValues) { setIsLoading(true); + setErrorMessage(null); try { // เรียกใช้ NextAuth signIn (Credential Provider) @@ -63,8 +65,7 @@ export default function LoginPage() { if (result?.error) { // กรณี Login ไม่สำเร็จ console.error("Login failed:", result.error); - // TODO: เปลี่ยนเป็น Toast Notification ในอนาคต - alert("เข้าสู่ระบบไม่สำเร็จ: ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง"); + setErrorMessage("เข้าสู่ระบบไม่สำเร็จ: ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง"); return; } @@ -73,7 +74,7 @@ export default function LoginPage() { router.refresh(); // Refresh เพื่อให้ Server Component รับรู้ Session ใหม่ } catch (error) { console.error("Login error:", error); - alert("เกิดข้อผิดพลาดที่ไม่คาดคิด"); + setErrorMessage("เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่อีกครั้ง"); } finally { setIsLoading(false); } @@ -89,9 +90,14 @@ export default function LoginPage() { กรอกชื่อผู้ใช้งานและรหัสผ่านเพื่อเข้าสู่ระบบ - +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} {/* Username Field */}
@@ -162,4 +168,4 @@ export default function LoginPage() { ); -} \ No newline at end of file +} diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index 52066e6..dea3e9e 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -4,12 +4,57 @@ import Credentials from "next-auth/providers/credentials"; import { z } from "zod"; import type { User } from "next-auth"; -// Schema สำหรับ Validate ข้อมูลขาเข้าอีกครั้งเพื่อความปลอดภัย +// Schema for input validation const loginSchema = z.object({ username: z.string().min(1), password: z.string().min(1), }); +const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; + +// Helper to parse JWT expiry +function getJwtExpiry(token: string): number { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.exp * 1000; // Convert to ms + } catch (e) { + return Date.now(); // If invalid, treat as expired + } +} + +async function refreshAccessToken(token: any) { + try { + const response = await fetch(`${baseUrl}/auth/refresh`, { + method: "POST", + headers: { + Authorization: `Bearer ${token.refreshToken}`, + }, + }); + + const refreshedTokens = await response.json(); + + if (!response.ok) { + throw refreshedTokens; + } + + const data = refreshedTokens.data || refreshedTokens; + + return { + ...token, + accessToken: data.access_token, + accessTokenExpires: getJwtExpiry(data.access_token), + refreshToken: data.refresh_token ?? token.refreshToken, + }; + } catch (error) { + console.log("RefreshAccessTokenError", error); + + return { + ...token, + error: "RefreshAccessTokenError", + }; + } +} + export const { handlers: { GET, POST }, auth, @@ -25,55 +70,39 @@ export const { }, authorize: async (credentials) => { try { - // 1. Validate ข้อมูลที่ส่งมาจากฟอร์ม const { username, password } = await loginSchema.parseAsync(credentials); - - // อ่านค่าจาก ENV หรือใช้ Default (ต้องมั่นใจว่าชี้ไปที่ Port 3001 และมี /api) - const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; - + console.log(`Attempting login to: ${baseUrl}/auth/login`); - // 2. เรียก API ไปยัง NestJS Backend const res = await fetch(`${baseUrl}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); - // ถ้า Backend ตอบกลับมาว่าไม่สำเร็จ (เช่น 401, 404, 500) if (!res.ok) { const errorMsg = await res.text(); console.error("Login failed:", errorMsg); return null; } - // 3. รับข้อมูล JSON จาก Backend - // โครงสร้างที่ Backend ส่งมา: { statusCode: 200, message: "...", data: { access_token: "...", user: {...} } } const responseJson = await res.json(); - - // เจาะเข้าไปเอาข้อมูลจริงใน .data - const backendData = responseJson.data; + const backendData = responseJson.data || responseJson; - // ตรวจสอบว่ามี Token หรือไม่ if (!backendData || !backendData.access_token) { - console.error("No access token received in response data"); + console.error("No access token received"); return null; } - // 4. Return ข้อมูล User เพื่อส่งต่อไปยัง JWT Callback - // ต้อง Map ชื่อ Field ให้ตรงกับที่ NextAuth คาดหวัง และเก็บ Access Token return { - // Map user_id จาก DB ให้เป็น id (string) ตามที่ NextAuth ต้องการ id: backendData.user.user_id.toString(), - // รวมชื่อจริงนามสกุล name: `${backendData.user.firstName} ${backendData.user.lastName}`, email: backendData.user.email, username: backendData.user.username, - // Role (ถ้า Backend ยังไม่ส่ง role มา อาจต้องใส่ Default หรือปรับ Backend เพิ่มเติม) - role: backendData.user.role || "User", + role: backendData.user.role || "User", organizationId: backendData.user.primaryOrganizationId, - // เก็บ Token ไว้ใช้งาน accessToken: backendData.access_token, + refreshToken: backendData.refresh_token, } as User; } catch (error) { @@ -84,37 +113,48 @@ export const { }), ], pages: { - signIn: "/login", // กำหนดหน้า Login ของเราเอง - error: "/login", // กรณีเกิด Error ให้กลับมาหน้า Login + signIn: "/login", + error: "/login", }, callbacks: { - // 1. JWT Callback: ทำงานเมื่อสร้าง Token หรืออ่าน Token async jwt({ token, user }) { - // ถ้ามี user เข้ามา (คือตอน Login ครั้งแรก) ให้บันทึกข้อมูลลง Token if (user) { - token.id = user.id; - token.role = user.role; - token.organizationId = user.organizationId; - token.accessToken = user.accessToken; + return { + ...token, + id: user.id, + role: user.role, + organizationId: user.organizationId, + accessToken: user.accessToken, + refreshToken: user.refreshToken, + accessTokenExpires: getJwtExpiry(user.accessToken!), + }; } - return token; + + // Return previous token if valid (minus 10s buffer) + if (Date.now() < (token.accessTokenExpires as number) - 10000) { + return token; + } + + // Token expired, refresh it + return refreshAccessToken(token); }, - // 2. Session Callback: ทำงานเมื่อฝั่ง Client เรียก useSession() async session({ session, token }) { - // ส่งข้อมูลจาก Token ไปให้ Client ใช้งาน if (token && session.user) { session.user.id = token.id as string; session.user.role = token.role as string; session.user.organizationId = token.organizationId as number; + session.accessToken = token.accessToken as string; + session.refreshToken = token.refreshToken as string; + session.error = token.error as string; } return session; }, }, session: { strategy: "jwt", - maxAge: 8 * 60 * 60, // 8 ชั่วโมง + maxAge: 24 * 60 * 60, // 24 hours }, secret: process.env.AUTH_SECRET, debug: process.env.NODE_ENV === "development", -}); \ No newline at end of file +}); diff --git a/frontend/types/next-auth.d.ts b/frontend/types/next-auth.d.ts index 014729b..4dfa918 100644 --- a/frontend/types/next-auth.d.ts +++ b/frontend/types/next-auth.d.ts @@ -9,6 +9,8 @@ declare module "next-auth" { organizationId?: number; } & DefaultSession["user"] accessToken?: string; + refreshToken?: string; + error?: string; } interface User { @@ -16,6 +18,7 @@ declare module "next-auth" { role: string; organizationId?: number; accessToken?: string; + refreshToken?: string; } } @@ -25,5 +28,8 @@ declare module "next-auth/jwt" { role: string; organizationId?: number; accessToken?: string; + refreshToken?: string; + accessTokenExpires?: number; + error?: string; } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4329cb9..120fec9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: '@types/nodemailer': specifier: ^7.0.4 version: 7.0.4 + '@willsoto/nestjs-prometheus': + specifier: ^6.0.2 + version: 6.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3) ajv: specifier: ^8.17.1 version: 8.17.1 @@ -3047,6 +3050,12 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@willsoto/nestjs-prometheus@6.0.2': + resolution: {integrity: sha512-ePyLZYdIrOOdlOWovzzMisIgviXqhPVzFpSMKNNhn6xajhRHeBsjAzSdpxZTc6pnjR9hw1lNAHyKnKl7lAPaVg==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + prom-client: ^15.0.0 + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -9851,6 +9860,11 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@willsoto/nestjs-prometheus@6.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + prom-client: 15.1.3 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} diff --git a/specs/09-history/2025-12-06_p0-build-fixes.md b/specs/09-history/2025-12-06_p0-build-fixes.md new file mode 100644 index 0000000..69c39df --- /dev/null +++ b/specs/09-history/2025-12-06_p0-build-fixes.md @@ -0,0 +1,53 @@ +# 2025-12-06 P0 Build Fix Summary + +**Date:** 2025-12-06 +**Status:** ✅ P0 Complete +**Objective:** Resolve Critical Build Failures + +## Executive Summary +This session addressed critical TypeScript build errors in the backend that were preventing successful compilation (`pnpm build`). These errors originated from stricter TypeScript settings interacting with legacy P0 code and recent refactors. + +**Result:** `pnpm build` now passes successfully. + +## Fixed Issues + +### 1. Workflow DSL Parser (`parser.service.ts`) +- **Issue:** Property mismatches between DSL JSON and `WorkflowDefinition` entity (camelCase vs snake_case). +- **Fix:** Mapped properties correctly: + - `dsl.name` -> `entity.workflow_code` + - `dsl.isActive` -> `entity.is_active` + - `dsl.dslContent` -> `entity.dsl` (Direct JSON storage) +- **Issue:** Strict strict-mode errors in `catch(error)` blocks (unknown type). +- **Fix:** Cast error to `any` and added fallback logic. + +### 2. Permissions Guard (`permissions.guard.ts`) +- **Issue:** Strict type checking failures in `Ability.can(action, subject)`. +- **Fix:** Explicitly cast action and subject to `any` to satisfy the CASL Ability type signature. + +### 3. Ability Factory (`ability.factory.ts`) +- **Issue:** `item.constructor` access on potentially unknown type. +- **Fix:** Explicitly typed `item` as `any` in `detectSubjectType`. + +### 4. RBAC Guard (`rbac.guard.ts`) +- **Issue:** Incorrect import (`PERMISSION_KEY` vs `PERMISSIONS_KEY`) and mismatch with updated Decorator (Array vs String). +- **Fix:** Updated to use `PERMISSIONS_KEY` and handle array of permissions. Fixed import paths (removed `.js`). + +### 5. Document Numbering Service +- **Issue:** Unknown error type in catch block. +- **Fix:** Cast error to `any` for logging. + +### 6. P0-1: RBAC Tests (`ability.factory.spec.ts`) +- **Issue:** Tests failed to load due to `Cannot find module ... .js`. +- **Fix:** Removed `.js` extensions from imports in `organization.entity.ts`, `project.entity.ts`, `contract.entity.ts`, `routing-template.entity.ts`. +- **Issue:** Global Admin test failed (`can('manage', 'all')` -> false). +- **Fix:** + 1. Updated `detectSubjectType` to return string subjects directly (fixing CASL string matching). + 2. Moved `system.manage_all` check to top of `parsePermission` to prevent incorrect splitting. +- **Verification:** `pnpm test src/common/auth/casl/ability.factory.spec.ts` -> **PASS** (7/7 tests). + +## Verification +- Ran `pnpm build`. +- **Outcome:** Success (Exit code 0). + +## Next Steps +- Continue to P3 (Admin Panel) or P2-5 (Tests) knowing the foundation is stable. diff --git a/specs/09-history/2025-12-06_p1-frontend-plan.md b/specs/09-history/2025-12-06_p1-frontend-plan.md new file mode 100644 index 0000000..7069a4f --- /dev/null +++ b/specs/09-history/2025-12-06_p1-frontend-plan.md @@ -0,0 +1,33 @@ +# P1-Frontend: Setup & Authentication Plan + +## Goal +Finalize frontend setup and implement robust Authentication connecting to the NestJS Backend (P2-2 Refresh Token support). + +## Status Analysis +- **P1-1 (Setup):** ✅ Project structure, Tailwind, Shadcn/UI are already present. +- **P1-2 (Auth):** 🚧 `lib/auth.ts` exists but lacks `refreshToken` rotation logic. Types need verification. + +## Proposed Changes + +### 1. Type Definitions (`types/next-auth.d.ts`) +- [ ] Add `refreshToken`, `accessTokenExpires` (optional), and `error` field to `Session` and `JWT` types. + +### 2. Auth Configuration (`lib/auth.ts`) +- [ ] Update `authorize` to store `refresh_token` from Backend response. +- [ ] Implement `refreshToken` rotation logic in `jwt` callback: + - Check if token is expired. + - If expired, call backend POST `/auth/refresh`. + - Update `accessToken` and `refreshToken`. + - Handle refresh errors (Force sign out). + +### 3. Login Page (`app/(auth)/login/page.tsx`) +- [ ] Polish Error Handling (Use Toasts instead of alerts). +- [ ] Ensure redirect works correctly. + +### 4. Middleware (`middleware.ts`) +- [ ] Verify middleware protects dashboard routes. + +## Verification Plan +1. **Manual Test:** Login with valid credentials. +2. **Inspection:** Check LocalStorage/Cookies (NextAuth session cookie). +3. **Token Rotation:** Wait for short access token expiry (if configurable) or manually invalidate, and verify seamless refresh. diff --git a/specs/09-history/2025-12-06_p2-completion.md b/specs/09-history/2025-12-06_p2-completion.md new file mode 100644 index 0000000..2a01b61 --- /dev/null +++ b/specs/09-history/2025-12-06_p2-completion.md @@ -0,0 +1,61 @@ +# 2025-12-06 P2 Implementation Summary + +**Date:** 2025-12-06 +**Status:** ✅ P2 Complete +**Objective:** Enhance Security and Documentation + +## Executive Summary +This session focused on completing Priority 2 (P2) tasks for the Backend v1.4.3. All P2 objectives were met, including API documentation, secure session management, observability, and API hardening. + +**Note:** While P2 features are complete and verified by code review, the `pnpm build` process is currently failing due to pre-existing issues in P0 modules (Casl Ability & Workflow DSL) that were outside the scope of this session. These build errors must be addressed in the next session (P0 Urgent). + +## Completed Tasks + +### ✅ P2-1: Swagger API Documentation +- **Objective:** Improve API discoverability. +- **Changes:** + - Configured `SwaggerModule` at `/docs`. + - Added full documentation for `AuthController`, `CorrespondenceController`, `RfaController`, and `UserController`. + - Decorated DTOs with `@ApiProperty` for schema clarity. + +### ✅ P2-2: Refresh Token Mechanism +- **Objective:** Secure session management implementation (ADR-016). +- **Changes:** + - Created `RefreshToken` entity (hashed tokens). + - Implemented `AuthService` logic for: + - **Token Generation:** Access (15m) + Refresh (7d). + - **Storage:** Hashed in DB. + - **Rotation:** Refresh token reuse triggers rotation. + - **Revocation:** Security mechanism to invalidate stolen token families. + - Exposed `POST /auth/refresh` endpoint. + +### ✅ P2-3: Prometheus Metrics +- **Objective:** System observability. +- **Changes:** + - Integrated `@willsoto/nestjs-prometheus` and opened `/metrics`. + - Implemented standard metrics (CPU, Memory). + - Added custom HTTP metrics (`http_requests_total`, `http_request_duration_seconds`) via `PerformanceInterceptor`. + - Refactored `MonitoringModule` for modularity. + +### ✅ P2-4: Rate Limiting & Security Headers +- **Objective:** API Hardening. +- **Changes:** + - **Throttler:** Verified global rate limit (100/min) and strict login limit (5/min). + - **Helmet:** Configured Security Headers with custom CSP to support Swagger UI. + - **CORS:** Dynamic configuration connected to `ConfigService`. + +--- + +## Known Issues (P0 - Urgent) + +The following build errors were identified but deferred as they belong to P0 scope: + +1. **AbilityFactory (CASL):** TypeScript mismatch in Permission loops (`CASL integration`). +2. **WorkflowEngine (DSL):** TypeScript mismatch in Zod Schema validation (`WorkflowParser`). + +**Action Plan:** These must be fixed immediately in the next session to restore build stability. + +## Artifacts Created +- `specs/09-history/2025-12-06_p2-completion.md` (This file) +- `src/common/auth/entities/refresh-token.entity.ts` +- `src/modules/monitoring/` (Refactored) diff --git a/specs/09-history/2025-12-06_p3-admin-panel-plan.md b/specs/09-history/2025-12-06_p3-admin-panel-plan.md new file mode 100644 index 0000000..345ec28 --- /dev/null +++ b/specs/09-history/2025-12-06_p3-admin-panel-plan.md @@ -0,0 +1,44 @@ +# P3-1: Frontend Admin Panel Implementation Plan + +## Goal +Implement a functional Admin Panel for User and Master Data Management, connected to existing Backend APIs. + +## Scope +1. **Admin Layout**: Sidebar navigation and layout structure at `/app/(admin)`. +2. **User Management**: + * List Users (`GET /users`) with pagination/filtering. + * Create/Edit User (`POST /users`, `PATCH /users/:id`). + * Assign Roles (`POST /users/assign-role`). +3. **Organization Management**: + * List Organizations (`GET /organizations`). + * Create/Edit Organization (`POST`, `PATCH`). + +## Implementation Steps + +### 1. Admin Layout & Navigation +- **File**: `app/(admin)/layout.tsx` +- **File**: `components/admin/admin-sidebar.tsx` +- **Logic**: Ensure only users with `ADMIN` role can access. + +### 2. User Management +- **Page**: `app/(admin)/admin/users/page.tsx` +- **Components**: + * `components/admin/users/user-table.tsx` (using `tanstack/react-table`) + * `components/admin/users/user-dialog.tsx` (Create/Edit Form with Zod validation) + +### 3. Organization Management +- **Page**: `app/(admin)/admin/organizations/page.tsx` +- **Components**: + * `components/admin/orgs/org-table.tsx` + * `components/admin/orgs/org-dialog.tsx` + +## Dependencies +- Backend Endpoints: verified (`UserController`, `OrganizationController`). +- UI Components: `Table`, `Dialog`, `Form` (Shadcn/UI - already installed). + +## Verification +- [ ] Login as Admin. +- [ ] Navigate to `/admin/users`. +- [ ] Create a new user and verify in DB/List. +- [ ] Edit user details. +- [ ] Create a new Organization.