login time
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@ SMTP_HOST="sandbox.smtp.mailtrap.io"
|
|||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER="0a508c201b8611"
|
SMTP_USER="0a508c201b8611"
|
||||||
SMTP_PASS="783cf1747d831a"
|
SMTP_PASS="783cf1747d831a"
|
||||||
SMTP_FROM="Project Athena <no-reply@yourdomain.com>"
|
SMTP_FROM="Bird Eye View <no-reply@yourdomain.com>"
|
||||||
|
|||||||
Generated
+1571
-210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LoginActivityStatus" AS ENUM ('SUCCESS', 'FAILURE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LoginFailureReason" AS ENUM ('MISSING_CREDENTIALS', 'INVALID_CREDENTIALS', 'WRONG_PASSWORD', 'ACCOUNT_LOCKED', 'NOT_ACTIVATED', 'NOT_ALLOWED', 'UNKNOWN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "lastLoginAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LoginActivity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"activity" TEXT NOT NULL,
|
||||||
|
"status" "LoginActivityStatus" NOT NULL DEFAULT 'SUCCESS',
|
||||||
|
"failureReason" "LoginFailureReason",
|
||||||
|
|
||||||
|
CONSTRAINT "LoginActivity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LoginActivity_userId_createdAt_idx" ON "LoginActivity"("userId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LoginActivity_createdAt_idx" ON "LoginActivity"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LoginActivity_activity_idx" ON "LoginActivity"("activity");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LoginActivity" ADD CONSTRAINT "LoginActivity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -2,6 +2,20 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LoginActivity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
activity String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
status LoginActivityStatus @default(SUCCESS)
|
||||||
|
failureReason LoginFailureReason?
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([activity])
|
||||||
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
@@ -30,6 +44,8 @@ model User {
|
|||||||
teamMembers User[] @relation("ManagerMembers")
|
teamMembers User[] @relation("ManagerMembers")
|
||||||
assignedTodos Todo[] @relation("AssignedTodos")
|
assignedTodos Todo[] @relation("AssignedTodos")
|
||||||
securityType SecurityType @default(BASIC)
|
securityType SecurityType @default(BASIC)
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
loginActivities LoginActivity[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Todo {
|
model Todo {
|
||||||
@@ -100,3 +116,18 @@ enum SecurityType {
|
|||||||
ASSISTANCE
|
ASSISTANCE
|
||||||
MAXIMUM
|
MAXIMUM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LoginActivityStatus {
|
||||||
|
SUCCESS
|
||||||
|
FAILURE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoginFailureReason {
|
||||||
|
MISSING_CREDENTIALS
|
||||||
|
INVALID_CREDENTIALS
|
||||||
|
WRONG_PASSWORD
|
||||||
|
ACCOUNT_LOCKED
|
||||||
|
NOT_ACTIVATED
|
||||||
|
NOT_ALLOWED
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -12,12 +12,13 @@ const port = process.env.PORT || 5000;
|
|||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: true,
|
origin: true,
|
||||||
methods: ["GET", "POST", "PUT", "DELETE"],
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allowedHeaders: ["Content-Type", "Authorization"],
|
allowedHeaders: ["Content-Type", "Authorization"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
app.use("/api/auth", authRoutes);
|
app.use("/api/auth", authRoutes);
|
||||||
|
|||||||
+4
-4
@@ -25,9 +25,9 @@ export const getTransporter = () => {
|
|||||||
|
|
||||||
export const sendResetPasswordEmail = async (to: string, link: string) => {
|
export const sendResetPasswordEmail = async (to: string, link: string) => {
|
||||||
const t = getTransporter();
|
const t = getTransporter();
|
||||||
const subject = "Reset your Project Athena password";
|
const subject = "Reset your Bird Eye View password";
|
||||||
const html = `
|
const html = `
|
||||||
<p>You requested to reset your Project Athena password.</p>
|
<p>You requested to reset your Bird Eye View password.</p>
|
||||||
<p>Click the link below to set a new password. If you didn't request this, you can ignore this email.</p>
|
<p>Click the link below to set a new password. If you didn't request this, you can ignore this email.</p>
|
||||||
<p><a href="${link}">Reset Password</a></p>
|
<p><a href="${link}">Reset Password</a></p>
|
||||||
<p>This link will expire shortly for security reasons.</p>
|
<p>This link will expire shortly for security reasons.</p>
|
||||||
@@ -38,9 +38,9 @@ export const sendResetPasswordEmail = async (to: string, link: string) => {
|
|||||||
|
|
||||||
export const sendInvitationEmail = async (to: string, link: string) => {
|
export const sendInvitationEmail = async (to: string, link: string) => {
|
||||||
const t = getTransporter();
|
const t = getTransporter();
|
||||||
const subject = "You're invited to Project Athena";
|
const subject = "You're invited to Bird Eye View";
|
||||||
const html = `
|
const html = `
|
||||||
<p>You have been invited to join Project Athena.</p>
|
<p>You have been invited to join Bird Eye View.</p>
|
||||||
<p>Click the link below to set your password and activate your account.</p>
|
<p>Click the link below to set your password and activate your account.</p>
|
||||||
<p><a href="${link}">Accept Invitation</a></p>
|
<p><a href="${link}">Accept Invitation</a></p>
|
||||||
<p>This link will expire shortly for security reasons.</p>
|
<p>This link will expire shortly for security reasons.</p>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import prisma from "../prismaClient";
|
||||||
|
|
||||||
|
export type LoginStatus = "SUCCESS" | "FAILURE";
|
||||||
|
export type FailureReason =
|
||||||
|
| "MISSING_CREDENTIALS"
|
||||||
|
| "INVALID_CREDENTIALS"
|
||||||
|
| "WRONG_PASSWORD"
|
||||||
|
| "ACCOUNT_LOCKED"
|
||||||
|
| "NOT_ACTIVATED"
|
||||||
|
| "NOT_ALLOWED"
|
||||||
|
| "UNKNOWN"
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export async function upsertLoginActivity(
|
||||||
|
userId: string,
|
||||||
|
activity: string,
|
||||||
|
status: LoginStatus,
|
||||||
|
failureReason: FailureReason
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Deduplicate identical events in a short window to avoid accidental duplicates
|
||||||
|
const since = new Date(Date.now() - 2000);
|
||||||
|
const recent = await prisma.loginActivity.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
activity,
|
||||||
|
status,
|
||||||
|
failureReason: failureReason ?? null,
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
if (recent) return;
|
||||||
|
|
||||||
|
await prisma.loginActivity.create({
|
||||||
|
data: { userId, activity, status, failureReason } as any,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn("loginActivity upsert failed", {
|
||||||
|
userId,
|
||||||
|
activity,
|
||||||
|
status,
|
||||||
|
failureReason,
|
||||||
|
error: e?.message || String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ export class AuthController {
|
|||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const code = typeof e.code === "number" ? e.code : 500;
|
const code = typeof e.code === "number" ? e.code : 500;
|
||||||
return handleError(res, e.message || "Login failed", e, code);
|
const msg = code >= 500 ? "Login failed" : e.message || "Login failed";
|
||||||
|
return handleError(res, msg, e, code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
consumePasswordResetToken,
|
consumePasswordResetToken,
|
||||||
} from "../../lib/passwordReset";
|
} from "../../lib/passwordReset";
|
||||||
import { sendResetPasswordEmail } from "../../lib/email";
|
import { sendResetPasswordEmail } from "../../lib/email";
|
||||||
|
import { upsertLoginActivity } from "../../lib/loginActivity";
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
async registerDisabled() {
|
async registerDisabled() {
|
||||||
@@ -20,6 +21,17 @@ export class AuthService {
|
|||||||
|
|
||||||
async login(email?: string, password?: string) {
|
async login(email?: string, password?: string) {
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
|
if (email) {
|
||||||
|
const found = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (found && found.role !== Role.USER) {
|
||||||
|
await upsertLoginActivity(
|
||||||
|
found.id,
|
||||||
|
"Login failed",
|
||||||
|
"FAILURE",
|
||||||
|
"MISSING_CREDENTIALS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const error: any = new Error("Missing email or password");
|
const error: any = new Error("Missing email or password");
|
||||||
error.code = 400;
|
error.code = 400;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -28,12 +40,26 @@ export class AuthService {
|
|||||||
const user = await prisma.user.findUnique({ where: { email } });
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
if (!user || user.role === Role.USER) {
|
if (!user || user.role === Role.USER) {
|
||||||
|
if (user && user.role === Role.USER) {
|
||||||
|
await upsertLoginActivity(
|
||||||
|
user.id,
|
||||||
|
"Login failed",
|
||||||
|
"FAILURE",
|
||||||
|
"NOT_ALLOWED"
|
||||||
|
);
|
||||||
|
}
|
||||||
const error: any = new Error("Invalid credentials");
|
const error: any = new Error("Invalid credentials");
|
||||||
error.code = 401;
|
error.code = 401;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isActivated) {
|
if (!user.isActivated) {
|
||||||
|
await upsertLoginActivity(
|
||||||
|
user.id,
|
||||||
|
"Login failed",
|
||||||
|
"FAILURE",
|
||||||
|
"NOT_ACTIVATED"
|
||||||
|
);
|
||||||
const error: any = new Error(
|
const error: any = new Error(
|
||||||
"Account is not activated. Please use your invitation link to set a password."
|
"Account is not activated. Please use your invitation link to set a password."
|
||||||
);
|
);
|
||||||
@@ -42,6 +68,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.isLocked) {
|
if (user.isLocked) {
|
||||||
|
await upsertLoginActivity(
|
||||||
|
user.id,
|
||||||
|
"Login failed",
|
||||||
|
"FAILURE",
|
||||||
|
"ACCOUNT_LOCKED"
|
||||||
|
);
|
||||||
const error: any = new Error(
|
const error: any = new Error(
|
||||||
"Account is locked due to suspicious activity. Please contact an administrator."
|
"Account is locked due to suspicious activity. Please contact an administrator."
|
||||||
);
|
);
|
||||||
@@ -51,6 +83,12 @@ export class AuthService {
|
|||||||
|
|
||||||
const ok = await bcrypt.compare(password, user.password);
|
const ok = await bcrypt.compare(password, user.password);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
await upsertLoginActivity(
|
||||||
|
user.id,
|
||||||
|
"Login failed",
|
||||||
|
"FAILURE",
|
||||||
|
"WRONG_PASSWORD"
|
||||||
|
);
|
||||||
const error: any = new Error("Invalid credentials");
|
const error: any = new Error("Invalid credentials");
|
||||||
error.code = 401;
|
error.code = 401;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -61,7 +99,18 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
});
|
});
|
||||||
return { token, user };
|
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: now },
|
||||||
|
});
|
||||||
|
} catch (_e) {}
|
||||||
|
await upsertLoginActivity(user.id, "Login success", "SUCCESS", null);
|
||||||
|
|
||||||
|
const updated = await prisma.user.findUnique({ where: { id: user.id } });
|
||||||
|
return { token, user: updated! };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentUser(userId: string) {
|
async getCurrentUser(userId: string) {
|
||||||
@@ -114,7 +163,7 @@ export class AuthService {
|
|||||||
data: { usedAt: new Date() },
|
data: { usedAt: new Date() },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
await upsertLoginActivity(user.id, "Password reset", "SUCCESS", null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { Role, RiskStatus } from "@prisma/client";
|
import { Role, RiskStatus } from "@prisma/client";
|
||||||
|
|
||||||
import { AuthenticatedRequest } from "../../middleware/auth";
|
import { AuthenticatedRequest } from "../../middleware/auth";
|
||||||
import { userService, CreateUserData, UpdateUserData } from "./users.service";
|
import { userService, CreateUserData, UpdateUserData } from "./users.service";
|
||||||
import { UserListParams } from "./users.repository";
|
import { UserListParams } from "./users.repository";
|
||||||
@@ -20,10 +21,13 @@ export class UserController {
|
|||||||
.map((r) => r.trim())
|
.map((r) => r.trim())
|
||||||
.filter((r) => !!r),
|
.filter((r) => !!r),
|
||||||
sortBy: (req.query.sortBy as string) || "",
|
sortBy: (req.query.sortBy as string) || "",
|
||||||
sortDir: ((req.query.sortDir as string) || "asc").toLowerCase() === "desc"
|
sortDir:
|
||||||
|
((req.query.sortDir as string) || "asc").toLowerCase() === "desc"
|
||||||
? "desc"
|
? "desc"
|
||||||
: "asc",
|
: "asc",
|
||||||
roleType: ((req.query.roleType as string) || "user").toLowerCase() as "user" | "admin",
|
roleType: ((req.query.roleType as string) || "user").toLowerCase() as
|
||||||
|
| "user"
|
||||||
|
| "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await userService.getUsers(params);
|
const result = await userService.getUsers(params);
|
||||||
@@ -38,13 +42,35 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get login activities for a specific user
|
||||||
|
*/
|
||||||
|
async getUserLoginActivities(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const page = Math.max(parseInt((req.query.page as string) || "1", 10), 1);
|
||||||
|
const limit = Math.max(
|
||||||
|
parseInt((req.query.limit as string) || "25", 10),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await userService.getUserLoginActivities(id, page, limit);
|
||||||
|
res.json({ items: result.items, total: result.total, page, limit });
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleError(res, "Failed to fetch user login activities", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user login activity logs
|
* Get user login activity logs
|
||||||
*/
|
*/
|
||||||
async getUserLogs(req: Request, res: Response) {
|
async getUserLogs(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(parseInt((req.query.page as string) || "1", 10), 1);
|
const page = Math.max(parseInt((req.query.page as string) || "1", 10), 1);
|
||||||
const limit = Math.max(parseInt((req.query.limit as string) || "25", 10), 1);
|
const limit = Math.max(
|
||||||
|
parseInt((req.query.limit as string) || "25", 10),
|
||||||
|
1
|
||||||
|
);
|
||||||
const search = ((req.query.search as string) || "").trim();
|
const search = ((req.query.search as string) || "").trim();
|
||||||
|
|
||||||
const result = await userService.getUserLogs(page, limit, search);
|
const result = await userService.getUserLogs(page, limit, search);
|
||||||
@@ -72,7 +98,8 @@ export class UserController {
|
|||||||
return res.status(404).json({ error: error.message });
|
return res.status(404).json({ error: error.message });
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error.message === "Invitations are not applicable for end-user accounts" ||
|
error.message ===
|
||||||
|
"Invitations are not applicable for end-user accounts" ||
|
||||||
error.message === "User is already activated"
|
error.message === "User is already activated"
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({ error: error.message });
|
return res.status(400).json({ error: error.message });
|
||||||
@@ -119,7 +146,11 @@ export class UserController {
|
|||||||
const requesterRole = req.user?.role as Role | undefined;
|
const requesterRole = req.user?.role as Role | undefined;
|
||||||
const requesterId = req.user?.userId as string | undefined;
|
const requesterId = req.user?.userId as string | undefined;
|
||||||
|
|
||||||
const user = await userService.createUser(data, requesterRole, requesterId);
|
const user = await userService.createUser(
|
||||||
|
data,
|
||||||
|
requesterRole,
|
||||||
|
requesterId
|
||||||
|
);
|
||||||
res.status(201).json(user);
|
res.status(201).json(user);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === "Missing required fields") {
|
if (error.message === "Missing required fields") {
|
||||||
@@ -132,14 +163,18 @@ export class UserController {
|
|||||||
error.message === "Admins may only create end-user accounts" ||
|
error.message === "Admins may only create end-user accounts" ||
|
||||||
error.message === "Only Managers can create Admin accounts"
|
error.message === "Only Managers can create Admin accounts"
|
||||||
) {
|
) {
|
||||||
return res.status(403).json({ error: "Forbidden", details: error.message });
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Forbidden", details: error.message });
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error.message === "Could not determine manager for the new Admin" ||
|
error.message === "Could not determine manager for the new Admin" ||
|
||||||
error.message === "managerId must reference an existing Manager"
|
error.message === "managerId must reference an existing Manager"
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: error.message.includes("manager") ? "Invalid manager assignment" : "Invalid manager",
|
error: error.message.includes("manager")
|
||||||
|
? "Invalid manager assignment"
|
||||||
|
: "Invalid manager",
|
||||||
details: error.message,
|
details: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,10 +200,19 @@ export class UserController {
|
|||||||
managerId: req.body.managerId,
|
managerId: req.body.managerId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const requesterRole = (req as AuthenticatedRequest).user?.role as Role | undefined;
|
const requesterRole = (req as AuthenticatedRequest).user?.role as
|
||||||
const requesterId = (req as AuthenticatedRequest).user?.userId as string | undefined;
|
| Role
|
||||||
|
| undefined;
|
||||||
|
const requesterId = (req as AuthenticatedRequest).user?.userId as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
const user = await userService.updateUser(id, data, requesterRole, requesterId);
|
const user = await userService.updateUser(
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
requesterRole,
|
||||||
|
requesterId
|
||||||
|
);
|
||||||
res.json(user);
|
res.json(user);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === "User not found") {
|
if (error.message === "User not found") {
|
||||||
@@ -179,19 +223,25 @@ export class UserController {
|
|||||||
error.message === "Admins may not assign or reassign Admins" ||
|
error.message === "Admins may not assign or reassign Admins" ||
|
||||||
error.message === "Only Managers can assign/reassign Admins"
|
error.message === "Only Managers can assign/reassign Admins"
|
||||||
) {
|
) {
|
||||||
return res.status(403).json({ error: "Forbidden", details: error.message });
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Forbidden", details: error.message });
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error.message === "managerId may only be set for Admin accounts" ||
|
error.message === "managerId may only be set for Admin accounts" ||
|
||||||
error.message === "Could not determine manager for the Admin"
|
error.message === "Could not determine manager for the Admin"
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: error.message.includes("manager") ? "Invalid manager assignment" : "Invalid assignment",
|
error: error.message.includes("manager")
|
||||||
|
? "Invalid manager assignment"
|
||||||
|
: "Invalid assignment",
|
||||||
details: error.message,
|
details: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (error.message === "managerId must reference an existing Manager") {
|
if (error.message === "managerId must reference an existing Manager") {
|
||||||
return res.status(400).json({ error: "Invalid manager", details: error.message });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid manager", details: error.message });
|
||||||
}
|
}
|
||||||
return handleError(res, "Failed to update user", error);
|
return handleError(res, "Failed to update user", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ router.get(
|
|||||||
userController.getUserLogs
|
userController.getUserLogs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Per-user login activities (admins and managers)
|
||||||
|
router.get(
|
||||||
|
"/:id/logs",
|
||||||
|
requireRole([Role.ADMIN, Role.MANAGER]),
|
||||||
|
userController.getUserLoginActivities
|
||||||
|
);
|
||||||
|
|
||||||
// Resend invitation email (admins and managers)
|
// Resend invitation email (admins and managers)
|
||||||
router.post(
|
router.post(
|
||||||
"/:id/invite",
|
"/:id/invite",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Role, RiskStatus, User, Prisma } from "@prisma/client";
|
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { Role, RiskStatus, User, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
userRepository,
|
userRepository,
|
||||||
UserListParams,
|
UserListParams,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
generateRawToken,
|
generateRawToken,
|
||||||
} from "../../lib/passwordReset";
|
} from "../../lib/passwordReset";
|
||||||
import { sendInvitationEmail } from "../../lib/email";
|
import { sendInvitationEmail } from "../../lib/email";
|
||||||
|
import prisma from "../../prismaClient";
|
||||||
|
|
||||||
export interface CreateUserData {
|
export interface CreateUserData {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -41,6 +43,8 @@ export interface LoginLogItem {
|
|||||||
email: string;
|
email: string;
|
||||||
lastLoginAt: Date;
|
lastLoginAt: Date;
|
||||||
activity: string;
|
activity: string;
|
||||||
|
status?: string;
|
||||||
|
failureReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -131,45 +135,93 @@ export class UserService {
|
|||||||
limit: number,
|
limit: number,
|
||||||
search: string
|
search: string
|
||||||
): Promise<{ items: LoginLogItem[]; total: number }> {
|
): Promise<{ items: LoginLogItem[]; total: number }> {
|
||||||
const users = await userRepository.findUsersForLogs();
|
let where: any = {};
|
||||||
|
if (search && search.trim()) {
|
||||||
|
const tokens = search
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length >= 2)
|
||||||
|
.slice(0, 3);
|
||||||
|
const tokenAndClauses = tokens.map((tok) => ({
|
||||||
|
OR: [
|
||||||
|
{ activity: { contains: tok, mode: "insensitive" } },
|
||||||
|
{ user: { firstName: { contains: tok, mode: "insensitive" } } },
|
||||||
|
{ user: { lastName: { contains: tok, mode: "insensitive" } } },
|
||||||
|
{ user: { email: { contains: tok, mode: "insensitive" } } },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
if (tokenAndClauses.length > 0) where = { AND: tokenAndClauses };
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by search
|
const [total, rows] = await Promise.all([
|
||||||
const filtered = users.filter((u) => {
|
prisma.loginActivity.count({ where }),
|
||||||
if (!search) return true;
|
prisma.loginActivity.findMany({
|
||||||
const name = `${u.firstName} ${u.lastName}`.toLowerCase();
|
where,
|
||||||
return (
|
select: {
|
||||||
name.includes(search.toLowerCase()) ||
|
id: true,
|
||||||
u.email.toLowerCase().includes(search.toLowerCase())
|
userId: true,
|
||||||
);
|
createdAt: true,
|
||||||
});
|
activity: true,
|
||||||
|
status: true,
|
||||||
|
failureReason: true,
|
||||||
|
user: { select: { firstName: true, lastName: true, email: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// Build dummy logs
|
const items: LoginLogItem[] = (rows as any[]).map((row: any) => ({
|
||||||
const activities = [
|
id: row.id,
|
||||||
"Login success",
|
name: `${row.user.firstName} ${row.user.lastName}`,
|
||||||
"Login failed",
|
email: row.user.email,
|
||||||
"2FA challenge passed",
|
lastLoginAt: row.createdAt,
|
||||||
"Password reset",
|
activity: row.activity,
|
||||||
"Logout",
|
status: row.status,
|
||||||
];
|
failureReason: row.failureReason ?? null,
|
||||||
const now = Date.now();
|
}));
|
||||||
const itemsAll = filtered.map((u, idx) => {
|
|
||||||
const offsetMinutes =
|
|
||||||
((idx * 37) % 7_200) + Math.floor(Math.random() * 60);
|
|
||||||
const lastLoginAt = new Date(now - offsetMinutes * 60 * 1000);
|
|
||||||
const activity = activities[(idx * 13) % activities.length];
|
|
||||||
return {
|
|
||||||
id: u.id,
|
|
||||||
name: `${u.firstName} ${u.lastName}`,
|
|
||||||
email: u.email,
|
|
||||||
lastLoginAt,
|
|
||||||
activity,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = itemsAll.length;
|
return { items, total };
|
||||||
const start = (page - 1) * limit;
|
}
|
||||||
const end = start + limit;
|
|
||||||
const items = itemsAll.slice(start, end);
|
/**
|
||||||
|
* Get login activities for a specific user
|
||||||
|
*/
|
||||||
|
async getUserLoginActivities(
|
||||||
|
userId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number
|
||||||
|
): Promise<{ items: LoginLogItem[]; total: number }> {
|
||||||
|
const where: any = { userId };
|
||||||
|
const [total, rows] = await Promise.all([
|
||||||
|
prisma.loginActivity.count({ where }),
|
||||||
|
prisma.loginActivity.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
createdAt: true,
|
||||||
|
activity: true,
|
||||||
|
status: true,
|
||||||
|
failureReason: true,
|
||||||
|
user: { select: { firstName: true, lastName: true, email: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items: LoginLogItem[] = (rows as any[]).map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: `${row.user.firstName} ${row.user.lastName}`,
|
||||||
|
email: row.user.email,
|
||||||
|
lastLoginAt: row.createdAt,
|
||||||
|
activity: row.activity,
|
||||||
|
status: row.status,
|
||||||
|
failureReason: row.failureReason ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user