confirm actions
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user