feat(identity): redesign identity module and introduce RBAC foundation

- Redesign the Identity module with a richer domain model.
- Extend the User entity to support username, Authentik integration, activity tracking, and storage information.
- Add Role and Permission domain models with many-to-many relationships.
- Implement RBAC foundation using UserRole, RolePermission, and UserPermission mappings.
- Add user storage quota and usage fields with default values.
- Introduce Authentik identifiers and synchronization metadata.
- Refactor user domain logic for role and permission management.
- Update Prisma schema to support the new identity architecture.
- Improve JWT authentication and permission guard integration.
- Update repositories, handlers, controllers, mappers, DTOs, and Swagger configuration.
- Refresh environment configuration and project dependencies.
This commit is contained in:
Rayyan
2026-08-02 00:25:36 +07:00
parent fdbfb34842
commit 7ce0de4e91
132 changed files with 7754 additions and 2037 deletions
+53 -7
View File
@@ -1,10 +1,56 @@
APP_ENV=development #development / test / prod
PORT=3069
DATABASE_URL=postgresql://USERNAME:PASSWORD@HOST:5432/DATABASE?schema=public
# Application
BASE_URL=http://localhost:3069 PORT=3000 NODE_ENV=development
JWT_SECRET=GANTI_DENGAN_SECRET
JWT_REFRESH_SECRET=GANTI_DENGAN_REFRESH_SECRET
# Database (PostgreSQL)
DATABASE_URL=postgresql://user:password@localhost:5432/Database?shema=public
SWAGGER_ENABLED=true
# Authentik (OIDC discovery base)
# Used by OIDC discovery and token exchange (AuthService)
AUTHENTIK_URL=https://auth.raylab.site
LOG_LEVEL=debug
# Authentik Admin API (provisioning identities & groups)
# Example: https://auth.raylab.site/api
AUTHENTIK_API_BASE=https://auth.raylab.site/api
# Bearer token for Authentik Admin API (admin service account)
AUTHENTIK_API_TOKEN=changeme_admin_api_token
# Default group name format: RL-{object}
# RayLab will use this group to gate access (default RL-RayLab-Users)
AUTHENTIK_DEFAULT_GROUP=RL-RayLab-Users
# OIDC / JWKS / Issuer settings for verifying Authentik tokens (if needed)
AUTHENTIK_JWKS_URI=https://auth.raylab.site/.well-known/jwks.json AUTHENTIK_ISSUER=https://auth.raylab.site/application/o/ray-lab-core/ AUTHENTIK_AUDIENCE=ray-lab-core
# OIDC client credentials (used when RayLab needs to call token endpoint)
AUTHENTIK_CLIENT_ID=raylab-client AUTHENTIK_CLIENT_SECRET=raylab-client-secret
# RayLab internal JWT (used to sign internal tokens)
RAYLAB_JWT_SECRET=replace_with_a_long_random_secret
# Token lifetime in seconds
RAYLAB_JWT_EXPIRES_IN=3600
# Sync / reconciliation
# Interval in seconds for reconciler to retry PENDING/FAILED syncs
AUTH_RECONCILE_INTERVAL_SECONDS=3600
# Auth / sync behavior
# Disable auto-creation of users on login (must remain false per design)
AUTH_AUTO_CREATE_USER=false AUTH_SYNC_EMAIL=true AUTH_SYNC_USERNAME=false
# Test accounts for integration tests (must exist in Authentik and RayLab DB before tests,
# or be provisioned via RayLab Admin endpoints during test run)
ADMIN_USERNAME=admin@example.com ADMIN_PASSWORD=changeme_admin_password
OWNER_USERNAME=owner@example.com OWNER_PASSWORD=changeme_owner_password
EMPLOYEE_USERNAME=employee@example.com EMPLOYEE_PASSWORD=changeme_employee_password
# Optional: integration test user (overrides defaults used by tests)
TEST_USER_EMAIL=test-integration@example.com TEST_USER_USERNAME=test-integration TEST_USER_PASSWORD=StrongP@ssw0rd!
# Optional: adjust logging or other runtime flags
LOG_LEVEL=info
SWAGGER_ENABLED=true
+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug NestJS",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start:debug"
],
"console": "integratedTerminal",
"restart": true,
"skipFiles": [
"<node_internals>/**"
]
}
]
}
+25
View File
@@ -0,0 +1,25 @@
RayLab Core
├── Identity
│ ├── User
│ ├── Group
│ ├── Role
│ └── Permission
├── Authentication
├── Authorization
├── Audit
├── Scheduler
├── Media
├── Storage
├── Configuration
├── Workflow / Events
└── Registry
BIN
View File
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
Note :
- pastikan berada di branch yang benar.
-
Requirement :
- Node.js 22 LTS
Step :
1. jalankan "NPM Install".
2. generate prisma client "npx prisma generate".
3. jalankan "cp env.example env" atau buat file .env baru.
4. sesuaikan isi dari .env
Run Server :
- npm run start:dev
Command Dev :
- npm test
- npm run lint
- npm run format
- npx prisma studio {untuk cek db}
Test API :
- menggunakan postman :
arahkan ke file /postman/RayLab.postman_collection.json
- menggunakan swagger :
- masuk ke http://localhost:port/apilist
- klik authorize dan masukkan jwt token
Controller Requirement :
- diatas deklarasi class controller pastikan ada ini :
- @ApiTags('object')
- @Controller('object')
- @UseGuards(JwtAuthGuard, PermissionGuard)
- diatas deklarasi method controller pastikan ada ini :
- tipe api : @Post(), @Get(':id'), @Patch(':id'), @Delete(':id')
- @Permissions(PermissionType.SOMETHING)
- @ApiOperation({ summary: 'penjelasan singkat api' })
Table :
- untuk table akan dibuat otomatis oleh schema.prisma, disana hanya perlu ditambahkan model atau diubah, jalankan commandnya, setelah itu otomatis tablenya dibuat
- command : npx prisma migrate dev --name {Nama Perubahan}
Use case:
- penentuan use case ditentukan siapa pemiliknya dahulu, dan harus konsisten.
BIN
View File
Binary file not shown.
+3888 -1567
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -11,6 +11,7 @@
"scripts": {
"start": "node dist/main.js",
"start:dev": "ts-node-dev --respawn --pretty --transpile-only src/main.ts",
"start:debug": "nest start --debug --watch",
"build": "tsc -p tsconfig.json",
"lint": "eslint \"src/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
@@ -37,6 +38,8 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^16.6.1",
"jose": "^6.2.6",
"openid-client": "^6.5.0",
"jsonwebtoken": "^9.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -47,6 +50,9 @@
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@nestjs/testing": "^10.4.22",
"@playwright/test": "^1.62.1",
"@types/bcrypt": "^5.0.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.10",
@@ -56,7 +62,6 @@
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"eslint": "^9.21.0",
"@eslint/js": "^9.21.0",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.5.3",
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// only run Playwright tests in the integration folder to avoid running Jest unit tests
testDir: './tests/integration',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["list"], ["./tests/reporter/custom-reporter.js"]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
},
});
+58
View File
@@ -0,0 +1,58 @@
{
"info": {
"_postman_id": "e1e605cc-a2c6-4410-a7aa-4692b594d42d",
"name": "RayLab",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "45313893"
},
"item": [
{
"name": "User",
"item": [
{
"name": "Create User",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
},
{
"key": "Authorization",
"value": "Bearer ",
"description": "Bearer <JWT_Token>",
"type": "text",
"disabled": true
}
],
"body": {
"mode": "raw",
"raw": "{\r\n \"username\": \"rayyan\",\r\n \"email\": \"rayyan@example.com\",\r\n \"password\": \"Password123!\",\r\n \"fullName\": \"Rayyan\"\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:3000/api/v1/users",
"protocol": "http",
"host": [
"localhost"
],
"port": "3000",
"path": [
"api",
"v1",
"users"
]
}
},
"response": []
}
]
}
]
}
@@ -0,0 +1,93 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","roleId")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId","permissionId")
);
-- CreateTable
CREATE TABLE "UserPermission" (
"userId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserPermission_pkey" PRIMARY KEY ("userId","permissionId")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Role_code_key" ON "Role"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_code_key" ON "Permission"("code");
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPermission" ADD CONSTRAINT "UserPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPermission" ADD CONSTRAINT "UserPermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
/*
Warnings:
- Added the required column `assignedBy` to the `RolePermission` table without a default value. This is not possible if the table is not empty.
- Added the required column `assignedBy` to the `UserPermission` table without a default value. This is not possible if the table is not empty.
- Added the required column `assignedBy` to the `UserRole` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "RolePermission" ADD COLUMN "assignedBy" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "UserPermission" ADD COLUMN "assignedBy" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "UserRole" ADD COLUMN "assignedBy" TEXT NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Role" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[authentikId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "authentikId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_authentikId_key" ON "User"("authentikId");
@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `password` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "password",
ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "lastSeenAt" TIMESTAMP(3);
@@ -0,0 +1,15 @@
/*
Warnings:
- A unique constraint covering the columns `[authentikUserId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[authentikSubject]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "lastSyncedAt" SET DATA TYPE TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "User_authentikUserId_key" ON "User"("authentikUserId");
-- CreateIndex
CREATE UNIQUE INDEX "User_authentikSubject_key" ON "User"("authentikSubject");
@@ -0,0 +1,17 @@
-- Migration: add_authentik_fields
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "authentikUserId" TEXT;
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "authentikSubject" TEXT;
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP;
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "syncStatus" TEXT;
-- Optional: add unique constraints if desired
-- DO NOT add unique constraints without verifying existing data
-- ALTER TABLE "User" ADD CONSTRAINT "User_authentikUserId_key" UNIQUE ("authentikUserId");
@@ -0,0 +1,7 @@
-- Migration: add_storage
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "storageQuota" bigint NOT NULL DEFAULT 10737418240;
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "storageUsed" bigint NOT NULL DEFAULT 0;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+95 -9
View File
@@ -11,13 +11,99 @@ datasource db {
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
status String @default("ACTIVE")
metadata Json?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
deleted_at DateTime?
id String @id @default(uuid())
authentikId String? @unique
authentikUserId String? @unique
authentikSubject String? @unique
username String @unique
email String @unique
isActive Boolean @default(true)
// storage (in bytes)
storageQuota BigInt @default(10737418240)
storageUsed BigInt @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
lastSeenAt DateTime?
lastSyncedAt DateTime?
syncStatus String?
roles UserRole[]
permissions UserPermission[]
}
model Role {
id String @id @default(uuid())
code String @unique
name String
description String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users UserRole[]
permissions RolePermission[]
}
model Permission {
id String @id @default(uuid())
code String @unique
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles RolePermission[]
users UserPermission[]
}
model UserRole {
userId String
roleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
assignedBy String
@@id([userId, roleId])
}
model RolePermission {
roleId String
permissionId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
assignedBy String
@@id([roleId, permissionId])
}
model UserPermission {
userId String
permissionId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
assignedBy String
@@id([userId, permissionId])
}
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { IdentityModule } from './modules/identity/identity.module';
import { AuthModule } from './modules/auth/auth.module';
@Module({
imports: [
@@ -10,6 +11,7 @@ import { IdentityModule } from './modules/identity/identity.module';
}),
IdentityModule,
AuthModule,
],
})
export class AppModule {}
@@ -0,0 +1,16 @@
export const PermissionType = {
USER_CREATE: 'USER_CREATE',
USER_READ: 'USER_READ',
USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE',
ROLE_CREATE: 'ROLE_CREATE',
ROLE_READ: 'ROLE_READ',
ROLE_UPDATE: 'ROLE_UPDATE',
ROLE_DELETE: 'ROLE_DELETE',
PERMISSION_CREATE: 'PERMISSION_CREATE',
PERMISSION_READ: 'PERMISSION_READ',
PERMISSION_UPDATE: 'PERMISSION_UPDATE',
PERMISSION_DELETE: 'PERMISSION_DELETE',
} as const;
+52 -8
View File
@@ -4,17 +4,23 @@ import {
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { IdentityData } from '../interfaces/identity-data';
import * as jwt from 'jsonwebtoken';
/**
* JwtAuthGuard verifies JWTs issued by the external Identity Provider (Authentik)
* using JWKS (RS256). It also accepts internal RayLab JWTs signed with a local secret.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
) {}
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
constructor() {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const request = context.switchToHttp().getRequest<Request & { identity?: IdentityData }>();
const authHeader = request.headers.authorization;
@@ -28,13 +34,51 @@ export class JwtAuthGuard implements CanActivate {
throw new UnauthorizedException('Invalid authorization header.');
}
const jwksUri = process.env.AUTHENTIK_JWKS_URI;
// First try verifying with external JWKS (Authentik)
if (jwksUri) {
try {
if (!this.jwks) this.jwks = createRemoteJWKSet(new URL(jwksUri));
const { payload } = await jwtVerify(token, this.jwks, {
issuer: process.env.AUTHENTIK_ISSUER,
audience: process.env.AUTHENTIK_AUDIENCE,
});
const identity = new IdentityData(
payload.sub as string,
(payload as any).preferred_username as string | undefined,
(payload as any).email as string | undefined,
payload as Record<string, any>,
);
request.identity = identity;
return true;
} catch (err) {
// ignore and try internal verification
}
}
// Fallback: verify with internal symmetric secret
const secret = process.env.RAYLAB_JWT_SECRET;
if (!secret) {
throw new UnauthorizedException('Invalid or expired token.');
}
try {
const payload = await this.jwtService.verifyAsync(token);
const payload = jwt.verify(token, secret) as any;
request['user'] = payload;
const identity = new IdentityData(
payload.sub as string,
payload.preferred_username as string | undefined,
payload.email as string | undefined,
payload as Record<string, any>,
);
request.identity = identity;
return true;
} catch {
} catch (err: any) {
throw new UnauthorizedException('Invalid or expired token.');
}
}
@@ -1,12 +1,12 @@
import { Request } from 'express';
export interface JwtPayload {
sub: string;
email: string;
role: string;
permissions: string[];
}
import { UserData } from '../../../modules/identity/domain/entities/user.entity';
import { IdentityData } from './identity-data';
export interface AuthenticatedRequest extends Request {
user: JwtPayload;
// Identity comes from the external Identity Provider (Authentik)
// JwtAuthGuard must set request.identity = payload
identity?: IdentityData;
// After CurrentUserGuard resolves the user from repository, it must set request.currentUser = User Domain
currentUser?: UserData;
}
@@ -0,0 +1,8 @@
export class IdentityData {
constructor(
public readonly sub: string,
public readonly preferred_username?: string,
public readonly email?: string,
public readonly claims?: Record<string, any>,
) {}
}
+3 -4
View File
@@ -10,7 +10,7 @@ async function bootstrap() {
const config = app.get(ConfigService);
app.setGlobalPrefix('api');
app.setGlobalPrefix('');
app.useGlobalPipes(
new ValidationPipe({
@@ -22,8 +22,7 @@ async function bootstrap() {
app.enableCors();
const swaggerEnabled =
config.get<string>('SWAGGER_ENABLED') === 'true';
const swaggerEnabled = config.get<string>('SWAGGER_ENABLED') === 'true';
if (swaggerEnabled) {
const swaggerConfig = new DocumentBuilder()
@@ -35,7 +34,7 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
SwaggerModule.setup('ApiList', app, document);
}
const port = config.get<number>('PORT') || 3000;
+65
View File
@@ -0,0 +1,65 @@
import { Controller, Get, Post, Query, Res, Req, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { Response, Request } from 'express';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Get('login')
@ApiOperation({ summary: 'Start Authorization Code + PKCE login (redirect to Identity Provider)' })
async login(@Query('returnTo') returnTo: string | undefined, @Res() res: Response) {
const redirect = await this.authService.createAuthorizationRedirect(returnTo);
return res.redirect(302, redirect);
}
@Get('callback')
@ApiOperation({ summary: 'OIDC callback endpoint' })
async callback(@Query('code') code: string, @Query('state') state: string, @Res() res: Response) {
const result = await this.authService.handleCallback(code, state);
// set cookies
const cookieOptions: any = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
};
// access token cookie (internal JWT)
res.cookie('raylab_jwt', result.accessToken, { ...cookieOptions, maxAge: result.expiresIn * 1000 });
// refresh token cookie
res.cookie('raylab_refresh', result.refreshToken, { ...cookieOptions, maxAge: result.refreshTtl * 1000 });
return res.redirect(302, result.returnTo || '/');
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout (invalidate internal session and redirect to identity provider logout)' })
async logout(@Req() req: Request, @Res() res: Response) {
const refreshToken = req.cookies?.raylab_refresh || req.body?.refreshToken;
const redirect = await this.authService.logout(refreshToken);
return res.redirect(302, redirect);
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' })
async refresh(@Req() req: Request) {
const refreshToken = req.cookies?.raylab_refresh || req.body?.refreshToken;
const result = await this.authService.refresh(refreshToken);
return { success: true, data: result };
}
@Get('me')
@ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' })
async me(@Req() req: Request) {
const token = (req.cookies?.raylab_jwt) || (req.headers.authorization && (req.headers.authorization as string).replace(/^Bearer\s+/i, ''));
const user = await this.authService.me(token);
return { success: true, data: user };
}
}
+46
View File
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PrismaService } from '../../shared/prisma.service';
import { IUser } from '../identity/domain/repositories/user.interface';
import { PrismaUserRepository } from '../identity/infrastructure/repositories/prisma-user.repository';
import { SyncIdentityHandler } from '../identity/application/handlers/user/sync-identity.handler';
import { IRole } from '../identity/domain/repositories/role.interface';
import { PrismaRoleRepository } from '../identity/infrastructure/repositories/prisma-role.repository';
import { IAuthConfig } from '../identity/application/config/i-auth-config';
import { EnvAuthConfig } from '../identity/application/config/env-auth-config';
import { RedisPkceStore } from './pkce/redis-pkce.store';
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
secret: config.get('RAYLAB_JWT_SECRET') || 'raylab-secret',
signOptions: { algorithm: 'HS256' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [
AuthService,
PrismaService,
PrismaUserRepository,
PrismaRoleRepository,
SyncIdentityHandler,
{ provide: IUser, useClass: PrismaUserRepository },
{ provide: IRole, useClass: PrismaRoleRepository },
{ provide: IAuthConfig, useClass: EnvAuthConfig },
// In-memory PKCE and Refresh stores (Redis removed)
RedisPkceStore,
InMemoryRefreshStore,
],
exports: [AuthService],
})
export class AuthModule {}
+193
View File
@@ -0,0 +1,193 @@
import { Injectable, UnauthorizedException, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { SyncIdentityHandler } from '../identity/application/handlers/user/sync-identity.handler';
import { IUser } from '../identity/domain/repositories/user.interface';
import { PrismaService } from '../../shared/prisma.service';
import { RedisPkceStore } from './pkce/redis-pkce.store';
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
import { Issuer, generators, TokenSet } from 'openid-client';
@Injectable()
export class AuthService {
constructor(
private readonly config: ConfigService,
private readonly jwtService: JwtService,
private readonly prisma: PrismaService,
@Inject(IUser) private readonly userRepository: IUser,
private readonly syncIdentityHandler: SyncIdentityHandler,
private readonly pkceStore: RedisPkceStore,
private readonly refreshStore: InMemoryRefreshStore,
) {}
private issuer: any = null;
private client: any = null;
private async getIssuer() {
if (this.issuer) return this.issuer;
const issuerUrl = this.config.get<string>('AUTHENTIK_ISSUER');
if (!issuerUrl) throw new Error('AUTHENTIK_ISSUER not configured');
this.issuer = await Issuer.discover(issuerUrl);
return this.issuer;
}
private async getClient() {
if (this.client) return this.client;
const issuer = await this.getIssuer();
const clientId = this.config.get<string>('AUTHENTIK_CLIENT_ID');
const clientSecret = this.config.get<string>('AUTHENTIK_CLIENT_SECRET');
if (!clientId) throw new Error('AUTHENTIK_CLIENT_ID not configured');
this.client = new issuer.Client({ client_id: clientId, client_secret: clientSecret });
return this.client;
}
async createAuthorizationRedirect(returnTo?: string) {
const client = await this.getClient();
const redirectUri = this.config.get<string>('AUTH_CALLBACK_URL') || `${this.config.get('APP_URL') || ''}/auth/callback`;
const state = require('crypto').randomUUID();
const code_verifier = generators.codeVerifier();
const code_challenge = await generators.codeChallenge(code_verifier);
const nonce = generators.nonce();
await this.pkceStore.save(state, { code_verifier, nonce, returnTo }, 300);
const url = client.authorizationUrl({
redirect_uri: redirectUri,
scope: this.config.get<string>('AUTHENTIK_DEFAULT_SCOPE') || 'openid email profile',
response_type: 'code',
code_challenge,
code_challenge_method: 'S256',
state,
nonce,
});
return url;
}
async handleCallback(code: string, state: string) {
const client = await this.getClient();
const pkce = await this.pkceStore.get(state);
if (!pkce) throw new UnauthorizedException('Invalid or expired state');
// remove one-time state
await this.pkceStore.remove(state);
const redirectUri = this.config.get<string>('AUTH_CALLBACK_URL') || `${this.config.get('APP_URL') || ''}/auth/callback`;
// exchange code
const tokenSet: TokenSet = await client.callback(redirectUri, { code, state }, { code_verifier: pkce.code_verifier, nonce: pkce.nonce });
// verify id_token and get claims
const claims = tokenSet.claims();
// fetch userinfo if available
let userInfo = null;
try {
if (tokenSet.access_token && client.userinfo) {
userInfo = await client.userinfo(tokenSet.access_token);
}
} catch (e) {
// ignore
}
const identity = {
sub: userInfo?.sub || claims.sub || null,
preferred_username: userInfo?.preferred_username || userInfo?.username || userInfo?.email || claims.preferred_username || claims.email,
email: userInfo?.email || claims.email,
raw: { tokenSet, userInfo, claims },
} as any;
// Sync identity to local user (create if needed)
const domainUser = await this.syncIdentityHandler.execute(identity as any);
// ensure active/not deleted
if (!domainUser.isActive) throw new UnauthorizedException('User is not active');
if (domainUser.deletedAt) throw new UnauthorizedException('User is deleted');
// create internal JWT
const jwtPayload = {
sub: domainUser.id,
preferred_username: domainUser.username,
email: domainUser.email,
} as any;
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
const access = this.jwtService.sign(jwtPayload, { expiresIn });
// create internal refresh token
const refreshToken = require('crypto').randomUUID();
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); // default 30 days
await this.refreshStore.set(refreshToken, { userId: domainUser.id }, refreshTtl);
return {
accessToken: access,
refreshToken,
expiresIn,
refreshTtl,
user: {
id: domainUser.id,
username: domainUser.username,
email: domainUser.email,
roles: domainUser.roles || [],
},
returnTo: pkce.returnTo,
};
}
async refresh(refreshToken?: string) {
if (!refreshToken) throw new UnauthorizedException('Missing refresh token');
const data = await this.refreshStore.get(refreshToken);
if (!data) throw new UnauthorizedException('Invalid refresh token');
const userId = data.userId;
// load user
const domainUser = await this.userRepository.findById(userId);
if (!domainUser) throw new UnauthorizedException('User not found');
if (!domainUser.isActive) throw new UnauthorizedException('User is not active');
// rotate refresh token
await this.refreshStore.del(refreshToken);
const newRefresh = require('crypto').randomUUID();
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600);
await this.refreshStore.set(newRefresh, { userId }, refreshTtl);
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
const access = this.jwtService.sign({ sub: domainUser.id, preferred_username: domainUser.username, email: domainUser.email }, { expiresIn });
return { accessToken: access, refreshToken: newRefresh, expiresIn, refreshTtl };
}
async logout(refreshToken?: string) {
if (refreshToken) {
await this.refreshStore.del(refreshToken);
}
const issuer = await this.getIssuer();
const endSession = issuer.metadata.end_session_endpoint;
const postLogout = this.config.get<string>('APP_URL') || '/';
if (endSession) {
// Redirect to identity provider logout
const url = new URL(endSession);
if (postLogout) url.searchParams.set('post_logout_redirect_uri', postLogout);
return url.toString();
}
return postLogout;
}
async me(token?: string) {
if (!token) throw new UnauthorizedException('Missing token');
try {
const payload: any = this.jwtService.verify(token);
const user = await this.userRepository.findById(payload.sub);
if (!user) throw new UnauthorizedException('User not found');
return { id: user.id, username: user.username, email: user.email, roles: user.roles || [] };
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Login DTO removed. Password grant has been removed in favor of Authorization Code + PKCE flow.
// Formerly contained username/password properties.
+42
View File
@@ -0,0 +1,42 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
type PkceEntry = { code_verifier: string; nonce: string; returnTo?: string; expiresAt: number };
@Injectable()
export class RedisPkceStore implements OnModuleDestroy {
// In-memory PKCE store replacing Redis-backed implementation
private map = new Map<string, PkceEntry>();
private cleanupInterval?: NodeJS.Timeout;
constructor() {
// periodic cleanup
this.cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [k, v] of this.map.entries()) {
if (v.expiresAt <= now) this.map.delete(k);
}
}, 60 * 1000);
}
private key(state: string) { return state; }
async save(state: string, data: { code_verifier: string; nonce: string; returnTo?: string }, ttlSeconds = 300) {
const expiresAt = Date.now() + ttlSeconds * 1000;
this.map.set(this.key(state), { ...data, expiresAt });
}
async get(state: string) {
const v = this.map.get(this.key(state));
if (!v) return null;
if (v.expiresAt <= Date.now()) { this.map.delete(this.key(state)); return null; }
return { code_verifier: v.code_verifier, nonce: v.nonce, returnTo: v.returnTo };
}
async remove(state: string) {
this.map.delete(this.key(state));
}
onModuleDestroy() {
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
}
}
@@ -0,0 +1,38 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
type RefreshEntry = { userId: string; expiresAt: number };
@Injectable()
export class InMemoryRefreshStore implements OnModuleDestroy {
private map = new Map<string, RefreshEntry>();
private cleanupInterval?: NodeJS.Timeout;
constructor() {
this.cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [k, v] of this.map.entries()) {
if (v.expiresAt <= now) this.map.delete(k);
}
}, 60 * 1000);
}
async set(token: string, data: { userId: string }, ttlSeconds: number) {
const expiresAt = Date.now() + ttlSeconds * 1000;
this.map.set(token, { userId: data.userId, expiresAt });
}
async get(token: string) {
const v = this.map.get(token);
if (!v) return null;
if (v.expiresAt <= Date.now()) { this.map.delete(token); return null; }
return { userId: v.userId };
}
async del(token: string) {
this.map.delete(token);
}
onModuleDestroy() {
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
}
}
@@ -2,6 +2,8 @@ import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@@ -25,12 +27,20 @@ export class PermissionGuard implements CanActivate {
return true;
}
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest() as any;
const user = request.user;
const user = request.currentUser;
return permissions.every(permission =>
user.permissions.includes(permission),
);
if (!user) {
throw new UnauthorizedException('Current user is missing.');
}
const hasAll = permissions.every((permission) => {
return user.hasPermission(permission);
});
if (!hasAll) throw new ForbiddenException('Insufficient permissions.');
return true;
}
}
+1 -12
View File
@@ -1,12 +1 @@
modules/identity/application/
Penjelasan:
Layer application berisi use-case (services/commands/queries) yang mengorkestrasi domain dan infrastruktur.
Contoh file:
- services/get-user.service.ts
- commands/create-user.command.ts
Aturan:
- Application service boleh memanggil repository interface, domain services, dan event publisher.
- Application menangani transaksi jika diperlukan.
ini adalah manager, semua kegiatan diarahkan dari sini, disini bukan mengakses db, menghitung, dll, dari controller akan masuk ke dalam sini dan dijalankan prosesnya, tapi tidak tau http dan databasenya.
@@ -0,0 +1,15 @@
import { IAuthConfig } from './i-auth-config';
export class EnvAuthConfig implements IAuthConfig {
autoCreateUser(): boolean {
return (process.env.AUTH_AUTO_CREATE_USER ?? 'true') === 'true';
}
syncEmail(): boolean {
return (process.env.AUTH_SYNC_EMAIL ?? 'true') === 'true';
}
syncUsername(): boolean {
return (process.env.AUTH_SYNC_USERNAME ?? 'false') === 'true';
}
}
@@ -0,0 +1,5 @@
export abstract class IAuthConfig {
abstract autoCreateUser(): boolean;
abstract syncEmail(): boolean;
abstract syncUsername(): boolean;
}
@@ -0,0 +1 @@
semua method disini didapat dari interface, itu ada di domain/repositories.
@@ -1,37 +0,0 @@
import {
Injectable,
ConflictException,
} from '@nestjs/common';
import { CreateUserDto } from '../../presentation/dto/create-user.dto';
import { User } from '../../domain/entities/user.entity';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
@Injectable()
export class CreateUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(dto: CreateUserDto): Promise<User> {
const exists = await this.userRepository.existsByEmail(dto.email);
if (exists) {
throw new ConflictException('Email already exists.');
}
const user = User.create({
name: dto.name,
email: dto.email,
password: dto.password,
metadata: dto.metadata,
});
await this.userRepository.create(user);
// TODO:
// this.eventDispatcher.publish(new UserCreatedEvent(user));
return user;
}
}
@@ -1,28 +0,0 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
@Injectable()
export class DeleteUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(id: string): Promise<void> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
user.delete();
await this.userRepository.update(user);
// TODO:
// Publish UserDeletedEvent
}
}
@@ -1,24 +0,0 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class FindUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(id: string): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
return user;
}
}
@@ -1,15 +0,0 @@
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class FindUsersHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(): Promise<User[]> {
return await this.userRepository.findAll();
}
}
@@ -0,0 +1,21 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { PermissionData } from '../../../domain/entities/permission.entity';
@Injectable()
export class CreatePermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
async execute(dto: any) {
const perm = PermissionData.restore({
id: crypto.randomUUID(),
code: dto.code || dto.name,
name: dto.name,
description: dto.description || '',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
return this.permissionRepository.create(perm);
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
@Injectable()
export class DeletePermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
async execute(id: string) {
const p = await this.permissionRepository.getById(id);
if (!p) throw new NotFoundException('Permission not found.');
await this.permissionRepository.delete(id);
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
@Injectable()
export class GetPermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
async execute(id: string) {
return this.permissionRepository.getById(id);
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
@Injectable()
export class GetPermissionsHandler {
constructor(private readonly permissionRepository: IPermission) {}
async execute(query: { page?: number; limit?: number; search?: string }) {
return this.permissionRepository.find({ page: query.page, limit: query.limit, search: query.search || null });
}
}
@@ -0,0 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
@Injectable()
export class UpdatePermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
async execute(id: string, dto: any) {
const perm = await this.permissionRepository.getById(id);
if (!perm) throw new NotFoundException('Permission not found.');
if (dto.name) perm.changeName(dto.name);
if (dto.description !== undefined) perm.changeDescription(dto.description);
if (dto.code) perm.changeCode(dto.code);
return this.permissionRepository.update(perm);
}
}
@@ -0,0 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { IPermission } from '../../../domain/repositories/permission.interface';
@Injectable()
export class RoleAssignPermissionHandler {
constructor(
private readonly roleRepository: IRole,
private readonly permissionRepository: IPermission,
) {}
async execute(roleId: string, permissionId: string) {
const role = await this.roleRepository.findById(roleId);
if (!role) throw new NotFoundException('Role not found.');
const perm = await this.permissionRepository.getById(permissionId);
if (!perm) throw new NotFoundException('Permission not found.');
role.assignPermission(perm);
return this.roleRepository.update(role);
}
}
@@ -0,0 +1,30 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { RoleData } from '../../../domain/entities/role.entity';
@Injectable()
export class CreateRoleHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(dto: any) {
// check uniqueness by code
// simple check
try {
// attempt to create; repository may enforce uniqueness
const role = RoleData.restore({
id: crypto.randomUUID(),
code: dto.code,
name: dto.name,
description: dto.description || '',
permissions: [],
isDefault: dto.isDefault || false,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
return this.roleRepository.create(role);
} catch (e) {
throw new ConflictException('Role creation failed.');
}
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class DeleteRoleHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(id: string) {
const role = await this.roleRepository.findById(id);
if (!role) throw new NotFoundException('Role not found.');
await this.roleRepository.delete(id);
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class GetRoleHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(id: string) {
return this.roleRepository.findById(id);
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class GetRolesHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(query: { page?: number; limit?: number; search?: string }) {
return this.roleRepository.find({ page: query.page, limit: query.limit, search: query.search || null });
}
}
@@ -0,0 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class RoleRemovePermissionHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(roleId: string, permissionId: string) {
const role = await this.roleRepository.findById(roleId);
if (!role) throw new NotFoundException('Role not found.');
role.removePermission(permissionId);
return this.roleRepository.update(role);
}
}
@@ -0,0 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class UpdateRoleHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(id: string, dto: any) {
const role = await this.roleRepository.findById(id);
if (!role) throw new NotFoundException('Role not found.');
if (dto.name) role.changeName(dto.name);
if (dto.description !== undefined) role.changeDescription(dto.description);
if (dto.isDefault !== undefined) role.setDefault(!!dto.isDefault);
return this.roleRepository.update(role);
}
}
@@ -1,51 +0,0 @@
import {
Injectable,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { UpdateUserDto } from '../../presentation/dto/update-user.dto';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class UpdateUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(
id: string,
dto: UpdateUserDto,
): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
if (
dto.email &&
dto.email !== user.email
) {
const exists =
await this.userRepository.existsByEmail(dto.email);
if (exists) {
throw new ConflictException(
'Email already exists.',
);
}
user.changeEmail(dto.email);
}
if (dto.name) {
user.changeName(dto.name);
}
await this.userRepository.update(user);
return user;
}
}
@@ -0,0 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
import { PermissionData } from '../../../domain/entities/permission.entity';
@Injectable()
export class AssignPermissionHandler {
constructor(private readonly userRepository: IUser) {}
async execute(userId: string, permissionId: string) {
const user = await this.userRepository.getById(userId);
if (!user) throw new NotFoundException('User not found.');
// Permission repository is not available; create a minimal PermissionData
const perm = PermissionData.restore({
id: permissionId,
name: permissionId,
description: '',
createdAt: new Date(),
updatedAt: new Date(),
});
user.assignPermission(perm);
return this.userRepository.update(user);
}
}
@@ -0,0 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class AssignRoleHandler {
constructor(
private readonly userRepository: IUser,
private readonly roleRepository: IRole,
) {}
async execute(userId: string, roleId: string) {
const user = await this.userRepository.getById(userId);
if (!user) throw new NotFoundException('User not found.');
const role = await this.roleRepository.findById(roleId);
if (!role) throw new NotFoundException('Role not found.');
user.assignRole(role);
return this.userRepository.update(user);
}
}
@@ -0,0 +1,31 @@
import {
Injectable,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { CreateUserDto } from '../../../presentation/dto/create-user.dto';
import { IUser } from '../../../domain/repositories/user.interface';
import { UserData } from '../../../domain/entities/user.entity';
import { UserService } from '../../services/user.service';
@Injectable()
export class CreateUserHandler {
constructor(
private readonly userRepository: IUser,
private readonly userService: UserService,
) {}
async execute(dto: CreateUserDto): Promise<UserData> {
const exists = await this.userRepository.existByEmail(dto.email);
if (exists) {
throw new ConflictException('Email already exists.');
}
// Provisioning disabled: Do not create users in external IdP from RayLab.
// Reject attempts to create users via API to enforce creation-at-Authentik policy.
throw new BadRequestException('User creation via RayLab API is disabled. Create users in Authentik and then login to sync.');
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class DeleteUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
const exists = await this.userRepository.existsById(id);
if (!exists) throw new NotFoundException('User not found.');
await this.userRepository.softDelete(id);
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class DisableUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
const exists = await this.userRepository.existsById(id);
if (!exists) throw new NotFoundException('User not found.');
return this.userRepository.disable(id);
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class EnableUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
const exists = await this.userRepository.existsById(id);
if (!exists) throw new NotFoundException('User not found.');
return this.userRepository.enable(id);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { UserData } from '../../../domain/entities/user.entity';
@Injectable()
export class GetCurrentUserHandler {
async execute(currentUser: UserData) {
// currentUser is already domain user attached by CurrentUserGuard
return currentUser;
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class GetUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
return this.userRepository.getById(id);
}
}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class GetUsersHandler {
constructor(private readonly userRepository: IUser) {}
async execute(query: {
page?: number;
limit?: number;
search?: string;
isActive?: boolean;
deleted?: boolean;
roleId?: string;
}) {
const res = await this.userRepository.find({
page: query.page,
limit: query.limit,
search: query.search || null,
isActive: query.isActive !== undefined ? query.isActive : null,
deleted: query.deleted !== undefined ? query.deleted : null,
roleId: query.roleId || null,
});
return res;
}
}
@@ -0,0 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class RemovePermissionHandler {
constructor(private readonly userRepository: IUser) {}
async execute(userId: string, permissionId: string) {
const user = await this.userRepository.getById(userId);
if (!user) throw new NotFoundException('User not found.');
user.removePermission(permissionId);
return this.userRepository.update(user);
}
}
@@ -0,0 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class RemoveRoleHandler {
constructor(private readonly userRepository: IUser) {}
async execute(userId: string, roleId: string) {
const user = await this.userRepository.getById(userId);
if (!user) throw new NotFoundException('User not found.');
user.removeRole(roleId);
return this.userRepository.update(user);
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class RestoreUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
const exists = await this.userRepository.existsById(id);
if (!exists) throw new NotFoundException('User not found.');
return this.userRepository.restore(id);
}
}
@@ -0,0 +1,86 @@
import { Injectable, UnauthorizedException, ForbiddenException, Inject } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
import { IRole } from '../../../domain/repositories/role.interface';
import { IAuthConfig } from '../../config/i-auth-config';
import { UserData } from '../../../domain/entities/user.entity';
import { IdentityData } from '../../../../../core/auth/interfaces/identity-data';
@Injectable()
export class SyncIdentityHandler {
constructor(
private readonly userRepository: IUser,
private readonly roleRepository: IRole,
private readonly authConfig: IAuthConfig,
) {}
async execute(identity: IdentityData): Promise<UserData> {
const { sub, preferred_username, email } = identity as any;
if (!sub) throw new UnauthorizedException('Invalid identity payload.');
// Try find by authentikId
let user = await this.userRepository.findByAuthentikId(sub);
const syncEmail = this.authConfig.syncEmail();
const syncUsername = this.authConfig.syncUsername();
if (!user) {
// User does not exist locally: create minimal local record per new architecture
const username = preferred_username || email || sub;
const userEntity = UserData.create({ username, email: email || '', authentikId: sub });
// assign default role if available
try {
const defaultRole = await this.roleRepository.getDefaultRole();
if (defaultRole) {
userEntity.assignRole(defaultRole);
}
} catch (e) {
// ignore if role repo not available or no default role
}
// persist local user
user = await this.userRepository.create(userEntity);
// return freshly created user
return user;
} else {
let changed = false;
if (syncEmail && email && user.email !== email) {
user.changeEmail(email);
changed = true;
// prepare event class instance if needed (no dispatch)
}
if (syncUsername && preferred_username && user.username !== preferred_username) {
user.changeUsername(preferred_username);
changed = true;
// prepare event class instance if needed (no dispatch)
}
// update last seen
user.touchLastSeen();
changed = true;
if (changed) {
user = await this.userRepository.update(user);
}
}
// Validations
if (!user.isActive) {
// Authentication succeeded but user is disabled
throw new ForbiddenException('User is not active.');
}
if (user.deletedAt) {
throw new ForbiddenException('User is deleted.');
}
// Ensure roles/permissions loaded (repo should return includes)
return user;
}
}
@@ -1,12 +0,0 @@
services/
Penjelasan:
Application services (use-cases) untuk module identity.
Contoh file:
- get-user.service.ts
- create-user.service.ts
Aturan:
- Application service mengorkestrasi domain services dan repository.
- Menangani transaction boundary jika perlu.
@@ -0,0 +1,20 @@
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../../../shared/prisma.service';
import { IUser } from '../../domain/repositories/user.interface';
import { UserData } from '../../domain/entities/user.entity';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
private readonly prisma: PrismaService,
private readonly userRepository: IUser,
) {}
async createUser(input: { username: string; email: string; roles?: string[]; isActive?: boolean }) {
// Provisioning to external Identity Provider (Authentik) has been disabled.
// All users must be created in Authentik first. RayLab will create local record on first successful login.
throw new BadRequestException('Provisioning disabled: create users in Authentik and login to sync to RayLab');
}
}
+1 -11
View File
@@ -1,11 +1 @@
entities/
Penjelasan:
Entity domain untuk identity, mis. User, Profile.
Contoh file:
- user.entity.ts
- profile.entity.ts
Aturan:
- Entity berisi atribut dan mungkin method domain kecil (invariants), bukan orchestration.
ini adalah tempat class objek dibuat, objek user, role, dll. disini juga ada method dan harus diingat yang boleh mengubah objek ini hanya objek ini sendiri.
@@ -0,0 +1,40 @@
export class PermissionData {
private constructor(
public readonly id: string,
public code: string,
public name: string,
public description: string,
public createdAt: Date,
public updatedAt: Date,
) {}
changeName(name: string) {
this.name = name;
}
changeDescription(description: string) {
this.description = description;
}
changeCode(code: string) {
this.code = code;
}
static restore(props: {
id: string;
code?: string;
name: string;
description: string;
createdAt: Date;
updatedAt: Date;
}) {
return new PermissionData(
props.id,
props.code || props.name,
props.name,
props.description,
props.createdAt,
props.updatedAt,
);
}
}
@@ -0,0 +1,64 @@
import { PermissionData } from "./permission.entity";
export class RoleData {
private constructor(
public readonly id: string,
public code: string,
public name: string,
public description: string,
public permissions: PermissionData[],
public isDefault: boolean,
public createdAt: Date,
public updatedAt: Date,
) {}
hasPermissions(permissionId: string): boolean {
return this.permissions.some(x => x.id.toLowerCase() === permissionId.toLowerCase());
}
assignPermission(permissionData: PermissionData): void {
if (this.hasPermissions(permissionData.id)) throw new Error("Permission sudah dimiliki.");
this.permissions.push(permissionData);
}
removePermission(permissionId: string): void {
const idx = this.permissions.findIndex(p => p.id.toLowerCase() === permissionId.toLowerCase());
if (idx === -1) throw new Error('Permission tidak ditemukan pada role.');
this.permissions.splice(idx, 1);
}
changeName(name: string) {
this.name = name;
}
changeDescription(description: string) {
this.description = description;
}
setDefault(isDefault: boolean) {
this.isDefault = isDefault;
}
static restore(props: {
id: string;
code: string;
name: string;
description: string;
permissions?: PermissionData[];
isDefault?: boolean;
createdAt: Date;
updatedAt: Date;
}) {
return new RoleData(
props.id,
props.code,
props.name,
props.description,
props.permissions || [],
props.isDefault ?? false,
props.createdAt,
props.updatedAt,
);
}
}
@@ -1,52 +1,196 @@
export class User {
private constructor(
import { RoleData } from "./role.entity";
import { PermissionData } from "./permission.entity";
export class UserData {
private constructor(
public readonly id: string,
public name: string,
public authentikId: string | null,
public username: string,
public email: string,
public password: string,
public metadata?: Record<string, any>,
public password: string | null,
public roles: RoleData[],
public permissions: PermissionData[],
public isActive: boolean,
public deletedAt: Date | null,
public lastSeenAt: Date | null,
public storageQuota: number,
public storageUsed: number,
) {}
static create(data: {
name: string;
//#region Create
static create(data: {
username: string;
email: string;
password: string;
metadata?: Record<string, any>;
}): User {
return new User(
password?: string | null;
authentikId?: string | null;
storageQuota?: number;
storageUsed?: number;
}): UserData {
const quota = data.storageQuota !== undefined ? data.storageQuota : 10737418240; // 10 GB
const used = data.storageUsed !== undefined ? data.storageUsed : 0;
return new UserData(
crypto.randomUUID(),
data.name,
data.authentikId || null,
data.username,
data.email,
data.password,
data.metadata,
data.password ?? null,
[],
[],
true,
null,
null,
quota,
used,
);
}
delete() {
// Business Rule
//#endregion
//#region Read
hasRole(roleId: string) : boolean {
return this.roles.some(x => x.id.toLowerCase() === roleId.toLowerCase())
}
changeEmail(email: string) {
assignRole(roleData : RoleData) : void {
if (this.hasRole(roleData.id))
throw new Error("Role Sudah dimiliki.");
this.roles.push(roleData);
}
hasPermissions(permissionId : string) : boolean {
return this.permissions.some(x => x.id.toLowerCase() === permissionId.toLowerCase())
}
hasPermission(permission: string) : boolean {
const byPerm = this.permissions.some(x => x.id.toLowerCase() === permission.toLowerCase() || x.name.toLowerCase() === permission.toLowerCase());
if (byPerm) return true;
// check roles
return this.roles.some(r => r.permissions.some(p => p.id.toLowerCase() === permission.toLowerCase() || p.name.toLowerCase() === permission.toLowerCase()));
}
assignPermission(permissionData : PermissionData) : void {
if (this.hasPermissions(permissionData.id))
throw new Error("Permission sudah dimiliki.")
this.permissions.push(permissionData);
}
//#endregion
//#region Update
changeEmail(email: string) {
this.email = email;
}
changeName(name: string) {
this.name = name;
changeUsername(username: string) {
this.username = username;
}
static restore(data: {
id: string;
name: string;
email: string;
password: string;
metadata?: Record<string, any>;
}): User {
return new User(
data.id,
data.name,
data.email,
data.password,
data.metadata,
);
touchLastSeen() {
this.lastSeenAt = new Date();
}
enable() {
this.isActive = true;
}
disable() {
this.isActive = false;
}
//#endregion
//#region Delete
softDelete() {
this.deletedAt = new Date();
this.isActive = false;
}
restoreInstance() {
this.deletedAt = null;
}
//#endregion
public static restore(props: {
id: string;
authentikId?: string | null;
username: string;
email: string;
password: string | null;
roles?: RoleData[];
permissions?: PermissionData[];
isActive?: boolean;
deletedAt?: Date | null;
lastSeenAt?: Date | null;
storageQuota?: number | null;
storageUsed?: number | null;
}): UserData {
return new UserData(
props.id,
props.authentikId || null,
props.username,
props.email,
props.password,
props.roles || [],
props.permissions || [],
props.isActive !== undefined ? props.isActive : true,
props.deletedAt || null,
props.lastSeenAt || null,
props.storageQuota !== undefined && props.storageQuota !== null ? props.storageQuota : 10737418240,
props.storageUsed !== undefined && props.storageUsed !== null ? props.storageUsed : 0,
);
}
removeRole(roleId: string) {
const idx = this.roles.findIndex(r => r.id.toLowerCase() === roleId.toLowerCase());
if (idx === -1) throw new Error('Role tidak ditemukan pada user.');
this.roles.splice(idx, 1);
}
removePermission(permissionId: string) {
const idx = this.permissions.findIndex(p => p.id.toLowerCase() === permissionId.toLowerCase());
if (idx === -1) throw new Error('Permission tidak ditemukan pada user.');
this.permissions.splice(idx, 1);
}
// Storage helpers
setStorageQuota(bytes: number) {
if (bytes < 0) throw new Error('storageQuota must be >= 0');
if (this.storageUsed > bytes) throw new Error('storageQuota cannot be less than storageUsed');
this.storageQuota = bytes;
}
setStorageUsed(bytes: number) {
if (bytes < 0) throw new Error('storageUsed must be >= 0');
if (bytes > this.storageQuota) throw new Error('storageUsed cannot exceed storageQuota');
this.storageUsed = bytes;
}
// Response helper for API
toResponse() {
const remaining = this.storageQuota - this.storageUsed;
const usagePercentage = this.storageQuota > 0 ? Math.round((this.storageUsed / this.storageQuota) * 100) : 0;
return {
id: this.id,
authentikId: this.authentikId,
username: this.username,
email: this.email,
roles: this.roles,
permissions: this.permissions,
isActive: this.isActive,
deletedAt: this.deletedAt,
lastSeenAt: this.lastSeenAt,
storage: {
quota: this.storageQuota,
used: this.storageUsed,
remaining,
usagePercentage,
},
};
}
}
@@ -2,5 +2,6 @@ import { DomainEvent } from '../../../../core/events/event.interface';
export class UserCreatedEvent implements DomainEvent {
readonly name = 'UserCreated';
constructor(public readonly payload: any, public readonly occurredAt: Date = new Date()) {}
readonly occurredAt: Date = new Date();
constructor(public readonly payload: { userId: string; authentikId: string; email: string; username: string }) {}
}
@@ -1,6 +0,0 @@
import { DomainEvent } from '../../../../core/events/event.interface';
export class UserDeletedEvent implements DomainEvent {
readonly name = 'UserDeleted';
constructor(public readonly payload: any, public readonly occurredAt: Date = new Date()) {}
}
@@ -0,0 +1,7 @@
import { DomainEvent } from '../../../../core/events/event.interface';
export class UserDisabledEvent implements DomainEvent {
readonly name = 'UserDisabled';
readonly occurredAt: Date = new Date();
constructor(public readonly payload: { userId: string }) {}
}
@@ -0,0 +1,7 @@
import { DomainEvent } from '../../../../core/events/event.interface';
export class UserEmailChangedEvent implements DomainEvent {
readonly name = 'UserEmailChanged';
readonly occurredAt: Date = new Date();
constructor(public readonly payload: { userId: string; oldEmail: string; newEmail: string }) {}
}
@@ -0,0 +1,7 @@
import { DomainEvent } from '../../../../core/events/event.interface';
export class UserEnabledEvent implements DomainEvent {
readonly name = 'UserEnabled';
readonly occurredAt: Date = new Date();
constructor(public readonly payload: { userId: string }) {}
}
@@ -0,0 +1,7 @@
import { DomainEvent } from '../../../../core/events/event.interface';
export class UserUsernameChangedEvent implements DomainEvent {
readonly name = 'UserUsernameChanged';
readonly occurredAt: Date = new Date();
constructor(public readonly payload: { userId: string; oldUsername: string; newUsername: string }) {}
}
@@ -0,0 +1 @@
ini adalah interface, semua yang akan dibuat oleh application/handler harus dibuat disini dulu.
@@ -0,0 +1,9 @@
import { PermissionData } from '../entities/permission.entity';
export abstract class IPermission {
abstract find(params: { page?: number; limit?: number; search?: string | null }): Promise<{ data: PermissionData[]; total: number }>;
abstract getById(id: string): Promise<PermissionData>;
abstract create(permission: PermissionData): Promise<PermissionData>;
abstract update(permission: PermissionData): Promise<PermissionData>;
abstract delete(id: string): Promise<void>;
}
@@ -0,0 +1,12 @@
import { RoleData } from '../entities/role.entity';
export abstract class IRole {
abstract getDefaultRole(): Promise<RoleData | null>;
abstract findById(roleId: string): Promise<RoleData>;
abstract find(params: { page?: number; limit?: number; search?: string | null }): Promise<{ data: RoleData[]; total: number }>;
abstract create(role: RoleData): Promise<RoleData>;
abstract update(role: RoleData): Promise<RoleData>;
abstract delete(roleId: string): Promise<void>;
}
@@ -0,0 +1,29 @@
import { UserData } from '../entities/user.entity';
export abstract class IUser {
abstract create(user: UserData): Promise<UserData>;
abstract existsById(userId: string): Promise<boolean>;
abstract existByEmail(userEmail: string): Promise<boolean>;
abstract getById(userId: string): Promise<UserData>;
abstract getByEmail(userEmail: string): Promise<UserData>;
abstract findByAuthentikId(authentikId: string): Promise<UserData | null>;
abstract update(user: UserData): Promise<UserData>;
abstract find(params: {
page?: number;
limit?: number;
search?: string | null;
isActive?: boolean | null;
deleted?: boolean | null;
roleId?: string | null;
}): Promise<{ data: UserData[]; total: number }>;
abstract softDelete(userId: string): Promise<void>;
abstract restore(userId: string): Promise<UserData>;
abstract enable(userId: string): Promise<UserData>;
abstract disable(userId: string): Promise<UserData>;
}
@@ -1,17 +0,0 @@
import { User } from '../entities/user.entity';
export abstract class UserRepository {
abstract create(user: User): Promise<User>;
abstract update(user: User): Promise<User>;
abstract findById(
id: string,
): Promise<User | null>;
abstract findAll(): Promise<User[]>;
abstract existsByEmail(
email: string,
): Promise<boolean>;
}
+91 -28
View File
@@ -1,52 +1,115 @@
import { Module } from '@nestjs/common';
import { UsersController } from './presentation/controllers/users.controller';
import { RolesController } from './presentation/controllers/roles.controller';
import { PermissionsController } from './presentation/controllers/permissions.controller';
import { PrismaService } from '../../shared/prisma.service';
import { UserService } from './application/services/user.service';
import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository';
import { UserRepository } from './domain/repositories/user.repository.interface';
import { EventDispatcher } from '../../core/events/event-dispatcher';
import { IUser } from './domain/repositories/user.interface';
import { JwtAuthGuard } from '../../core/auth/guards/jwt-auth.guard';
import { JwtModule } from '@nestjs/jwt';
import { CreateUserHandler } from './application/handlers/create-user.handler';
import { FindUserHandler } from './application/handlers/find-user.handler';
import { DeleteUserHandler } from './application/handlers/delete-user.handler';
import { FindUsersHandler } from './application/handlers/find-users.handler';
import { UpdateUserHandler } from './application/handlers/update-user.handler';
import { CreateUserHandler } from './application/handlers/user/create-user.handler';
import { PermissionGuard } from '../authorization/presentation/guards/permission.guard';
import { Reflector } from '@nestjs/core';
import { SyncIdentityHandler } from './application/handlers/user/sync-identity.handler';
import { CurrentUserGuard } from './presentation/guards/current-user.guard';
import { PrismaRoleRepository } from './infrastructure/repositories/prisma-role.repository';
import { PrismaPermissionRepository } from './infrastructure/repositories/prisma-permission.repository';
import { IRole } from './domain/repositories/role.interface';
import { IPermission } from './domain/repositories/permission.interface';
import { IAuthConfig } from './application/config/i-auth-config';
import { EnvAuthConfig } from './application/config/env-auth-config';
import { GetUsersHandler } from './application/handlers/user/get-users.handler';
import { GetUserHandler } from './application/handlers/user/get-user.handler';
import { GetCurrentUserHandler } from './application/handlers/user/get-current-user.handler';
import { EnableUserHandler } from './application/handlers/user/enable-user.handler';
import { DisableUserHandler } from './application/handlers/user/disable-user.handler';
import { DeleteUserHandler } from './application/handlers/user/delete-user.handler';
import { RestoreUserHandler } from './application/handlers/user/restore-user.handler';
import { AssignRoleHandler } from './application/handlers/user/assign-role.handler';
import { RemoveRoleHandler } from './application/handlers/user/remove-role.handler';
import { AssignPermissionHandler } from './application/handlers/user/assign-permission.handler';
import { RemovePermissionHandler } from './application/handlers/user/remove-permission.handler';
import { GetRolesHandler } from './application/handlers/role/get-roles.handler';
import { GetRoleHandler } from './application/handlers/role/get-role.handler';
import { CreateRoleHandler } from './application/handlers/role/create-role.handler';
import { UpdateRoleHandler } from './application/handlers/role/update-role.handler';
import { DeleteRoleHandler } from './application/handlers/role/delete-role.handler';
import { RoleAssignPermissionHandler } from './application/handlers/role/assign-permission.handler';
import { RoleRemovePermissionHandler } from './application/handlers/role/remove-permission.handler';
import { GetPermissionsHandler } from './application/handlers/permission/get-permissions.handler';
import { GetPermissionHandler } from './application/handlers/permission/get-permission.handler';
import { CreatePermissionHandler } from './application/handlers/permission/create-permission.handler';
import { UpdatePermissionHandler } from './application/handlers/permission/update-permission.handler';
import { DeletePermissionHandler } from './application/handlers/permission/delete-permission.handler';
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: {
expiresIn: '1d',
},
}),
],
controllers: [UsersController],
providers: [
//#region User
CreateUserHandler,
FindUserHandler,
FindUsersHandler,
UpdateUserHandler,
//#region User
// CreateUserHandler has been removed: provisioning disabled; users must be created in Authentik.
SyncIdentityHandler,
GetUsersHandler,
GetUserHandler,
GetCurrentUserHandler,
EnableUserHandler,
DisableUserHandler,
DeleteUserHandler,
RestoreUserHandler,
AssignRoleHandler,
RemoveRoleHandler,
AssignPermissionHandler,
RemovePermissionHandler,
//#endregion
// role & permission handlers
GetRolesHandler,
GetRoleHandler,
CreateRoleHandler,
UpdateRoleHandler,
DeleteRoleHandler,
RoleAssignPermissionHandler,
RoleRemovePermissionHandler,
GetPermissionsHandler,
GetPermissionHandler,
CreatePermissionHandler,
UpdatePermissionHandler,
DeletePermissionHandler,
JwtAuthGuard,
CurrentUserGuard,
PermissionGuard,
Reflector,
PrismaService,
PrismaService,
PrismaUserRepository,
PrismaRoleRepository,
PrismaPermissionRepository,
UserService,
{
provide: UserRepository,
provide: IUser,
useClass: PrismaUserRepository,
},
{
provide: 'EVENT_DISPATCHER',
useValue: new EventDispatcher(),
{
provide: IRole,
useClass: PrismaRoleRepository,
},
],
{
provide: IPermission,
useClass: PrismaPermissionRepository,
},
{
provide: IAuthConfig,
useClass: EnvAuthConfig,
},
],
imports: [],
controllers: [UsersController, RolesController, PermissionsController],
})
export class IdentityModule {}
+1 -12
View File
@@ -1,12 +1 @@
modules/identity/infrastructure/
Penjelasan:
Implementasi teknis untuk module identity, seperti Prisma repository, adapter implementations, dan data mappers.
Contoh file:
- prisma/user.repository.ts (mengimplementasikan domain repository interface)
- adapter/identity-adapter.ts
Aturan:
- Infrastruktur hanya mengimplementasikan interface domain; jangan memuat business rules.
- Import dari infrastructure ke domain harus satu arah: infrastructure -> domain (implementasi).
infrastrucute adalah tempat yang langsung berhubungan dengan dunia luar, misal db, server lain, JWT, dsb.
@@ -0,0 +1 @@
disini emngubah dari domain/entity menjadi format di database dan sebaliknya.
@@ -0,0 +1,23 @@
import { PermissionData } from '../../domain/entities/permission.entity';
export class PrismaPermissionMapper {
static toDomain(model: any): PermissionData {
return PermissionData.restore({
id: model.id,
code: model.code || model.name,
name: model.name,
description: model.description,
createdAt: model.createdAt,
updatedAt: model.updatedAt,
});
}
static toPersistence(permission: PermissionData) {
return {
id: permission.id,
code: permission.code,
name: permission.name,
description: permission.description,
};
}
}
@@ -0,0 +1,27 @@
import { RoleData } from '../../domain/entities/role.entity';
import { PermissionData } from '../../domain/entities/permission.entity';
export class PrismaRoleMapper {
static toDomain(model: any): RoleData {
const permissions: PermissionData[] = (model.permissions || []).map((p: any) =>
PermissionData.restore({
id: p.id,
name: p.name,
description: p.description,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
}),
);
return RoleData.restore({
id: model.id,
code: model.code,
name: model.name,
description: model.description,
permissions,
isDefault: model.isDefault ?? false,
createdAt: model.createdAt,
updatedAt: model.updatedAt,
});
}
}
@@ -1,27 +1,73 @@
import { User } from '../../domain/entities/user.entity';
import { UserData } from '../../domain/entities/user.entity';
import { RoleData } from '../../domain/entities/role.entity';
import { PermissionData } from '../../domain/entities/permission.entity';
export class PrismaUserMapper {
static toDomain(model: any): User | null {
if (!model) {
return null;
}
static toDomain(model: any): UserData {
const roles: RoleData[] = (model.roles || []).map((ur: any) => {
const r = ur.role;
const permissions: PermissionData[] = (r?.permissions || []).map((rp: any) =>
PermissionData.restore({
id: rp.permission.id,
name: rp.permission.name,
description: rp.permission.description,
createdAt: rp.permission.createdAt,
updatedAt: rp.permission.updatedAt,
}),
);
return User.restore({
return RoleData.restore({
id: r.id,
code: r.code,
name: r.name,
description: r.description,
permissions,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
});
});
const permissions: PermissionData[] = (model.permissions || []).map((up: any) =>
PermissionData.restore({
id: up.permission.id,
name: up.permission.name,
description: up.permission.description,
createdAt: up.permission.createdAt,
updatedAt: up.permission.updatedAt,
}),
);
return UserData.restore({
id: model.id,
name: model.name,
authentikId: model.authentikUserId || model.authentikId || null,
username: model.username || model.name || model.username,
email: model.email,
password: model.password,
metadata: model.metadata,
password: model.password ?? null,
roles,
permissions,
isActive: model.isActive ?? true,
deletedAt: model.deletedAt || null,
lastSeenAt: model.lastSeenAt || null,
storageQuota: model.storageQuota !== undefined && model.storageQuota !== null ? Number(model.storageQuota) : undefined,
storageUsed: model.storageUsed !== undefined && model.storageUsed !== null ? Number(model.storageUsed) : undefined,
});
}
static toPersistence(user: User) {
return {
id: user.id,
name: user.name,
email: user.email,
password: user.password,
metadata: user.metadata ?? {},
};
static toPersistence(userData: UserData) {
return {
id: userData.id,
authentikUserId: (userData as any).authentikUserId || userData.authentikId,
authentikId: userData.authentikId,
username: userData.username,
email: userData.email,
password: userData.password ?? null,
isActive: userData.isActive,
deletedAt: userData.deletedAt,
lastSeenAt: userData.lastSeenAt,
lastSyncedAt: (userData as any).lastSyncedAt,
syncStatus: (userData as any).syncStatus,
storageQuota: (userData as any).storageQuota,
storageUsed: (userData as any).storageUsed,
};
}
}
@@ -0,0 +1 @@
ini adalah tempat konfigurasi database.
@@ -1,11 +1 @@
repositories/
Penjelasan:
Implementasi repository di layer infrastructure. Biasanya berisi Prisma queries dan mapping antara DB model dan domain entity.
Contoh file:
- prisma/user.repository.ts
Aturan:
- Repository mengimplementasikan interface di domain layer.
- Hindari business logic di repository.
disini yang menjalankan logika bisnisnya.
@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../shared/prisma.service';
import { IPermission } from '../../domain/repositories/permission.interface';
import { PrismaPermissionMapper } from '../mappers/prisma-permission.mapper';
import { PermissionData } from '../../domain/entities/permission.entity';
@Injectable()
export class PrismaPermissionRepository implements IPermission {
constructor(private readonly prisma: PrismaService) {}
async find(params: { page?: number; limit?: number; search?: string | null }) {
const page = params.page && params.page > 0 ? params.page : 1;
const limit = params.limit && params.limit > 0 ? params.limit : 10;
const where: any = {};
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: 'insensitive' } },
{ code: { contains: params.search, mode: 'insensitive' } },
{ description: { contains: params.search, mode: 'insensitive' } },
];
}
const [total, items] = await Promise.all([
this.prisma.permission.count({ where }),
this.prisma.permission.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' } }),
]);
return { data: items.map(i => PrismaPermissionMapper.toDomain(i)!), total };
}
async getById(id: string) {
const p = await this.prisma.permission.findUnique({ where: { id } });
if (!p) throw new Error('Permission not found.');
return PrismaPermissionMapper.toDomain(p);
}
async create(permission: PermissionData) {
const base = PrismaPermissionMapper.toPersistence(permission);
const created = await this.prisma.permission.create({ data: base });
return PrismaPermissionMapper.toDomain(created)!;
}
async update(permission: PermissionData) {
const base = PrismaPermissionMapper.toPersistence(permission);
const updated = await this.prisma.permission.update({ where: { id: permission.id }, data: base });
return PrismaPermissionMapper.toDomain(updated)!;
}
async delete(id: string) {
await this.prisma.permission.delete({ where: { id } });
}
}
@@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../shared/prisma.service';
import { IRole } from '../../domain/repositories/role.interface';
import { PrismaRoleMapper } from '../mappers/prisma-role.mapper';
import { RoleData } from '../../domain/entities/role.entity';
@Injectable()
export class PrismaRoleRepository implements IRole {
constructor(private readonly prisma: PrismaService) {}
async getDefaultRole() {
const role = await this.prisma.role.findFirst({
where: { isDefault: true },
include: { permissions: true },
});
if (!role) return null;
return PrismaRoleMapper.toDomain(role);
}
async findById(roleId: string) {
const role = await this.prisma.role.findUnique({
where: { id: roleId },
include: { permissions: true },
});
if (!role) throw new Error('Role not found.');
return PrismaRoleMapper.toDomain(role);
}
async find(params: { page?: number; limit?: number; search?: string | null }) {
const page = params.page && params.page > 0 ? params.page : 1;
const limit = params.limit && params.limit > 0 ? params.limit : 10;
const where: any = {};
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: 'insensitive' } },
{ code: { contains: params.search, mode: 'insensitive' } },
{ description: { contains: params.search, mode: 'insensitive' } },
];
}
const [total, items] = await Promise.all([
this.prisma.role.count({ where }),
this.prisma.role.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' }, include: { permissions: true } }),
]);
return { data: items.map(i => PrismaRoleMapper.toDomain(i)!), total };
}
async create(role: RoleData) {
const base: any = {
id: role.id,
code: role.code,
name: role.name,
description: role.description,
isDefault: role.isDefault,
};
if (role.permissions && role.permissions.length > 0) {
base.permissions = {
create: role.permissions.map(p => ({ permission: { connect: { id: p.id } }, assignedBy: 'system' })),
};
}
const created = await this.prisma.role.create({ data: base, include: { permissions: true } });
return PrismaRoleMapper.toDomain(created)!;
}
async update(role: RoleData) {
const base: any = {
code: role.code,
name: role.name,
description: role.description,
isDefault: role.isDefault,
};
// sync permissions via transaction
const permIds = role.permissions ? role.permissions.map(p => p.id) : [];
await this.prisma.$transaction(async (prisma) => {
const currentPerms = await prisma.rolePermission.findMany({ where: { roleId: role.id } });
const currentIds = currentPerms.map(p => p.permissionId);
const toAdd = permIds.filter(id => !currentIds.includes(id));
const toRemove = currentIds.filter(id => !permIds.includes(id));
if (toRemove.length > 0) {
await prisma.rolePermission.deleteMany({ where: { roleId: role.id, permissionId: { in: toRemove } } });
}
if (toAdd.length > 0) {
await prisma.rolePermission.createMany({ data: toAdd.map(pid => ({ roleId: role.id, permissionId: pid, assignedBy: 'system' })) as any, skipDuplicates: true });
}
await prisma.role.update({ where: { id: role.id }, data: base });
});
const updated = await this.prisma.role.findUnique({ where: { id: role.id }, include: { permissions: true } });
return PrismaRoleMapper.toDomain(updated)!;
}
async delete(roleId: string) {
await this.prisma.role.delete({ where: { id: roleId } });
}
}
@@ -1,64 +1,257 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../shared/prisma.service';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
import { IUser } from '../../domain/repositories/user.interface';
import { UserData } from '../../domain/entities/user.entity';
import { PrismaUserMapper } from '../mappers/prisma-user.mapper';
@Injectable()
export class PrismaUserRepository implements UserRepository {
export class PrismaUserRepository implements IUser {
constructor(private readonly prisma: PrismaService) {}
//#region Create
async findById(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
});
async create(userData: UserData): Promise<UserData> {
const base = PrismaUserMapper.toPersistence(userData);
return PrismaUserMapper.toDomain(user);
}
const data: any = { ...base };
async findAll(): Promise<User[]> {
const users = await this.prisma.user.findMany({
where: { deleted_at: null },
orderBy: { created_at: 'desc' },
});
if (userData.roles && userData.roles.length > 0) {
data.roles = {
create: userData.roles.map(r => ({ role: { connect: { id: r.id } }, assignedBy: 'system' })),
};
}
return users.map(PrismaUserMapper.toDomain);
}
async create(user: User): Promise<User> {
const created = await this.prisma.user.create({
data: PrismaUserMapper.toPersistence(user),
data,
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
});
return PrismaUserMapper.toDomain(created)!;
}
async update(user: User): Promise<User> {
//#endregion
//#region Read
async existsById(userId: string): Promise<boolean> {
return (
(await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true },
})) !== null
);
}
async existByEmail(userEmail: string): Promise<boolean> {
return (
(await this.prisma.user.findUnique({
where: { email: userEmail },
select: { email: true },
})) !== null
);
}
async getById(userId: string): Promise<UserData> {
const userData = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
});
if (userData === null) throw new Error('User tidak ditemukan.');
return PrismaUserMapper.toDomain(userData);
}
async getByEmail(userEmail: string): Promise<UserData> {
const userData = await this.prisma.user.findUnique({
where: { email: userEmail },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
});
if (userData === null) throw new Error('User tidak ditemukan.');
return PrismaUserMapper.toDomain(userData);
}
async findByAuthentikId(authentikId: string): Promise<UserData | null> {
const userData = await this.prisma.user.findUnique({
where: { authentikId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
});
if (!userData) return null;
return PrismaUserMapper.toDomain(userData);
}
async find(params: {
page?: number;
limit?: number;
search?: string | null;
isActive?: boolean | null;
deleted?: boolean | null;
roleId?: string | null;
}): Promise<{ data: UserData[]; total: number }> {
const page = params.page && params.page > 0 ? params.page : 1;
const limit = params.limit && params.limit > 0 ? params.limit : 10;
const where: any = {};
if (params.search) {
where.OR = [
{ username: { contains: params.search, mode: 'insensitive' } },
{ email: { contains: params.search, mode: 'insensitive' } },
];
}
if (params.isActive !== undefined && params.isActive !== null) {
where.isActive = params.isActive;
}
if (params.deleted !== undefined && params.deleted !== null) {
if (params.deleted) where.deletedAt = { not: null };
else where.deletedAt = null;
}
if (params.roleId) {
where.roles = { some: { roleId: params.roleId } };
}
const [total, items] = await Promise.all([
this.prisma.user.count({ where }),
this.prisma.user.findMany({
where,
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
]);
return { data: items.map(i => PrismaUserMapper.toDomain(i)!), total };
}
async softDelete(userId: string): Promise<void> {
await this.prisma.user.update({ where: { id: userId }, data: { deletedAt: new Date(), isActive: false } });
}
async restore(userId: string): Promise<UserData> {
const updated = await this.prisma.user.update({
where: {
id: user.id,
where: { id: userId },
data: { deletedAt: null },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
data: PrismaUserMapper.toPersistence(user),
});
return PrismaUserMapper.toDomain(updated)!;
}
async delete(id: string) {
const user = await this.prisma.user.update({
where: { id },
data: { deleted_at: new Date() },
async enable(userId: string): Promise<UserData> {
const updated = await this.prisma.user.update({
where: { id: userId },
data: { isActive: true },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
});
return PrismaUserMapper.toDomain(user);
return PrismaUserMapper.toDomain(updated)!;
}
async existsByEmail(email: string, excludeId?: string | null) {
const where: any = { email };
if (excludeId) {
where.id = { not: excludeId };
}
async disable(userId: string): Promise<UserData> {
const updated = await this.prisma.user.update({
where: { id: userId },
data: { isActive: false },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
});
const user = await this.prisma.user.findFirst({ where });
return !!user;
return PrismaUserMapper.toDomain(updated)!;
}
async update(userData: UserData): Promise<UserData> {
const base = PrismaUserMapper.toPersistence(userData);
const data: any = { ...base };
// sync roles and permissions using transaction
const roleIds = userData.roles ? userData.roles.map(r => r.id) : [];
const permissionIds = userData.permissions ? userData.permissions.map(p => p.id) : [];
// perform transaction: delete removed relations, create missing ones, update user
await this.prisma.$transaction(async (prisma) => {
// current roles
const currentRoles = await prisma.userRole.findMany({ where: { userId: userData.id } });
const currentRoleIds = currentRoles.map(r => r.roleId);
const toAddRoles = roleIds.filter(id => !currentRoleIds.includes(id));
const toRemoveRoles = currentRoleIds.filter(id => !roleIds.includes(id));
if (toRemoveRoles.length > 0) {
await prisma.userRole.deleteMany({ where: { userId: userData.id, roleId: { in: toRemoveRoles } } });
}
if (toAddRoles.length > 0) {
await prisma.userRole.createMany({
data: toAddRoles.map(rid => ({ userId: userData.id, roleId: rid, assignedBy: 'system' })) as any,
skipDuplicates: true,
});
}
// permissions
const currentPerms = await prisma.userPermission.findMany({ where: { userId: userData.id } });
const currentPermIds = currentPerms.map(p => p.permissionId);
const toAddPerms = permissionIds.filter(id => !currentPermIds.includes(id));
const toRemovePerms = currentPermIds.filter(id => !permissionIds.includes(id));
if (toRemovePerms.length > 0) {
await prisma.userPermission.deleteMany({ where: { userId: userData.id, permissionId: { in: toRemovePerms } } });
}
if (toAddPerms.length > 0) {
await prisma.userPermission.createMany({
data: toAddPerms.map(pid => ({ userId: userData.id, permissionId: pid, assignedBy: 'system' })) as any,
skipDuplicates: true,
});
}
// update base user fields
await prisma.user.update({ where: { id: userData.id }, data });
});
const updated = await this.prisma.user.findUnique({
where: { id: userData.id },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
permissions: { include: { permission: true } },
},
});
return PrismaUserMapper.toDomain(updated)!;
}
//#endregion
}
@@ -0,0 +1,67 @@
import { Controller, Get, Post, Patch, Delete, Param, UseGuards, Body, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../core/auth/guards/jwt-auth.guard';
import { PermissionGuard } from '../../../authorization/presentation/guards/permission.guard';
import { CurrentUserGuard } from '../guards/current-user.guard';
import { Permissions } from '../../../authorization/presentation/decorators/permission.decorator';
import { PermissionType } from '../../../../common/constants/permission.constants';
import { GetPermissionsHandler } from '../../application/handlers/permission/get-permissions.handler';
import { GetPermissionHandler } from '../../application/handlers/permission/get-permission.handler';
import { CreatePermissionHandler } from '../../application/handlers/permission/create-permission.handler';
import { UpdatePermissionHandler } from '../../application/handlers/permission/update-permission.handler';
import { DeletePermissionHandler } from '../../application/handlers/permission/delete-permission.handler';
@ApiTags('Permissions')
@Controller('permissions')
@UseGuards(JwtAuthGuard, CurrentUserGuard, PermissionGuard)
export class PermissionsController {
constructor(
private readonly getPermissionsHandler: GetPermissionsHandler,
private readonly getPermissionHandler: GetPermissionHandler,
private readonly createPermissionHandler: CreatePermissionHandler,
private readonly updatePermissionHandler: UpdatePermissionHandler,
private readonly deletePermissionHandler: DeletePermissionHandler,
) {}
@Get()
@Permissions(PermissionType.PERMISSION_READ)
@ApiOperation({ summary: 'List permissions' })
async findAll(@Query() query: any) {
const res = await this.getPermissionsHandler.execute(query);
return { success: true, data: res.data, meta: { total: res.total } };
}
@Get(':id')
@Permissions(PermissionType.PERMISSION_READ)
@ApiOperation({ summary: 'Get permission' })
async findOne(@Param('id') id: string) {
const p = await this.getPermissionHandler.execute(id);
return { success: true, data: p, meta: {} };
}
@Post()
@Permissions(PermissionType.PERMISSION_CREATE)
@ApiOperation({ summary: 'Create permission' })
async create(@Body() body: any) {
const created = await this.createPermissionHandler.execute(body);
return { success: true, data: created, meta: {} };
}
@Patch(':id')
@Permissions(PermissionType.PERMISSION_UPDATE)
@ApiOperation({ summary: 'Update permission' })
async update(@Param('id') id: string, @Body() body: any) {
const updated = await this.updatePermissionHandler.execute(id, body);
return { success: true, data: updated, meta: {} };
}
@Delete(':id')
@Permissions(PermissionType.PERMISSION_DELETE)
@ApiOperation({ summary: 'Delete permission' })
async remove(@Param('id') id: string) {
await this.deletePermissionHandler.execute(id);
return { success: true, data: null, meta: {} };
}
}
@@ -0,0 +1,88 @@
import { Controller, Get, Post, Patch, Delete, Param, UseGuards, Body, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../core/auth/guards/jwt-auth.guard';
import { PermissionGuard } from '../../../authorization/presentation/guards/permission.guard';
import { CurrentUserGuard } from '../guards/current-user.guard';
import { Permissions } from '../../../authorization/presentation/decorators/permission.decorator';
import { PermissionType } from '../../../../common/constants/permission.constants';
import { GetRolesHandler } from '../../application/handlers/role/get-roles.handler';
import { GetRoleHandler } from '../../application/handlers/role/get-role.handler';
import { CreateRoleHandler } from '../../application/handlers/role/create-role.handler';
import { UpdateRoleHandler } from '../../application/handlers/role/update-role.handler';
import { DeleteRoleHandler } from '../../application/handlers/role/delete-role.handler';
import { RoleAssignPermissionHandler } from '../../application/handlers/role/assign-permission.handler';
import { RoleRemovePermissionHandler } from '../../application/handlers/role/remove-permission.handler';
@ApiTags('Roles')
@Controller('roles')
@UseGuards(JwtAuthGuard, CurrentUserGuard, PermissionGuard)
export class RolesController {
constructor(
private readonly getRolesHandler: GetRolesHandler,
private readonly getRoleHandler: GetRoleHandler,
private readonly createRoleHandler: CreateRoleHandler,
private readonly updateRoleHandler: UpdateRoleHandler,
private readonly deleteRoleHandler: DeleteRoleHandler,
private readonly roleAssignPermissionHandler: RoleAssignPermissionHandler,
private readonly roleRemovePermissionHandler: RoleRemovePermissionHandler,
) {}
@Get()
@Permissions(PermissionType.ROLE_READ)
@ApiOperation({ summary: 'List roles' })
async findAll(@Query() query: any) {
const res = await this.getRolesHandler.execute(query);
return { success: true, data: res.data, meta: { total: res.total } };
}
@Get(':id')
@Permissions(PermissionType.ROLE_READ)
@ApiOperation({ summary: 'Get role' })
async findOne(@Param('id') id: string) {
const role = await this.getRoleHandler.execute(id);
return { success: true, data: role, meta: {} };
}
@Post()
@Permissions(PermissionType.ROLE_CREATE)
@ApiOperation({ summary: 'Create role' })
async create(@Body() body: any) {
const created = await this.createRoleHandler.execute(body);
return { success: true, data: created, meta: {} };
}
@Patch(':id')
@Permissions(PermissionType.ROLE_UPDATE)
@ApiOperation({ summary: 'Update role' })
async update(@Param('id') id: string, @Body() body: any) {
const updated = await this.updateRoleHandler.execute(id, body);
return { success: true, data: updated, meta: {} };
}
@Delete(':id')
@Permissions(PermissionType.ROLE_DELETE)
@ApiOperation({ summary: 'Delete role' })
async remove(@Param('id') id: string) {
await this.deleteRoleHandler.execute(id);
return { success: true, data: null, meta: {} };
}
@Post(':id/permissions')
@Permissions(PermissionType.ROLE_UPDATE)
@ApiOperation({ summary: 'Assign permission to role' })
async assignPermission(@Param('id') id: string, @Body() body: any) {
const permissionId = body.permissionId;
const updated = await this.roleAssignPermissionHandler.execute(id, permissionId);
return { success: true, data: updated, meta: {} };
}
@Delete(':id/permissions/:permissionId')
@Permissions(PermissionType.ROLE_UPDATE)
@ApiOperation({ summary: 'Remove permission from role' })
async removePermission(@Param('id') id: string, @Param('permissionId') permissionId: string) {
const updated = await this.roleRemovePermissionHandler.execute(id, permissionId);
return { success: true, data: updated, meta: {} };
}
}
@@ -1,101 +1,134 @@
import {
Controller,
Get,
Post,
Delete,
Param,
UseGuards,
Body,
Patch
} from '@nestjs/common';
import { Controller, Get, Param, UseGuards, Query, Patch, Delete, Post, Body, Req } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../core/auth/guards/jwt-auth.guard';
import { PermissionGuard } from '../../../authorization/presentation/guards/permission.guard';
import { CurrentUserGuard } from '../guards/current-user.guard';
import { Permissions } from '../../../authorization/presentation/decorators/permission.decorator';
import { PermissionType } from '../../../../common/constants/permission.constants';
import { FindUserHandler } from '../../application/handlers/find-user.handler';
import { FindUsersHandler } from '../../application/handlers/find-users.handler';
import { CreateUserHandler } from '../../application/handlers/create-user.handler';
import { UpdateUserHandler } from '../../application/handlers/update-user.handler';
import { DeleteUserHandler } from '../../application/handlers/delete-user.handler';
import { CreateUserDto } from '../dto/create-user.dto';
import { UpdateUserDto } from '../dto/update-user.dto';
import { GetUsersHandler } from '../../application/handlers/user/get-users.handler';
import { GetUserHandler } from '../../application/handlers/user/get-user.handler';
import { GetCurrentUserHandler } from '../../application/handlers/user/get-current-user.handler';
import { EnableUserHandler } from '../../application/handlers/user/enable-user.handler';
import { DisableUserHandler } from '../../application/handlers/user/disable-user.handler';
import { DeleteUserHandler } from '../../application/handlers/user/delete-user.handler';
import { RestoreUserHandler } from '../../application/handlers/user/restore-user.handler';
import { AssignRoleHandler } from '../../application/handlers/user/assign-role.handler';
import { RemoveRoleHandler } from '../../application/handlers/user/remove-role.handler';
import { AssignPermissionHandler } from '../../application/handlers/user/assign-permission.handler';
import { RemovePermissionHandler } from '../../application/handlers/user/remove-permission.handler';
@ApiTags('Users')
@Controller('api/v1/users')
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller('users')
@UseGuards(JwtAuthGuard, CurrentUserGuard, PermissionGuard)
export class UsersController {
constructor(
private readonly createUserHandler: CreateUserHandler,
private readonly findUserHandler: FindUserHandler,
private readonly findUsersHandler: FindUsersHandler,
private readonly updateUserHandler: UpdateUserHandler,
private readonly getUsersHandler: GetUsersHandler,
private readonly getUserHandler: GetUserHandler,
private readonly getCurrentUserHandler: GetCurrentUserHandler,
private readonly enableUserHandler: EnableUserHandler,
private readonly disableUserHandler: DisableUserHandler,
private readonly deleteUserHandler: DeleteUserHandler,
private readonly restoreUserHandler: RestoreUserHandler,
private readonly assignRoleHandler: AssignRoleHandler,
private readonly removeRoleHandler: RemoveRoleHandler,
private readonly assignPermissionHandler: AssignPermissionHandler,
private readonly removePermissionHandler: RemovePermissionHandler,
) {}
@Post()
@Permissions('USER_CREATE')
@ApiOperation({ summary: 'Create user' })
async create(@Body() dto: CreateUserDto) {
const data = await this.createUserHandler.execute(dto);
@Get()
@Permissions(PermissionType.USER_READ)
@ApiOperation({ summary: 'List users' })
async findAll(@Query() query: any) {
const res = await this.getUsersHandler.execute(query);
return {
success: true,
data,
meta: {},
}
data: res.data.map(u => (u as any).toResponse ? (u as any).toResponse() : u),
meta: { total: res.total },
};
}
@Get('me')
@ApiOperation({ summary: 'Get current user' })
async me(@Req() req: any) {
const user = await this.getCurrentUserHandler.execute(req.currentUser);
return { success: true, data: (user as any).toResponse ? (user as any).toResponse() : user, meta: {} };
}
@Get(':id')
@Permissions('USER_READ')
@Permissions(PermissionType.USER_READ)
@ApiOperation({ summary: 'Get user by id' })
async getById(@Param('id') id: string) {
const data = await this.findUserHandler.execute(id);
return {
success: true,
data,
meta: {},
};
async findOne(@Param('id') id: string) {
const user = await this.getUserHandler.execute(id);
return { success: true, data: (user as any).toResponse ? (user as any).toResponse() : user, meta: {} };
}
@Get()
@Permissions('USER_READ_ADMIN')
@ApiOperation({ summary: 'Get All User' })
async getAll() {
const data = await this.findUsersHandler.execute();
return {
success: true,
data,
meta: {},
};
@Patch(':id/enable')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Enable user' })
async enable(@Param('id') id: string) {
const updated = await this.enableUserHandler.execute(id);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Patch(':id')
@Permissions('USER_UPDATE')
@ApiOperation({ summary: 'Update User' })
async update(@Param('id') id:string, @Body() dto:UpdateUserDto) {
const data = await this.updateUserHandler.execute(id, dto);
return {
success: true,
data,
meta: {},
}
@Patch(':id/disable')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Disable user' })
async disable(@Param('id') id: string) {
const updated = await this.disableUserHandler.execute(id);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Delete(':id')
@Permissions('USER_DELETE')
@ApiOperation({ summary: 'Delete user by id' })
async delete(@Param('id') id: string) {
@Permissions(PermissionType.USER_DELETE)
@ApiOperation({ summary: 'Soft delete user' })
async remove(@Param('id') id: string) {
await this.deleteUserHandler.execute(id);
return {
success: true,
data: null,
meta: {},
};
return { success: true, data: null, meta: {} };
}
}
@Post(':id/restore')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Restore user' })
async restore(@Param('id') id: string) {
const restored = await this.restoreUserHandler.execute(id);
return { success: true, data: (restored as any).toResponse ? (restored as any).toResponse() : restored, meta: {} };
}
@Post(':id/roles')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Assign role to user' })
async assignRole(@Param('id') id: string, @Body() body: any) {
const roleId = body.roleId;
const updated = await this.assignRoleHandler.execute(id, roleId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Delete(':id/roles/:roleId')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Remove role from user' })
async removeRole(@Param('id') id: string, @Param('roleId') roleId: string) {
const updated = await this.removeRoleHandler.execute(id, roleId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Post(':id/permissions')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Assign permission to user' })
async assignPermission(@Param('id') id: string, @Body() body: any) {
const permissionId = body.permissionId;
const updated = await this.assignPermissionHandler.execute(id, permissionId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
@Delete(':id/permissions/:permissionId')
@Permissions(PermissionType.USER_UPDATE)
@ApiOperation({ summary: 'Remove permission from user' })
async removePermission(@Param('id') id: string, @Param('permissionId') permissionId: string) {
const updated = await this.removePermissionHandler.execute(id, permissionId);
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
}
}
@@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class CreatePermissionDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
code!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
description?: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class CreateRoleDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
code!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ required: false })
@IsOptional()
isDefault?: boolean;
}

Some files were not shown because too many files have changed in this diff Show More