login time

This commit is contained in:
Sone
2025-11-05 20:11:59 +01:00
parent deb622b796
commit af4d079181
12 changed files with 1904 additions and 273 deletions
+1 -1
View File
@@ -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>"
+1571 -210
View File
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;
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+47
View File
@@ -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),
});
}
}
+2 -1
View File
@@ -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);
} }
} }
+51 -2
View File
@@ -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;
} }
} }
+67 -17
View File
@@ -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:
? "desc" ((req.query.sortDir as string) || "asc").toLowerCase() === "desc"
: "asc", ? "desc"
roleType: ((req.query.roleType as string) || "user").toLowerCase() as "user" | "admin", : "asc",
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);
} }
@@ -220,7 +270,7 @@ export class UserController {
try { try {
const { id } = req.params; const { id } = req.params;
const requesterRole = (req as AuthenticatedRequest).user?.role; const requesterRole = (req as AuthenticatedRequest).user?.role;
await userService.deleteUser(id, requesterRole); await userService.deleteUser(id, requesterRole);
res.status(204).send(); res.status(204).send();
} catch (error: any) { } catch (error: any) {
@@ -244,7 +294,7 @@ export class UserController {
try { try {
const { ids } = req.body; const { ids } = req.body;
const requesterRole = (req as AuthenticatedRequest).user?.role; const requesterRole = (req as AuthenticatedRequest).user?.role;
await userService.deleteUsers(ids, requesterRole); await userService.deleteUsers(ids, requesterRole);
res.status(204).send(); res.status(204).send();
} catch (error: any) { } catch (error: any) {
+7
View File
@@ -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",
+89 -37
View File
@@ -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 };
} }