login time
This commit is contained in:
+2
-1
@@ -12,12 +12,13 @@ const port = process.env.PORT || 5000;
|
||||
app.use(
|
||||
cors({
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use("/api/auth", authRoutes);
|
||||
|
||||
+4
-4
@@ -25,9 +25,9 @@ export const getTransporter = () => {
|
||||
|
||||
export const sendResetPasswordEmail = async (to: string, link: string) => {
|
||||
const t = getTransporter();
|
||||
const subject = "Reset your Project Athena password";
|
||||
const subject = "Reset your Bird Eye View password";
|
||||
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><a href="${link}">Reset Password</a></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) => {
|
||||
const t = getTransporter();
|
||||
const subject = "You're invited to Project Athena";
|
||||
const subject = "You're invited to Bird Eye View";
|
||||
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><a href="${link}">Accept Invitation</a></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);
|
||||
} catch (e: any) {
|
||||
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,
|
||||
} from "../../lib/passwordReset";
|
||||
import { sendResetPasswordEmail } from "../../lib/email";
|
||||
import { upsertLoginActivity } from "../../lib/loginActivity";
|
||||
|
||||
export class AuthService {
|
||||
async registerDisabled() {
|
||||
@@ -20,6 +21,17 @@ export class AuthService {
|
||||
|
||||
async login(email?: string, password?: string) {
|
||||
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");
|
||||
error.code = 400;
|
||||
throw error;
|
||||
@@ -28,12 +40,26 @@ export class AuthService {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
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");
|
||||
error.code = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!user.isActivated) {
|
||||
await upsertLoginActivity(
|
||||
user.id,
|
||||
"Login failed",
|
||||
"FAILURE",
|
||||
"NOT_ACTIVATED"
|
||||
);
|
||||
const error: any = new Error(
|
||||
"Account is not activated. Please use your invitation link to set a password."
|
||||
);
|
||||
@@ -42,6 +68,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (user.isLocked) {
|
||||
await upsertLoginActivity(
|
||||
user.id,
|
||||
"Login failed",
|
||||
"FAILURE",
|
||||
"ACCOUNT_LOCKED"
|
||||
);
|
||||
const error: any = new Error(
|
||||
"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);
|
||||
if (!ok) {
|
||||
await upsertLoginActivity(
|
||||
user.id,
|
||||
"Login failed",
|
||||
"FAILURE",
|
||||
"WRONG_PASSWORD"
|
||||
);
|
||||
const error: any = new Error("Invalid credentials");
|
||||
error.code = 401;
|
||||
throw error;
|
||||
@@ -61,7 +99,18 @@ export class AuthService {
|
||||
email: user.email,
|
||||
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) {
|
||||
@@ -114,7 +163,7 @@ export class AuthService {
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
await upsertLoginActivity(user.id, "Password reset", "SUCCESS", null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from "express";
|
||||
import { Role, RiskStatus } from "@prisma/client";
|
||||
|
||||
import { AuthenticatedRequest } from "../../middleware/auth";
|
||||
import { userService, CreateUserData, UpdateUserData } from "./users.service";
|
||||
import { UserListParams } from "./users.repository";
|
||||
@@ -20,10 +21,13 @@ export class UserController {
|
||||
.map((r) => r.trim())
|
||||
.filter((r) => !!r),
|
||||
sortBy: (req.query.sortBy as string) || "",
|
||||
sortDir: ((req.query.sortDir as string) || "asc").toLowerCase() === "desc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
roleType: ((req.query.roleType as string) || "user").toLowerCase() as "user" | "admin",
|
||||
sortDir:
|
||||
((req.query.sortDir as string) || "asc").toLowerCase() === "desc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
roleType: ((req.query.roleType as string) || "user").toLowerCase() as
|
||||
| "user"
|
||||
| "admin",
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
async getUserLogs(req: Request, res: Response) {
|
||||
try {
|
||||
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 result = await userService.getUserLogs(page, limit, search);
|
||||
@@ -72,7 +98,8 @@ export class UserController {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
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"
|
||||
) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
@@ -119,7 +146,11 @@ export class UserController {
|
||||
const requesterRole = req.user?.role as Role | 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);
|
||||
} catch (error: any) {
|
||||
if (error.message === "Missing required fields") {
|
||||
@@ -132,14 +163,18 @@ export class UserController {
|
||||
error.message === "Admins may only create end-user 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 (
|
||||
error.message === "Could not determine manager for the new Admin" ||
|
||||
error.message === "managerId must reference an existing Manager"
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -165,10 +200,19 @@ export class UserController {
|
||||
managerId: req.body.managerId,
|
||||
};
|
||||
|
||||
const requesterRole = (req as AuthenticatedRequest).user?.role as Role | undefined;
|
||||
const requesterId = (req as AuthenticatedRequest).user?.userId as string | undefined;
|
||||
const requesterRole = (req as AuthenticatedRequest).user?.role as
|
||||
| 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);
|
||||
} catch (error: any) {
|
||||
if (error.message === "User not found") {
|
||||
@@ -179,19 +223,25 @@ export class UserController {
|
||||
error.message === "Admins may not assign or 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 (
|
||||
error.message === "managerId may only be set for Admin accounts" ||
|
||||
error.message === "Could not determine manager for the Admin"
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -220,7 +270,7 @@ export class UserController {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const requesterRole = (req as AuthenticatedRequest).user?.role;
|
||||
|
||||
|
||||
await userService.deleteUser(id, requesterRole);
|
||||
res.status(204).send();
|
||||
} catch (error: any) {
|
||||
@@ -244,7 +294,7 @@ export class UserController {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
const requesterRole = (req as AuthenticatedRequest).user?.role;
|
||||
|
||||
|
||||
await userService.deleteUsers(ids, requesterRole);
|
||||
res.status(204).send();
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -22,6 +22,13 @@ router.get(
|
||||
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)
|
||||
router.post(
|
||||
"/:id/invite",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Role, RiskStatus, User, Prisma } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { Role, RiskStatus, User, Prisma } from "@prisma/client";
|
||||
|
||||
import {
|
||||
userRepository,
|
||||
UserListParams,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
generateRawToken,
|
||||
} from "../../lib/passwordReset";
|
||||
import { sendInvitationEmail } from "../../lib/email";
|
||||
import prisma from "../../prismaClient";
|
||||
|
||||
export interface CreateUserData {
|
||||
firstName: string;
|
||||
@@ -41,6 +43,8 @@ export interface LoginLogItem {
|
||||
email: string;
|
||||
lastLoginAt: Date;
|
||||
activity: string;
|
||||
status?: string;
|
||||
failureReason?: string | null;
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
@@ -131,45 +135,93 @@ export class UserService {
|
||||
limit: number,
|
||||
search: string
|
||||
): 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 filtered = users.filter((u) => {
|
||||
if (!search) return true;
|
||||
const name = `${u.firstName} ${u.lastName}`.toLowerCase();
|
||||
return (
|
||||
name.includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Build dummy logs
|
||||
const activities = [
|
||||
"Login success",
|
||||
"Login failed",
|
||||
"2FA challenge passed",
|
||||
"Password reset",
|
||||
"Logout",
|
||||
];
|
||||
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 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,
|
||||
}));
|
||||
|
||||
const total = itemsAll.length;
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const items = itemsAll.slice(start, end);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user