IAM, package, luggage, app health, rate limit, and more

This commit is contained in:
Stephanos A
2026-06-24 14:02:51 +03:00
parent e10b013b62
commit 86760933e8
63 changed files with 3475 additions and 280 deletions

View File

@@ -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);
}
}

View File

@@ -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 }[]>(