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:
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Login DTO removed. Password grant has been removed in favor of Authorization Code + PKCE flow.
|
||||
// Formerly contained username/password properties.
|
||||
@@ -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 +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 +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>;
|
||||
}
|
||||
@@ -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 +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;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsString, Length, IsOptional, IsInt, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ example: 'John Doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
@@ -16,6 +16,15 @@ export class CreateUserDto {
|
||||
@Length(8, 128)
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
metadata?: Record<string, any>;
|
||||
@ApiPropertyOptional({ example: 10737418240 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
storageQuota?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
storageUsed?: number;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreatePermissionDto } from './create-permission.dto';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdatePermissionDto extends PartialType(CreatePermissionDto) {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateRoleDto } from './create-role.dto';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateRoleDto extends PartialType(CreateRoleDto) {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsEmail, Length } from 'class-validator';
|
||||
import { IsOptional, IsString, IsEmail, Length, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
@ApiPropertyOptional()
|
||||
@@ -22,4 +22,16 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional({ example: 10737418240 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
storageQuota?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
storageUsed?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { SyncIdentityHandler } from '../../application/handlers/user/sync-identity.handler';
|
||||
import { IdentityData } from '../../../../core/auth/interfaces/identity-data';
|
||||
|
||||
@Injectable()
|
||||
export class CurrentUserGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly syncIdentityHandler: SyncIdentityHandler,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest() as any;
|
||||
|
||||
const identity = request.identity as IdentityData | undefined;
|
||||
|
||||
if (!identity) {
|
||||
throw new UnauthorizedException('Identity is missing.');
|
||||
}
|
||||
|
||||
const user = await this.syncIdentityHandler.execute(identity);
|
||||
|
||||
// attach domain user as currentUser
|
||||
request.currentUser = user;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user