mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
IAM, package, luggage, app health, rate limit, and more
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
|
||||
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Patch, Delete, Param, Request, Query, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PassengerAuthService } from './passenger-auth.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
@@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||
|
||||
@@ -66,4 +68,54 @@ export class AuthController {
|
||||
}
|
||||
|
||||
// TODO: admin user management endpoints — implement when admin module is ready
|
||||
|
||||
@Get('users')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all users (admin)' })
|
||||
listUsers(
|
||||
@Query('search') search?: string,
|
||||
@Query('role') role?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.passengerAuthService.listUsers({
|
||||
search, role, status,
|
||||
page: page ? +page : 1,
|
||||
pageSize: pageSize ? +pageSize : 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create user (admin)' })
|
||||
createUser(@Body() body: any) {
|
||||
return this.passengerAuthService.createUser(body);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update user (admin)' })
|
||||
updateUser(@Param('id') id: string, @Body() body: any) {
|
||||
return this.passengerAuthService.updateUser(id, body);
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete user (admin)' })
|
||||
deleteUser(@Param('id') id: string) {
|
||||
return this.passengerAuthService.deleteUser(id);
|
||||
}
|
||||
|
||||
@Post('users/:id/reset-password')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reset user password (admin)' })
|
||||
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
|
||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,209 @@ export class PassengerAuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async listUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
|
||||
const page = filters.page ?? 1;
|
||||
const pageSize = filters.pageSize ?? 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const params: any[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (filters.search) {
|
||||
params.push(`%${filters.search}%`);
|
||||
conditions.push(`(u.email ILIKE $${params.length} OR (u.name->>'en') ILIKE $${params.length})`);
|
||||
}
|
||||
if (filters.role) {
|
||||
params.push(`%${filters.role}%`);
|
||||
conditions.push(`r.key ILIKE $${params.length}`);
|
||||
}
|
||||
if (filters.status) {
|
||||
const active = filters.status === 'ACTIVE';
|
||||
params.push(active);
|
||||
conditions.push(`u.is_active = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const baseQuery = `
|
||||
FROM iam.users u
|
||||
LEFT JOIN iam.user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN iam.roles r ON r.id = ur.role_id
|
||||
${where}
|
||||
`;
|
||||
|
||||
const countParams = [...params];
|
||||
const [rows, countRows] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT DISTINCT u.id, u.email, u.name, u.phone_number, u.is_active, u.status, u.created_at,
|
||||
r.key as role_key, r.name as role_name
|
||||
${baseQuery}
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
[...params, pageSize, offset],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(DISTINCT u.id) as count ${baseQuery}`,
|
||||
countParams,
|
||||
),
|
||||
]);
|
||||
|
||||
const items = rows.map((u: any) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
fullName: u.name?.en ?? u.name?.am ?? '',
|
||||
role: u.role_key ?? '',
|
||||
status: u.is_active ? 'ACTIVE' : 'INACTIVE',
|
||||
lastLogin: u.metadata?.lastLogin ?? null,
|
||||
createdAt: u.created_at,
|
||||
}));
|
||||
|
||||
return { items, total: parseInt(countRows[0]?.count ?? '0'), page, pageSize };
|
||||
}
|
||||
|
||||
async createUser(data: { email: string; fullName: string; role: string; password: string; status?: string }) {
|
||||
const existing = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||
[data.email],
|
||||
);
|
||||
if (existing.length) throw new ConflictException('Email already registered');
|
||||
|
||||
// Derive username from email local-part; ensure uniqueness by appending a short suffix if taken
|
||||
const baseUsername = data.email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, '');
|
||||
const taken = await this.dataSource.query<{ username: string }[]>(
|
||||
`SELECT username FROM iam.users WHERE username LIKE $1 LIMIT 10`,
|
||||
[`${baseUsername}%`],
|
||||
);
|
||||
const takenSet = new Set(taken.map((r) => r.username));
|
||||
let username = baseUsername;
|
||||
let suffix = 1;
|
||||
while (takenSet.has(username)) {
|
||||
username = `${baseUsername}${suffix++}`;
|
||||
}
|
||||
|
||||
// Hash with argon2 — same algorithm the IAM login uses (verifyPassword in auth.service.js)
|
||||
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.users (email, username, name, user_type, status, is_active)
|
||||
VALUES ($1, $2, $3::jsonb, 'employee', $4, $5)`,
|
||||
[
|
||||
data.email,
|
||||
username,
|
||||
JSON.stringify({ en: data.fullName, am: data.fullName }),
|
||||
data.status === 'INACTIVE' ? 'pending' : 'accepted',
|
||||
data.status !== 'INACTIVE',
|
||||
],
|
||||
);
|
||||
|
||||
// Insert credential with correct column `password` and is_active = true
|
||||
// so the IAM login SQL (find-user-for-login.sql) can find and verify it
|
||||
const newUser = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [data.email],
|
||||
);
|
||||
if (newUser.length) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
||||
[newUser[0].id],
|
||||
);
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
||||
[newUser[0].id, passwordHash],
|
||||
);
|
||||
}
|
||||
|
||||
// Assign the selected role in iam.user_roles
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, email, name, is_active, created_at FROM iam.users WHERE email = $1 LIMIT 1`,
|
||||
[data.email],
|
||||
);
|
||||
const u = rows[0];
|
||||
|
||||
if (data.role && u) {
|
||||
try {
|
||||
const roleRows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`,
|
||||
[data.role],
|
||||
);
|
||||
if (roleRows.length) {
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_roles (user_id, role_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[u.id, roleRows[0].id],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// non-fatal — role assignment failure should not block user creation
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: u.id, email: u.email,
|
||||
fullName: data.fullName, role: data.role,
|
||||
status: u.is_active ? 'ACTIVE' : 'INACTIVE',
|
||||
createdAt: u.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(id: string, data: { fullName?: string; role?: string; status?: string }) {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, name, is_active FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
if (!rows.length) throw new ConflictException('User not found');
|
||||
const existing = rows[0];
|
||||
const name = data.fullName ? { en: data.fullName, am: data.fullName } : existing.name;
|
||||
const isActive = data.status ? data.status === 'ACTIVE' : existing.is_active;
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users SET name = $1::jsonb, is_active = $2, updated_at = NOW() WHERE id = $3`,
|
||||
[JSON.stringify(name), isActive, id],
|
||||
);
|
||||
|
||||
// Update role: remove existing user_roles then assign the new one
|
||||
if (data.role) {
|
||||
try {
|
||||
const roleRows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`,
|
||||
[data.role],
|
||||
);
|
||||
if (roleRows.length) {
|
||||
await this.dataSource.query(`DELETE FROM iam.user_roles WHERE user_id = $1`, [id]);
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[id, roleRows[0].id],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
return { id, fullName: (name as any)?.en, role: data.role, status: isActive ? 'ACTIVE' : 'INACTIVE' };
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [id]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async resetUserPassword(id: string, tempPassword: string) {
|
||||
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const passwordHash = await hashPassword(tempPassword);
|
||||
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
||||
[id],
|
||||
);
|
||||
// Insert new active credential
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
||||
[id, passwordHash],
|
||||
);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
private async compensateIamSignup(email: string): Promise<void> {
|
||||
try {
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
|
||||
Reference in New Issue
Block a user