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_USER="0a508c201b8611"
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"
}
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 {
provider = "postgresql"
url = env("DATABASE_URL")
@@ -30,6 +44,8 @@ model User {
teamMembers User[] @relation("ManagerMembers")
assignedTodos Todo[] @relation("AssignedTodos")
securityType SecurityType @default(BASIC)
lastLoginAt DateTime?
loginActivities LoginActivity[]
}
model Todo {
@@ -100,3 +116,18 @@ enum SecurityType {
ASSISTANCE
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(
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
View File
@@ -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>
+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);
} 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);
}
}
+51 -2
View File
@@ -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;
}
}
+63 -13
View File
@@ -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"
sortDir:
((req.query.sortDir as string) || "asc").toLowerCase() === "desc"
? "desc"
: "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);
@@ -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);
}
+7
View File
@@ -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",
+89 -37
View File
@@ -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 };
}