BFF fixes: - Fix pino-http type import by using Params from nestjs-pino - Use Prisma-generated AuditAction enum instead of local duplicate - Add null check for sfAccountId in mapping mapper Portal mobile UX improvements: - DataTable: Add responsive card view for mobile with stacked layout - Header: Increase touch targets to 44px minimum, better spacing - PageLayout: Optimize padding and make breadcrumbs scrollable - PublicShell: Add iOS safe area support, slide animation, language switcher and sign-in button visible in mobile header Also removes "Trusted by Leading Companies" section from AboutUsView. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
169 lines
4.7 KiB
TypeScript
169 lines
4.7 KiB
TypeScript
import { Injectable, Inject } from "@nestjs/common";
|
|
import { Prisma, AuditAction } from "@prisma/client";
|
|
import { PrismaService } from "../database/prisma.service.js";
|
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
import { Logger } from "nestjs-pino";
|
|
import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js";
|
|
|
|
// Re-export AuditAction from Prisma for consumers
|
|
export { AuditAction } from "@prisma/client";
|
|
|
|
export interface AuditLogData {
|
|
userId?: string | undefined;
|
|
action: AuditAction;
|
|
resource?: string | undefined;
|
|
details?: Record<string, unknown> | string | number | boolean | null | undefined;
|
|
ipAddress?: string | undefined;
|
|
userAgent?: string | undefined;
|
|
success?: boolean | undefined;
|
|
error?: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Minimal request shape for audit logging.
|
|
* Compatible with Express Request but only requires the fields needed for IP/UA extraction.
|
|
* Must be compatible with RequestContextLike from request-context.util.ts.
|
|
*/
|
|
export type AuditRequest = {
|
|
headers?: Record<string, string | string[] | undefined> | undefined;
|
|
ip?: string | undefined;
|
|
connection?: { remoteAddress?: string | undefined } | undefined;
|
|
socket?: { remoteAddress?: string | undefined } | undefined;
|
|
};
|
|
|
|
@Injectable()
|
|
export class AuditService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {}
|
|
|
|
async log(data: AuditLogData): Promise<void> {
|
|
try {
|
|
const createData: Parameters<typeof this.prisma.auditLog.create>[0]["data"] = {
|
|
action: data.action,
|
|
success: data.success ?? true,
|
|
};
|
|
|
|
if (data.userId !== undefined) createData.userId = data.userId;
|
|
if (data.resource !== undefined) createData.resource = data.resource;
|
|
if (data.ipAddress !== undefined) createData.ipAddress = data.ipAddress;
|
|
if (data.userAgent !== undefined) createData.userAgent = data.userAgent;
|
|
if (data.error !== undefined) createData.error = data.error;
|
|
if (data.details !== undefined) {
|
|
createData.details =
|
|
data.details === null
|
|
? Prisma.JsonNull
|
|
: (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue);
|
|
}
|
|
|
|
await this.prisma.auditLog.create({ data: createData });
|
|
} catch (error) {
|
|
this.logger.error("Audit logging failed", {
|
|
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
|
message: extractErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
async logAuthEvent(
|
|
action: AuditAction,
|
|
userId?: string,
|
|
details?: Record<string, unknown> | string | number | boolean | null,
|
|
request?: AuditRequest,
|
|
success: boolean = true,
|
|
error?: string
|
|
): Promise<void> {
|
|
const ipAddress = extractClientIp(request);
|
|
const userAgent = extractUserAgent(request);
|
|
|
|
await this.log({
|
|
userId,
|
|
action,
|
|
resource: "auth",
|
|
details,
|
|
ipAddress,
|
|
userAgent,
|
|
success,
|
|
error,
|
|
});
|
|
}
|
|
|
|
async getAuditLogs({
|
|
page,
|
|
limit,
|
|
action,
|
|
userId,
|
|
}: {
|
|
page: number;
|
|
limit: number;
|
|
action?: AuditAction;
|
|
userId?: string;
|
|
}) {
|
|
const skip = (page - 1) * limit;
|
|
const where: Prisma.AuditLogWhereInput = {};
|
|
if (action) where.action = action;
|
|
if (userId) where.userId = userId;
|
|
|
|
const [logs, total] = await Promise.all([
|
|
this.prisma.auditLog.findMany({
|
|
where,
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
this.prisma.auditLog.count({ where }),
|
|
]);
|
|
|
|
return { logs, total };
|
|
}
|
|
|
|
async getSecurityStats() {
|
|
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
|
|
|
const [totalUsers, lockedAccounts, failedLoginsToday, successfulLoginsToday] =
|
|
await Promise.all([
|
|
this.prisma.user.count(),
|
|
this.prisma.user.count({
|
|
where: {
|
|
lockedUntil: {
|
|
gt: new Date(),
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.auditLog.count({
|
|
where: {
|
|
action: AuditAction.LOGIN_FAILED,
|
|
createdAt: {
|
|
gte: today,
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.auditLog.count({
|
|
where: {
|
|
action: AuditAction.LOGIN_SUCCESS,
|
|
createdAt: {
|
|
gte: today,
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
totalUsers,
|
|
lockedAccounts,
|
|
failedLoginsToday,
|
|
successfulLoginsToday,
|
|
securityEventsToday: failedLoginsToday + successfulLoginsToday,
|
|
};
|
|
}
|
|
}
|