confirm actions

This commit is contained in:
2026-03-15 18:59:37 -05:00
parent 59754c7657
commit df041254da
28 changed files with 999 additions and 63 deletions

View File

@@ -6,6 +6,7 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createAdminRole,
listAdminAuthSessions,
createAdminUser,
getBackupGuidance,
getAdminDiagnostics,
@@ -14,6 +15,7 @@ import {
listAdminPermissions,
listAdminRoles,
listAdminUsers,
revokeAdminAuthSession,
updateAdminRole,
updateAdminUser,
} from "./service.js";
@@ -100,6 +102,24 @@ adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (
return ok(response, await listAdminUsers());
});
adminRouter.get("/sessions", requirePermissions([permissions.adminManage]), async (request, response) => {
return ok(response, await listAdminAuthSessions(request.authSessionId));
});
adminRouter.post("/sessions/:sessionId/revoke", requirePermissions([permissions.adminManage]), async (request, response) => {
const sessionId = getRouteParam(request.params.sessionId);
if (!sessionId) {
return fail(response, 400, "INVALID_INPUT", "Session id is invalid.");
}
const result = await revokeAdminAuthSession(sessionId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, { success: true as const });
});
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {

View File

@@ -1,5 +1,6 @@
import type {
AdminDiagnosticsDto,
AdminAuthSessionDto,
BackupGuidanceDto,
AdminPermissionOptionDto,
AdminRoleDto,
@@ -124,6 +125,50 @@ function mapUser(record: {
};
}
function mapAuthSession(
record: {
id: string;
userId: string;
expiresAt: Date;
lastSeenAt: Date;
revokedAt: Date | null;
revokedReason: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: Date;
user: {
email: string;
firstName: string;
lastName: string;
};
revokedBy: {
firstName: string;
lastName: string;
} | null;
},
currentSessionId?: string
): AdminAuthSessionDto {
const now = Date.now();
const status = record.revokedAt ? "REVOKED" : record.expiresAt.getTime() <= now ? "EXPIRED" : "ACTIVE";
return {
id: record.id,
userId: record.userId,
userEmail: record.user.email,
userName: `${record.user.firstName} ${record.user.lastName}`.trim(),
status,
isCurrent: record.id === currentSessionId,
createdAt: record.createdAt.toISOString(),
lastSeenAt: record.lastSeenAt.toISOString(),
expiresAt: record.expiresAt.toISOString(),
revokedAt: record.revokedAt?.toISOString() ?? null,
revokedReason: record.revokedReason,
revokedByName: record.revokedBy ? `${record.revokedBy.firstName} ${record.revokedBy.lastName}`.trim() : null,
ipAddress: record.ipAddress,
userAgent: record.userAgent,
};
}
async function validatePermissionKeys(permissionKeys: string[]) {
const uniquePermissionKeys = [...new Set(permissionKeys)];
const permissions = await prisma.permission.findMany({
@@ -338,6 +383,76 @@ export async function listAdminUsers(): Promise<AdminUserDto[]> {
return users.map(mapUser);
}
export async function listAdminAuthSessions(currentSessionId?: string | null): Promise<AdminAuthSessionDto[]> {
const sessions = await prisma.authSession.findMany({
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
revokedBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revokedAt: "asc" }, { lastSeenAt: "desc" }, { createdAt: "desc" }],
take: 200,
});
return sessions.map((session) => mapAuthSession(session, currentSessionId ?? undefined));
}
export async function revokeAdminAuthSession(sessionId: string, actorId?: string | null) {
const existingSession = await prisma.authSession.findUnique({
where: { id: sessionId },
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!existingSession) {
return { ok: false as const, reason: "Session was not found." };
}
if (existingSession.revokedAt) {
return { ok: false as const, reason: "Session is already revoked." };
}
await prisma.authSession.update({
where: { id: sessionId },
data: {
revokedAt: new Date(),
revokedById: actorId ?? null,
revokedReason: "Revoked by administrator.",
},
});
await logAuditEvent({
actorId,
entityType: "auth-session",
entityId: existingSession.id,
action: "revoked",
summary: `Revoked session for ${existingSession.user.email}.`,
metadata: {
userId: existingSession.userId,
userEmail: existingSession.user.email,
},
});
return { ok: true as const };
}
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
if (!payload.password || payload.password.trim().length < 8) {
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
@@ -485,6 +600,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
companyProfile,
userCount,
activeUserCount,
activeSessionCount,
roleCount,
permissionCount,
customerCount,
@@ -504,6 +620,14 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.authSession.count({
where: {
revokedAt: null,
expiresAt: {
gt: new Date(),
},
},
}),
prisma.role.count(),
prisma.permission.count(),
prisma.customer.count(),
@@ -542,6 +666,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
companyProfilePresent: Boolean(companyProfile),
userCount,
activeUserCount,
activeSessionCount,
roleCount,
permissionCount,
customerCount,

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requireAuth } from "../../lib/rbac.js";
import { login } from "./service.js";
import { login, logout } from "./service.js";
const loginSchema = z.object({
email: z.string().email(),
@@ -18,7 +18,10 @@ authRouter.post("/login", async (request, response) => {
return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password.");
}
const result = await login(parsed.data);
const result = await login(parsed.data, {
ipAddress: request.ip,
userAgent: request.header("user-agent"),
});
if (!result) {
return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect.");
}
@@ -28,3 +31,11 @@ authRouter.post("/login", async (request, response) => {
authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser));
authRouter.post("/logout", requireAuth, async (request, response) => {
if (!request.authSessionId || !request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
await logout(request.authSessionId, request.authUser.id);
return ok(response, { success: true as const });
});

View File

@@ -1,11 +1,18 @@
import type { LoginRequest, LoginResponse } from "@mrp/shared";
import { signToken } from "../../lib/auth.js";
import { createAuthSession, revokeAuthSession } from "../../lib/auth-sessions.js";
import { getCurrentUserById } from "../../lib/current-user.js";
import { verifyPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js";
export async function login(payload: LoginRequest): Promise<LoginResponse | null> {
export async function login(
payload: LoginRequest,
context?: {
ipAddress?: string | null;
userAgent?: string | null;
}
): Promise<LoginResponse | null> {
const user = await prisma.user.findUnique({
where: { email: payload.email.toLowerCase() },
});
@@ -23,9 +30,21 @@ export async function login(payload: LoginRequest): Promise<LoginResponse | null
return null;
}
const session = await createAuthSession({
userId: user.id,
ipAddress: context?.ipAddress ?? null,
userAgent: context?.userAgent ?? null,
});
return {
token: signToken(authUser),
token: signToken(authUser, session.id),
user: authUser,
};
}
export async function logout(sessionId: string, actorId?: string | null) {
await revokeAuthSession(sessionId, {
revokedById: actorId ?? null,
reason: actorId ? "User signed out." : "Session signed out.",
});
}