generated from nhcarrigan/template
feat: security and auditing
This commit is contained in:
@@ -152,6 +152,7 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
avatar String?
|
avatar String?
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
|
isBanned Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -177,3 +178,40 @@ model Comment {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
action AuditAction
|
||||||
|
category AuditCategory
|
||||||
|
userId String? @db.ObjectId
|
||||||
|
targetUserId String? @db.ObjectId
|
||||||
|
resourceType String?
|
||||||
|
resourceId String?
|
||||||
|
details String?
|
||||||
|
userAgent String?
|
||||||
|
success Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuditAction {
|
||||||
|
LOGIN
|
||||||
|
LOGOUT
|
||||||
|
LOGIN_FAILED
|
||||||
|
COMMENT_CREATE
|
||||||
|
COMMENT_DELETE
|
||||||
|
ENTRY_CREATE
|
||||||
|
ENTRY_UPDATE
|
||||||
|
ENTRY_DELETE
|
||||||
|
USER_BAN
|
||||||
|
USER_UNBAN
|
||||||
|
RATE_LIMIT_EXCEEDED
|
||||||
|
CSRF_VALIDATION_FAILED
|
||||||
|
UNAUTHORIZED_ACCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuditCategory {
|
||||||
|
AUTH
|
||||||
|
CONTENT
|
||||||
|
ADMIN
|
||||||
|
SECURITY
|
||||||
|
}
|
||||||
|
|||||||
+37
-3
@@ -1,14 +1,48 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance, FastifyError } from 'fastify';
|
||||||
import AutoLoad from '@fastify/autoload';
|
import AutoLoad from '@fastify/autoload';
|
||||||
|
import { AuditService } from './services/audit.service';
|
||||||
|
import { AuditAction, AuditCategory } from '@library/shared-types';
|
||||||
|
|
||||||
/* eslint-disable-next-line */
|
/* eslint-disable-next-line */
|
||||||
export interface AppOptions {}
|
export interface AppOptions {}
|
||||||
|
|
||||||
export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
||||||
// Place here your custom code!
|
// Add global error handler for security event logging
|
||||||
|
fastify.setErrorHandler(async (error: FastifyError, request, reply) => {
|
||||||
|
// Log CSRF validation failures
|
||||||
|
if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') {
|
||||||
|
await AuditService.log({
|
||||||
|
action: AuditAction.CSRF_VALIDATION_FAILED,
|
||||||
|
category: AuditCategory.SECURITY,
|
||||||
|
details: `CSRF validation failed: ${error.message}, URL: ${request.url}`,
|
||||||
|
success: false,
|
||||||
|
}, request).catch(() => {
|
||||||
|
// Ignore logging errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Do not touch the following lines
|
// Log unauthorized access attempts
|
||||||
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||||
|
await AuditService.log({
|
||||||
|
action: AuditAction.UNAUTHORIZED_ACCESS,
|
||||||
|
category: AuditCategory.SECURITY,
|
||||||
|
details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`,
|
||||||
|
success: false,
|
||||||
|
}, request).catch(() => {
|
||||||
|
// Ignore logging errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the error response (don't leak internal details for server errors)
|
||||||
|
const statusCode = error.statusCode ?? 500;
|
||||||
|
|
||||||
|
reply.status(statusCode).send({
|
||||||
|
statusCode,
|
||||||
|
error: statusCode >= 500 ? "Internal Server Error" : error.name,
|
||||||
|
message: statusCode >= 500 ? "An unexpected error occurred" : error.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// This loads all plugins defined in plugins
|
// This loads all plugins defined in plugins
|
||||||
// those should be support plugins that are reused
|
// those should be support plugins that are reused
|
||||||
|
|||||||
@@ -5,17 +5,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { UserService } from "../services/user.service";
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to check if the authenticated user is an admin.
|
* Middleware to check if the authenticated user is an admin.
|
||||||
* Must be used after app.authenticate.
|
* Must be used after app.authenticate.
|
||||||
|
* Always checks the database to ensure admin status is current.
|
||||||
*/
|
*/
|
||||||
export async function adminGuard(
|
export async function adminGuard(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const user = request.user as any;
|
const user = request.user as { id: string };
|
||||||
if (!user || !user.isAdmin) {
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbUser = await userService.getUserById(user.id);
|
||||||
|
if (!dbUser?.isAdmin) {
|
||||||
return reply.code(403).send({ error: "Forbidden: Admin access required" });
|
return reply.code(403).send({ error: "Forbidden: Admin access required" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { UserService } from "../services/user.service";
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to check if the authenticated user is banned.
|
||||||
|
* Must be used after app.authenticate.
|
||||||
|
*/
|
||||||
|
export async function bannedGuard(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
): Promise<void> {
|
||||||
|
const user = request.user as { id: string };
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBanned = await userService.isUserBanned(user.id);
|
||||||
|
if (isBanned) {
|
||||||
|
return reply.code(403).send({ error: "You have been banned from commenting" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,13 +23,34 @@ declare module "@fastify/jwt" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getJwtSecret = (): string => {
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("JWT_SECRET environment variable is required");
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
};
|
||||||
|
|
||||||
const authPlugin: FastifyPluginAsync = async (app) => {
|
const authPlugin: FastifyPluginAsync = async (app) => {
|
||||||
|
const jwtSecret = getJwtSecret();
|
||||||
|
|
||||||
|
// Register cookie plugin with signing secret
|
||||||
|
app.register(fastifyCookie, {
|
||||||
|
secret: jwtSecret,
|
||||||
|
});
|
||||||
|
|
||||||
// Register JWT plugin
|
// Register JWT plugin
|
||||||
app.register(fastifyJwt, {
|
app.register(fastifyJwt, {
|
||||||
secret: process.env.JWT_SECRET || "your-secret-key",
|
secret: jwtSecret,
|
||||||
|
sign: {
|
||||||
|
algorithm: "HS256",
|
||||||
|
},
|
||||||
|
verify: {
|
||||||
|
algorithms: ["HS256"],
|
||||||
|
},
|
||||||
cookie: {
|
cookie: {
|
||||||
cookieName: "auth-token",
|
cookieName: "auth-token",
|
||||||
signed: false,
|
signed: true,
|
||||||
},
|
},
|
||||||
formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => {
|
formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => {
|
||||||
return {
|
return {
|
||||||
@@ -41,9 +62,6 @@ const authPlugin: FastifyPluginAsync = async (app) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register cookie plugin
|
|
||||||
app.register(fastifyCookie);
|
|
||||||
|
|
||||||
// Register Discord OAuth2
|
// Register Discord OAuth2
|
||||||
app.register(fastifyOauth2, {
|
app.register(fastifyOauth2, {
|
||||||
name: "oauth2Discord",
|
name: "oauth2Discord",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import fastifyPlugin from "fastify-plugin";
|
||||||
|
import fastifyCors from "@fastify/cors";
|
||||||
|
|
||||||
|
const corsPlugin: FastifyPluginAsync = async (app) => {
|
||||||
|
const baseUrl = process.env.BASE_URL;
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error("BASE_URL environment variable is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.register(fastifyCors, {
|
||||||
|
origin: baseUrl,
|
||||||
|
credentials: true,
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fastifyPlugin(corsPlugin);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
||||||
|
import fastifyPlugin from "fastify-plugin";
|
||||||
|
import fastifyCsrf from "@fastify/csrf-protection";
|
||||||
|
|
||||||
|
const csrfPlugin: FastifyPluginAsync = async (app) => {
|
||||||
|
await app.register(fastifyCsrf, {
|
||||||
|
sessionPlugin: "@fastify/cookie",
|
||||||
|
cookieOpts: {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
},
|
||||||
|
getToken: (request: FastifyRequest) => {
|
||||||
|
return request.headers["x-csrf-token"] as string;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fastifyPlugin(csrfPlugin);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import fastifyPlugin from "fastify-plugin";
|
||||||
|
import fastifyHelmet from "@fastify/helmet";
|
||||||
|
|
||||||
|
const helmetPlugin: FastifyPluginAsync = async (app) => {
|
||||||
|
await app.register(fastifyHelmet, {
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fastifyPlugin(helmetPlugin);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import fastifyPlugin from "fastify-plugin";
|
||||||
|
import fastifyRateLimit from "@fastify/rate-limit";
|
||||||
|
import { AuditService } from "../services/audit.service";
|
||||||
|
import { AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
|
|
||||||
|
const rateLimitPlugin: FastifyPluginAsync = async (app) => {
|
||||||
|
await app.register(fastifyRateLimit, {
|
||||||
|
max: 100,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
errorResponseBuilder: (request) => {
|
||||||
|
// Log rate limit exceeded event
|
||||||
|
AuditService.log({
|
||||||
|
action: AuditAction.RATE_LIMIT_EXCEEDED,
|
||||||
|
category: AuditCategory.SECURITY,
|
||||||
|
details: `Rate limit exceeded for URL: ${request.url}`,
|
||||||
|
success: false,
|
||||||
|
}, request).catch(() => {
|
||||||
|
// Ignore logging errors to avoid blocking the response
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 429,
|
||||||
|
error: "Too Many Requests",
|
||||||
|
message: "You have exceeded the rate limit. Please try again later.",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fastifyPlugin(rateLimitPlugin);
|
||||||
@@ -5,10 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto } from "@library/shared-types";
|
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
import { ArtService } from "../../services/art.service";
|
import { ArtService } from "../../services/art.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
const artRoutes: FastifyPluginAsync = async (app) => {
|
const artRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const artService = new ArtService();
|
const artService = new ArtService();
|
||||||
@@ -39,9 +41,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
return artService.createArt(request.body);
|
const art = await artService.createArt(request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "art",
|
||||||
|
resourceId: art.id,
|
||||||
|
details: `Created art: ${art.title}`,
|
||||||
|
});
|
||||||
|
return art;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,10 +67,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
return artService.updateArt(id, request.body);
|
const art = await artService.updateArt(id, request.body);
|
||||||
|
if (art) {
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "art",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Updated art: ${art.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return art;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,10 +92,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
await artService.deleteArt(id);
|
await artService.deleteArt(id);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_DELETE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "art",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted art with ID: ${id}`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -95,12 +125,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
"/:id/comments",
|
"/:id/comments",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate],
|
preValidation: [app.authenticate, bannedGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
return commentService.createCommentForArt(id, userId, request.body);
|
const comment = await commentService.createCommentForArt(id, userId, request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "art",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Added comment to art`,
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,10 +150,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id/comments/:commentId",
|
"/:id/comments/:commentId",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { commentId } = request.params;
|
const { id, commentId } = request.params;
|
||||||
await commentService.deleteComment(commentId);
|
await commentService.deleteComment(commentId);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_DELETE,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
resourceType: "art",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted comment ${commentId} from art`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
import type { AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
|
|
||||||
|
interface AuditLogQuery {
|
||||||
|
action?: AuditAction;
|
||||||
|
category?: AuditCategory;
|
||||||
|
userId?: string;
|
||||||
|
success?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: string;
|
||||||
|
limit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
/**
|
||||||
|
* Get audit logs (admin only).
|
||||||
|
*/
|
||||||
|
app.get<{ Querystring: AuditLogQuery }>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { action, category, userId, success, startDate, endDate, page, limit } = request.query;
|
||||||
|
|
||||||
|
return AuditService.getLogs({
|
||||||
|
action: action as AuditAction | undefined,
|
||||||
|
category: category as AuditCategory | undefined,
|
||||||
|
userId,
|
||||||
|
success: success === undefined ? undefined : success === "true",
|
||||||
|
startDate: startDate ? new Date(startDate) : undefined,
|
||||||
|
endDate: endDate ? new Date(endDate) : undefined,
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
limit: limit ? parseInt(limit, 10) : 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get security logs (admin only).
|
||||||
|
*/
|
||||||
|
app.get<{ Querystring: { page?: string; limit?: string } }>(
|
||||||
|
"/security",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { page, limit } = request.query;
|
||||||
|
return AuditService.getSecurityLogs(
|
||||||
|
page ? parseInt(page, 10) : 1,
|
||||||
|
limit ? parseInt(limit, 10) : 50
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logs for a specific user (admin only).
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { userId: string }; Querystring: { page?: string; limit?: string } }>(
|
||||||
|
"/user/:userId",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { userId } = request.params;
|
||||||
|
const { page, limit } = request.query;
|
||||||
|
return AuditService.getLogsByUser(
|
||||||
|
userId,
|
||||||
|
page ? parseInt(page, 10) : 1,
|
||||||
|
limit ? parseInt(limit, 10) : 50
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default auditRoutes;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { AuthService } from "../../services/auth.service";
|
import { AuthService } from "../../services/auth.service";
|
||||||
import { AuthResponse } from "@library/shared-types";
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
|
|
||||||
const authRoutes: FastifyPluginAsync = async (app) => {
|
const authRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const authService = new AuthService(app);
|
const authService = new AuthService(app);
|
||||||
@@ -33,7 +34,16 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
// Generate JWT
|
// Generate JWT
|
||||||
const jwt = await authService.generateToken(user);
|
const jwt = await authService.generateToken(user);
|
||||||
|
|
||||||
// Set cookie and redirect to frontend
|
// Log successful login
|
||||||
|
await AuditService.log({
|
||||||
|
action: AuditAction.LOGIN,
|
||||||
|
category: AuditCategory.AUTH,
|
||||||
|
userId: user.id,
|
||||||
|
details: `User ${user.username} logged in via Discord`,
|
||||||
|
success: true,
|
||||||
|
}, request);
|
||||||
|
|
||||||
|
// Set signed cookie and redirect to frontend
|
||||||
reply
|
reply
|
||||||
.setCookie("auth-token", jwt, {
|
.setCookie("auth-token", jwt, {
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -41,13 +51,22 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
signed: true,
|
||||||
})
|
})
|
||||||
.redirect("/"); // Redirect to root since API serves frontend
|
.redirect("/"); // Redirect to root since API serves frontend
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Log failed login attempt
|
||||||
|
await AuditService.log({
|
||||||
|
action: AuditAction.LOGIN_FAILED,
|
||||||
|
category: AuditCategory.SECURITY,
|
||||||
|
details: error instanceof Error ? error.message : String(error),
|
||||||
|
success: false,
|
||||||
|
}, request);
|
||||||
|
|
||||||
app.log.error({ err: error }, "Auth callback error");
|
app.log.error({ err: error }, "Auth callback error");
|
||||||
reply
|
reply
|
||||||
.code(401)
|
.code(401)
|
||||||
.send({ error: "Authentication failed", details: error instanceof Error ? error.message : String(error) });
|
.send({ error: "Authentication failed" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,8 +78,14 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
{
|
{
|
||||||
preValidation: [app.authenticate],
|
preValidation: [app.authenticate],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request, reply) => {
|
||||||
const user = request.user as any;
|
const jwtUser = request.user as { id: string };
|
||||||
|
const user = await authService.getUserById(jwtUser.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
const token = await authService.generateToken(user);
|
const token = await authService.generateToken(user);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -74,12 +99,38 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
* Logout.
|
* Logout.
|
||||||
*/
|
*/
|
||||||
app.post("/logout", async (request, reply) => {
|
app.post("/logout", async (request, reply) => {
|
||||||
|
// Try to get user ID from JWT if available
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
const user = request.user as { id?: string; username?: string };
|
||||||
|
if (user?.id) {
|
||||||
|
await AuditService.log({
|
||||||
|
action: AuditAction.LOGOUT,
|
||||||
|
category: AuditCategory.AUTH,
|
||||||
|
userId: user.id,
|
||||||
|
details: `User ${user.username ?? "unknown"} logged out`,
|
||||||
|
success: true,
|
||||||
|
}, request);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// User wasn't authenticated, just proceed with logout
|
||||||
|
}
|
||||||
|
|
||||||
reply
|
reply
|
||||||
.clearCookie("auth-token", {
|
.clearCookie("auth-token", {
|
||||||
path: "/",
|
path: "/",
|
||||||
|
signed: true,
|
||||||
})
|
})
|
||||||
.send({ message: "Logged out successfully" });
|
.send({ message: "Logged out successfully" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSRF token for state-changing requests.
|
||||||
|
*/
|
||||||
|
app.get("/csrf-token", async (request, reply) => {
|
||||||
|
const token = reply.generateCsrf();
|
||||||
|
return { csrfToken: token };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default authRoutes;
|
export default authRoutes;
|
||||||
@@ -5,10 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto } from "@library/shared-types";
|
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
import { BookService } from "../../services/book.service";
|
import { BookService } from "../../services/book.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
const booksRoutes: FastifyPluginAsync = async (app) => {
|
const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const bookService = new BookService();
|
const bookService = new BookService();
|
||||||
@@ -39,9 +41,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
return bookService.createBook(request.body);
|
const book = await bookService.createBook(request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "book",
|
||||||
|
resourceId: book.id,
|
||||||
|
details: `Created book: ${book.title}`,
|
||||||
|
});
|
||||||
|
return book;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,10 +67,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
return bookService.updateBook(id, request.body);
|
const book = await bookService.updateBook(id, request.body);
|
||||||
|
if (book) {
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "book",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Updated book: ${book.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return book;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,10 +92,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
await bookService.deleteBook(id);
|
await bookService.deleteBook(id);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_DELETE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "book",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted book with ID: ${id}`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -95,12 +125,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
"/:id/comments",
|
"/:id/comments",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate],
|
preValidation: [app.authenticate, bannedGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
return commentService.createCommentForBook(id, userId, request.body);
|
const comment = await commentService.createCommentForBook(id, userId, request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "book",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Added comment to book`,
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,10 +150,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id/comments/:commentId",
|
"/:id/comments/:commentId",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { commentId } = request.params;
|
const { id, commentId } = request.params;
|
||||||
await commentService.deleteComment(commentId);
|
await commentService.deleteComment(commentId);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_DELETE,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
resourceType: "book",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted comment ${commentId} from book`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto } from "@library/shared-types";
|
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
import { GameService } from "../../services/game.service";
|
import { GameService } from "../../services/game.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const gameService = new GameService();
|
const gameService = new GameService();
|
||||||
@@ -33,9 +35,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
return gameService.createGame(request.body);
|
const game = await gameService.createGame(request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "game",
|
||||||
|
resourceId: game.id,
|
||||||
|
details: `Created game: ${game.title}`,
|
||||||
|
});
|
||||||
|
return game;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -48,10 +59,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
return gameService.updateGame(id, request.body);
|
const game = await gameService.updateGame(id, request.body);
|
||||||
|
if (game) {
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "game",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Updated game: ${game.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return game;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,10 +82,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
await gameService.deleteGame(id);
|
await gameService.deleteGame(id);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_DELETE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "game",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted game with ID: ${id}`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -81,12 +111,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
"/:id/comments",
|
"/:id/comments",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate],
|
preValidation: [app.authenticate, bannedGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
return commentService.createCommentForGame(id, userId, request.body);
|
const comment = await commentService.createCommentForGame(id, userId, request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "game",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Added comment to game`,
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,10 +134,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id/comments/:commentId",
|
"/:id/comments/:commentId",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { commentId } = request.params;
|
const { id, commentId } = request.params;
|
||||||
await commentService.deleteComment(commentId);
|
await commentService.deleteComment(commentId);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_DELETE,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
resourceType: "game",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted comment ${commentId} from game`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto } from "@library/shared-types";
|
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
import { MangaService } from "../../services/manga.service";
|
import { MangaService } from "../../services/manga.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
const mangaRoutes: FastifyPluginAsync = async (app) => {
|
const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const mangaService = new MangaService();
|
const mangaService = new MangaService();
|
||||||
@@ -30,9 +32,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
return mangaService.createManga(request.body);
|
const manga = await mangaService.createManga(request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "manga",
|
||||||
|
resourceId: manga.id,
|
||||||
|
details: `Created manga: ${manga.title}`,
|
||||||
|
});
|
||||||
|
return manga;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,10 +55,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
return mangaService.updateManga(id, request.body);
|
const manga = await mangaService.updateManga(id, request.body);
|
||||||
|
if (manga) {
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "manga",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Updated manga: ${manga.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return manga;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,10 +77,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
await mangaService.deleteManga(id);
|
await mangaService.deleteManga(id);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_DELETE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "manga",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted manga with ID: ${id}`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -74,12 +104,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
"/:id/comments",
|
"/:id/comments",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate],
|
preValidation: [app.authenticate, bannedGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
return commentService.createCommentForManga(id, userId, request.body);
|
const comment = await commentService.createCommentForManga(id, userId, request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "manga",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Added comment to manga`,
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,10 +126,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id/comments/:commentId",
|
"/:id/comments/:commentId",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { commentId } = request.params;
|
const { id, commentId } = request.params;
|
||||||
await commentService.deleteComment(commentId);
|
await commentService.deleteComment(commentId);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_DELETE,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
resourceType: "manga",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted comment ${commentId} from manga`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto } from "@library/shared-types";
|
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
import { MusicService } from "../../services/music.service";
|
import { MusicService } from "../../services/music.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
const musicRoutes: FastifyPluginAsync = async (app) => {
|
const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const musicService = new MusicService();
|
const musicService = new MusicService();
|
||||||
@@ -39,9 +41,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
return musicService.createMusic(request.body);
|
const music = await musicService.createMusic(request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "music",
|
||||||
|
resourceId: music.id,
|
||||||
|
details: `Created music: ${music.title}`,
|
||||||
|
});
|
||||||
|
return music;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,10 +67,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
return musicService.updateMusic(id, request.body);
|
const music = await musicService.updateMusic(id, request.body);
|
||||||
|
if (music) {
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "music",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Updated music: ${music.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return music;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,10 +92,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
await musicService.deleteMusic(id);
|
await musicService.deleteMusic(id);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_DELETE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "music",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted music with ID: ${id}`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -95,12 +125,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
"/:id/comments",
|
"/:id/comments",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate],
|
preValidation: [app.authenticate, bannedGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
return commentService.createCommentForMusic(id, userId, request.body);
|
const comment = await commentService.createCommentForMusic(id, userId, request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "music",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Added comment to music`,
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,10 +150,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id/comments/:commentId",
|
"/:id/comments/:commentId",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { commentId } = request.params;
|
const { id, commentId } = request.params;
|
||||||
await commentService.deleteComment(commentId);
|
await commentService.deleteComment(commentId);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_DELETE,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
resourceType: "music",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted comment ${commentId} from music`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from "fastify";
|
import { FastifyPluginAsync } from "fastify";
|
||||||
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto } from "@library/shared-types";
|
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
import { ShowService } from "../../services/show.service";
|
import { ShowService } from "../../services/show.service";
|
||||||
import { CommentService } from "../../services/comment.service";
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
import { adminGuard } from "../../middleware/admin-guard";
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
import { bannedGuard } from "../../middleware/banned-guard";
|
||||||
|
|
||||||
const showsRoutes: FastifyPluginAsync = async (app) => {
|
const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const showService = new ShowService();
|
const showService = new ShowService();
|
||||||
@@ -30,9 +32,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/",
|
"/",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
return showService.createShow(request.body);
|
const show = await showService.createShow(request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "show",
|
||||||
|
resourceId: show.id,
|
||||||
|
details: `Created show: ${show.title}`,
|
||||||
|
});
|
||||||
|
return show;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,10 +55,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
return showService.updateShow(id, request.body);
|
const show = await showService.updateShow(id, request.body);
|
||||||
|
if (show) {
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_UPDATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "show",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Updated show: ${show.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return show;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,10 +77,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id",
|
"/:id",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
await showService.deleteShow(id);
|
await showService.deleteShow(id);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.ENTRY_DELETE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "show",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted show with ID: ${id}`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -74,12 +104,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||||
"/:id/comments",
|
"/:id/comments",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate],
|
preValidation: [app.authenticate, bannedGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
return commentService.createCommentForShow(id, userId, request.body);
|
const comment = await commentService.createCommentForShow(id, userId, request.body);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_CREATE,
|
||||||
|
category: AuditCategory.CONTENT,
|
||||||
|
resourceType: "show",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Added comment to show`,
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,10 +126,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
"/:id/comments/:commentId",
|
"/:id/comments/:commentId",
|
||||||
{
|
{
|
||||||
preValidation: [app.authenticate, adminGuard],
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { commentId } = request.params;
|
const { id, commentId } = request.params;
|
||||||
await commentService.deleteComment(commentId);
|
await commentService.deleteComment(commentId);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.COMMENT_DELETE,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
resourceType: "show",
|
||||||
|
resourceId: id,
|
||||||
|
details: `Deleted comment ${commentId} from show`,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import { User, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
|
import { UserService } from "../../services/user.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
const usersRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
app.get<{ Reply: User[] }>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return userService.getAllUsers();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Reply: User | null }>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
return userService.getUserById(id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
|
||||||
|
"/:id/ban",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const currentUser = request.user as { id: string };
|
||||||
|
|
||||||
|
if (currentUser.id === id) {
|
||||||
|
return reply.code(400).send({ error: "You cannot ban yourself" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userService.banUser(id);
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.USER_BAN,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
targetUserId: id,
|
||||||
|
details: `Banned user: ${user.username}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
|
||||||
|
"/:id/unban",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const user = await userService.unbanUser(id);
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.USER_UNBAN,
|
||||||
|
category: AuditCategory.ADMIN,
|
||||||
|
targetUserId: id,
|
||||||
|
details: `Unbanned user: ${user.username}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usersRoutes;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
import type { AuditAction, AuditCategory, AuditLogFilters } from "@library/shared-types";
|
||||||
|
|
||||||
|
interface AuditLogData {
|
||||||
|
action: AuditAction;
|
||||||
|
category: AuditCategory;
|
||||||
|
userId?: string;
|
||||||
|
targetUserId?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: string;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuditService = {
|
||||||
|
async log(data: AuditLogData, request?: FastifyRequest) {
|
||||||
|
const userAgent = request?.headers["user-agent"] ?? undefined;
|
||||||
|
|
||||||
|
return prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: data.action,
|
||||||
|
category: data.category,
|
||||||
|
userId: data.userId,
|
||||||
|
targetUserId: data.targetUserId,
|
||||||
|
resourceType: data.resourceType,
|
||||||
|
resourceId: data.resourceId,
|
||||||
|
details: data.details,
|
||||||
|
userAgent,
|
||||||
|
success: data.success ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async logFromRequest(
|
||||||
|
request: FastifyRequest,
|
||||||
|
data: Omit<AuditLogData, "userId">
|
||||||
|
) {
|
||||||
|
const userId = (request.user as { id?: string } | undefined)?.id;
|
||||||
|
|
||||||
|
return this.log(
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
request
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLogs(filters: AuditLogFilters = {}) {
|
||||||
|
const { action, category, userId, success, startDate, endDate, page = 1, limit = 50 } = filters;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
where.action = action;
|
||||||
|
}
|
||||||
|
if (category) {
|
||||||
|
where.category = category;
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
where.userId = userId;
|
||||||
|
}
|
||||||
|
if (success !== undefined) {
|
||||||
|
where.success = success;
|
||||||
|
}
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (startDate) {
|
||||||
|
(where.createdAt as Record<string, Date>).gte = startDate;
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
(where.createdAt as Record<string, Date>).lte = endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.auditLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLogsByUser(userId: string, page = 1, limit = 50) {
|
||||||
|
return this.getLogs({ userId, page, limit });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSecurityLogs(page = 1, limit = 50) {
|
||||||
|
return this.getLogs({ category: "SECURITY" as AuditCategory, page, limit });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -30,6 +30,29 @@ export class AuthService {
|
|||||||
return this.app.jwt.verify(token) as JwtPayload;
|
return this.app.jwt.verify(token) as JwtPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID from database.
|
||||||
|
*/
|
||||||
|
async getUserById(id: string): Promise<User | null> {
|
||||||
|
const dbUser = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dbUser.id,
|
||||||
|
discordId: dbUser.discordId,
|
||||||
|
username: dbUser.username,
|
||||||
|
email: dbUser.email,
|
||||||
|
avatarUrl: dbUser.avatar || undefined,
|
||||||
|
isAdmin: dbUser.isAdmin,
|
||||||
|
isBanned: dbUser.isBanned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or update user from Discord OAuth data.
|
* Create or update user from Discord OAuth data.
|
||||||
*/
|
*/
|
||||||
@@ -64,6 +87,7 @@ export class AuthService {
|
|||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
avatarUrl: dbUser.avatar || undefined,
|
avatarUrl: dbUser.avatar || undefined,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
|
isBanned: dbUser.isBanned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { BookService } from "./book.service";
|
|||||||
export { MusicService } from "./music.service";
|
export { MusicService } from "./music.service";
|
||||||
export { ShowService } from "./show.service";
|
export { ShowService } from "./show.service";
|
||||||
export { MangaService } from "./manga.service";
|
export { MangaService } from "./manga.service";
|
||||||
|
export { AuditService } from "./audit.service";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
|
||||||
|
export class UserService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
async getAllUsers(): Promise<User[]> {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
orderBy: { username: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatarUrl: user.avatar || undefined,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isBanned: user.isBanned,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(id: string): Promise<User | null> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatarUrl: user.avatar || undefined,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isBanned: user.isBanned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async banUser(id: string): Promise<User | null> {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isBanned: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatarUrl: user.avatar || undefined,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isBanned: user.isBanned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async unbanUser(id: string): Promise<User | null> {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isBanned: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
discordId: user.discordId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatarUrl: user.avatar || undefined,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isBanned: user.isBanned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async isUserBanned(id: string): Promise<boolean> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { isBanned: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return user?.isBanned ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,14 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'manga',
|
path: 'manga',
|
||||||
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent)
|
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/users',
|
||||||
|
loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/audit',
|
||||||
|
loadComponent: () => import('./components/admin/admin-audit.component').then(m => m.AdminAuditComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
redirectTo: ''
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { AuditLogService } from '../../services/audit.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-audit',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="audit-container">
|
||||||
|
<h1>Audit Logs</h1>
|
||||||
|
|
||||||
|
@if (!authService.isAuthenticated() || !authService.user()?.isAdmin) {
|
||||||
|
<div class="unauthorized">
|
||||||
|
<p>You must be an admin to view this page.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="category-filter">Category:</label>
|
||||||
|
<select
|
||||||
|
id="category-filter"
|
||||||
|
[(ngModel)]="selectedCategory"
|
||||||
|
(change)="loadLogs()"
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<option value="AUTH">Authentication</option>
|
||||||
|
<option value="CONTENT">Content</option>
|
||||||
|
<option value="ADMIN">Administration</option>
|
||||||
|
<option value="SECURITY">Security</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="action-filter">Action:</label>
|
||||||
|
<select
|
||||||
|
id="action-filter"
|
||||||
|
[(ngModel)]="selectedAction"
|
||||||
|
(change)="loadLogs()"
|
||||||
|
>
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
<option value="LOGIN">Login</option>
|
||||||
|
<option value="LOGOUT">Logout</option>
|
||||||
|
<option value="LOGIN_FAILED">Login Failed</option>
|
||||||
|
<option value="COMMENT_CREATE">Comment Created</option>
|
||||||
|
<option value="COMMENT_DELETE">Comment Deleted</option>
|
||||||
|
<option value="ENTRY_CREATE">Entry Created</option>
|
||||||
|
<option value="ENTRY_UPDATE">Entry Updated</option>
|
||||||
|
<option value="ENTRY_DELETE">Entry Deleted</option>
|
||||||
|
<option value="USER_BAN">User Banned</option>
|
||||||
|
<option value="USER_UNBAN">User Unbanned</option>
|
||||||
|
<option value="RATE_LIMIT_EXCEEDED">Rate Limit Exceeded</option>
|
||||||
|
<option value="CSRF_VALIDATION_FAILED">CSRF Failed</option>
|
||||||
|
<option value="UNAUTHORIZED_ACCESS">Unauthorized Access</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="success-filter">Status:</label>
|
||||||
|
<select
|
||||||
|
id="success-filter"
|
||||||
|
[(ngModel)]="selectedSuccess"
|
||||||
|
(change)="loadLogs()"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="true">Success</option>
|
||||||
|
<option value="false">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="refresh-btn" (click)="loadLogs()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading">Loading audit logs...</div>
|
||||||
|
} @else if (logs().length === 0) {
|
||||||
|
<div class="no-logs">No audit logs found.</div>
|
||||||
|
} @else {
|
||||||
|
<div class="logs-table-container">
|
||||||
|
<table class="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (log of logs(); track log.id) {
|
||||||
|
<tr [class.failed]="!log.success">
|
||||||
|
<td class="time">{{ formatDate(log.createdAt) }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="category-badge"
|
||||||
|
[style.background-color]="auditService.getCategoryColor(log.category)"
|
||||||
|
>
|
||||||
|
{{ auditService.getCategoryLabel(log.category) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ auditService.getActionLabel(log.action) }}</td>
|
||||||
|
<td class="details">{{ log.details ?? '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" [class.success]="log.success" [class.failed]="!log.success">
|
||||||
|
{{ log.success ? '✓' : '✗' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button
|
||||||
|
[disabled]="currentPage() <= 1"
|
||||||
|
(click)="goToPage(currentPage() - 1)"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>Page {{ currentPage() }} of {{ totalPages() }}</span>
|
||||||
|
<button
|
||||||
|
[disabled]="currentPage() >= totalPages()"
|
||||||
|
(click)="goToPage(currentPage() + 1)"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.audit-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #2d1b4e;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unauthorized {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .no-logs {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table th,
|
||||||
|
.logs-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table th {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table tr.failed {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-table tr.failed:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
max-width: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.failed {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #d1d5db;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:not(:disabled):hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class AdminAuditComponent implements OnInit {
|
||||||
|
authService = inject(AuthService);
|
||||||
|
auditService = inject(AuditLogService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
logs = signal<AuditLog[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
currentPage = signal(1);
|
||||||
|
totalPages = signal(1);
|
||||||
|
|
||||||
|
selectedCategory = '';
|
||||||
|
selectedAction = '';
|
||||||
|
selectedSuccess = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (!this.authService.isAuthenticated() || !this.authService.user()?.isAdmin) {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLogs() {
|
||||||
|
this.loading.set(true);
|
||||||
|
try {
|
||||||
|
const filters: Record<string, unknown> = {
|
||||||
|
page: this.currentPage(),
|
||||||
|
limit: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.selectedCategory) {
|
||||||
|
filters['category'] = this.selectedCategory as AuditCategory;
|
||||||
|
}
|
||||||
|
if (this.selectedAction) {
|
||||||
|
filters['action'] = this.selectedAction as AuditAction;
|
||||||
|
}
|
||||||
|
if (this.selectedSuccess !== '') {
|
||||||
|
filters['success'] = this.selectedSuccess === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.auditService.getLogs(filters);
|
||||||
|
this.logs.set(response.logs);
|
||||||
|
this.totalPages.set(response.totalPages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load audit logs:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page: number) {
|
||||||
|
this.currentPage.set(page);
|
||||||
|
this.loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { UserService } from '../../services/user.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { User } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-users',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="admin-container">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p class="loading">Loading users...</p>
|
||||||
|
} @else if (error()) {
|
||||||
|
<p class="error">{{ error() }}</p>
|
||||||
|
} @else {
|
||||||
|
<div class="users-list">
|
||||||
|
@for (user of users(); track user.id) {
|
||||||
|
<div class="user-card" [class.banned]="user.isBanned">
|
||||||
|
<div class="user-info">
|
||||||
|
@if (user.avatarUrl) {
|
||||||
|
<img [src]="user.avatarUrl" [alt]="user.username" class="avatar" />
|
||||||
|
} @else {
|
||||||
|
<div class="avatar-placeholder">{{ user.username.charAt(0).toUpperCase() }}</div>
|
||||||
|
}
|
||||||
|
<div class="user-details">
|
||||||
|
<span class="username">{{ user.username }}</span>
|
||||||
|
<span class="email">{{ user.email }}</span>
|
||||||
|
@if (user.isAdmin) {
|
||||||
|
<span class="admin-badge">Admin</span>
|
||||||
|
}
|
||||||
|
@if (user.isBanned) {
|
||||||
|
<span class="banned-badge">Banned</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-actions">
|
||||||
|
@if (!user.isAdmin) {
|
||||||
|
@if (user.isBanned) {
|
||||||
|
<button (click)="unbanUser(user.id)" class="btn btn-unban">Unban</button>
|
||||||
|
} @else {
|
||||||
|
<button (click)="banUser(user.id)" class="btn btn-ban">Ban</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="no-users">No users found.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.admin-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--witch-purple);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error, .no-users {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--witch-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--witch-moon);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px var(--witch-shadow);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card.banned {
|
||||||
|
opacity: 0.7;
|
||||||
|
background: var(--witch-lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--witch-purple);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--witch-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banned-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--witch-plum);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px var(--witch-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ban {
|
||||||
|
background: var(--witch-rose);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ban:hover {
|
||||||
|
background: var(--witch-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-unban {
|
||||||
|
background: var(--witch-purple);
|
||||||
|
color: var(--witch-moon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-unban:hover {
|
||||||
|
background: var(--witch-mauve);
|
||||||
|
color: var(--witch-purple);
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class AdminUsersComponent implements OnInit {
|
||||||
|
private userService = inject(UserService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
users = signal<User[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!this.authService.isAdmin()) {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadUsers(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
|
||||||
|
this.userService.getAllUsers().subscribe({
|
||||||
|
next: (users) => {
|
||||||
|
this.users.set(users);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error.set(err.message ?? 'Failed to load users');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
banUser(userId: string): void {
|
||||||
|
this.userService.banUser(userId).subscribe({
|
||||||
|
next: (updatedUser) => {
|
||||||
|
this.users.update(users =>
|
||||||
|
users.map(u => u.id === userId ? updatedUser : u)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error.set(err.message ?? 'Failed to ban user');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unbanUser(userId: string): void {
|
||||||
|
this.userService.unbanUser(userId).subscribe({
|
||||||
|
next: (updatedUser) => {
|
||||||
|
this.users.update(users =>
|
||||||
|
users.map(u => u.id === userId ? updatedUser : u)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error.set(err.message ?? 'Failed to unban user');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ArtService } from '../../services/art.service';
|
import { ArtService } from '../../services/art.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { CommentsService } from '../../services/comments.service';
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types';
|
import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -210,6 +211,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
|
|||||||
@if (expandedComments()[art.id]) {
|
@if (expandedComments()[art.id]) {
|
||||||
<div class="comments-container">
|
<div class="comments-container">
|
||||||
@if (authService.isAuthenticated()) {
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<form (ngSubmit)="addComment(art.id)" class="comment-form">
|
<form (ngSubmit)="addComment(art.id)" class="comment-form">
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="newCommentContent[art.id]"
|
[(ngModel)]="newCommentContent[art.id]"
|
||||||
@@ -220,6 +226,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
|
|||||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[art.id]) {
|
@if (commentsLoading()[art.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
@@ -236,7 +243,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
|
|||||||
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
@@ -621,12 +628,24 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
|
|||||||
color: var(--witch-mauve);
|
color: var(--witch-mauve);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class ArtGalleryComponent implements OnInit {
|
export class ArtGalleryComponent implements OnInit {
|
||||||
artService = inject(ArtService);
|
artService = inject(ArtService);
|
||||||
authService = inject(AuthService);
|
authService = inject(AuthService);
|
||||||
commentsService = inject(CommentsService);
|
commentsService = inject(CommentsService);
|
||||||
|
sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
artPieces = signal<Art[]>([]);
|
artPieces = signal<Art[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { BooksService } from '../../services/books.service';
|
import { BooksService } from '../../services/books.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { CommentsService } from '../../services/comments.service';
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types';
|
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -317,6 +318,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
|||||||
@if (expandedComments()[book.id]) {
|
@if (expandedComments()[book.id]) {
|
||||||
<div class="comments-container">
|
<div class="comments-container">
|
||||||
@if (authService.isAuthenticated()) {
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<form (ngSubmit)="addComment(book.id)" class="comment-form">
|
<form (ngSubmit)="addComment(book.id)" class="comment-form">
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="newCommentContent[book.id]"
|
[(ngModel)]="newCommentContent[book.id]"
|
||||||
@@ -327,6 +333,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
|||||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[book.id]) {
|
@if (commentsLoading()[book.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
@@ -343,7 +350,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
|||||||
<button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
<button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
@@ -703,6 +710,17 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -741,6 +759,7 @@ export class BooksListComponent implements OnInit {
|
|||||||
booksService = inject(BooksService);
|
booksService = inject(BooksService);
|
||||||
authService = inject(AuthService);
|
authService = inject(AuthService);
|
||||||
commentsService = inject(CommentsService);
|
commentsService = inject(CommentsService);
|
||||||
|
sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
books = signal<Book[]>([]);
|
books = signal<Book[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { GamesService } from '../../services/games.service';
|
import { GamesService } from '../../services/games.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { CommentsService } from '../../services/comments.service';
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@library/shared-types';
|
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -282,6 +283,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
@if (expandedComments()[game.id]) {
|
@if (expandedComments()[game.id]) {
|
||||||
<div class="comments-container">
|
<div class="comments-container">
|
||||||
@if (authService.isAuthenticated()) {
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<form (ngSubmit)="addComment(game.id)" class="comment-form">
|
<form (ngSubmit)="addComment(game.id)" class="comment-form">
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="newCommentContent[game.id]"
|
[(ngModel)]="newCommentContent[game.id]"
|
||||||
@@ -292,6 +298,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[game.id]) {
|
@if (commentsLoading()[game.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
@@ -308,7 +315,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
@@ -582,6 +589,17 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -620,6 +638,7 @@ export class GamesListComponent implements OnInit {
|
|||||||
gamesService = inject(GamesService);
|
gamesService = inject(GamesService);
|
||||||
authService = inject(AuthService);
|
authService = inject(AuthService);
|
||||||
commentsService = inject(CommentsService);
|
commentsService = inject(CommentsService);
|
||||||
|
sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
games = signal<Game[]>([]);
|
games = signal<Game[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
@if (authService.user(); as user) {
|
@if (authService.user(); as user) {
|
||||||
<span class="welcome">Welcome, {{ user.username }}!</span>
|
<span class="welcome">Welcome, {{ user.username }}!</span>
|
||||||
@if (user.isAdmin) {
|
@if (user.isAdmin) {
|
||||||
<span class="admin-badge">Admin</span>
|
<a routerLink="/admin/users" class="admin-badge">Users</a>
|
||||||
|
<a routerLink="/admin/audit" class="admin-badge">Audit</a>
|
||||||
}
|
}
|
||||||
<button (click)="logout()" class="btn btn-secondary">Logout</button>
|
<button (click)="logout()" class="btn btn-secondary">Logout</button>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -111,6 +112,14 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge:hover {
|
||||||
|
background-color: var(--witch-plum);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MangaService } from '../../services/manga.service';
|
import { MangaService } from '../../services/manga.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { CommentsService } from '../../services/comments.service';
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types';
|
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -282,6 +283,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
|
|||||||
@if (expandedComments()[manga.id]) {
|
@if (expandedComments()[manga.id]) {
|
||||||
<div class="comments-container">
|
<div class="comments-container">
|
||||||
@if (authService.isAuthenticated()) {
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<form (ngSubmit)="addComment(manga.id)" class="comment-form">
|
<form (ngSubmit)="addComment(manga.id)" class="comment-form">
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="newCommentContent[manga.id]"
|
[(ngModel)]="newCommentContent[manga.id]"
|
||||||
@@ -292,6 +298,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
|
|||||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[manga.id]) {
|
@if (commentsLoading()[manga.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
@@ -308,7 +315,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
|
|||||||
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
@@ -584,6 +591,17 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -622,6 +640,7 @@ export class MangaListComponent implements OnInit {
|
|||||||
mangaService = inject(MangaService);
|
mangaService = inject(MangaService);
|
||||||
authService = inject(AuthService);
|
authService = inject(AuthService);
|
||||||
commentsService = inject(CommentsService);
|
commentsService = inject(CommentsService);
|
||||||
|
sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
mangaList = signal<Manga[]>([]);
|
mangaList = signal<Manga[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MusicService } from '../../services/music.service';
|
import { MusicService } from '../../services/music.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { CommentsService } from '../../services/comments.service';
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } from '@library/shared-types';
|
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -355,6 +356,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
|||||||
@if (expandedComments()[music.id]) {
|
@if (expandedComments()[music.id]) {
|
||||||
<div class="comments-container">
|
<div class="comments-container">
|
||||||
@if (authService.isAuthenticated()) {
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<form (ngSubmit)="addComment(music.id)" class="comment-form">
|
<form (ngSubmit)="addComment(music.id)" class="comment-form">
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="newCommentContent[music.id]"
|
[(ngModel)]="newCommentContent[music.id]"
|
||||||
@@ -365,6 +371,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
|||||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[music.id]) {
|
@if (commentsLoading()[music.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
@@ -381,7 +388,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
|||||||
<button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
<button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
@@ -775,6 +782,17 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -813,6 +831,7 @@ export class MusicListComponent implements OnInit {
|
|||||||
musicService = inject(MusicService);
|
musicService = inject(MusicService);
|
||||||
authService = inject(AuthService);
|
authService = inject(AuthService);
|
||||||
commentsService = inject(CommentsService);
|
commentsService = inject(CommentsService);
|
||||||
|
sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
music = signal<Music[]>([]);
|
music = signal<Music[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ShowsService } from '../../services/shows.service';
|
import { ShowsService } from '../../services/shows.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { CommentsService } from '../../services/comments.service';
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types';
|
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -278,6 +279,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
|
|||||||
@if (expandedComments()[show.id]) {
|
@if (expandedComments()[show.id]) {
|
||||||
<div class="comments-container">
|
<div class="comments-container">
|
||||||
@if (authService.isAuthenticated()) {
|
@if (authService.isAuthenticated()) {
|
||||||
|
@if (authService.user()?.isBanned) {
|
||||||
|
<div class="banned-notice">
|
||||||
|
You have been banned from commenting.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<form (ngSubmit)="addComment(show.id)" class="comment-form">
|
<form (ngSubmit)="addComment(show.id)" class="comment-form">
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="newCommentContent[show.id]"
|
[(ngModel)]="newCommentContent[show.id]"
|
||||||
@@ -288,6 +294,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
|
|||||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (commentsLoading()[show.id]) {
|
@if (commentsLoading()[show.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
@@ -304,7 +311,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
|
|||||||
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
@@ -579,6 +586,17 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banned-notice {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -617,6 +635,7 @@ export class ShowsListComponent implements OnInit {
|
|||||||
showsService = inject(ShowsService);
|
showsService = inject(ShowsService);
|
||||||
authService = inject(AuthService);
|
authService = inject(AuthService);
|
||||||
commentsService = inject(CommentsService);
|
commentsService = inject(CommentsService);
|
||||||
|
sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
shows = signal<Show[]>([]);
|
shows = signal<Show[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
|||||||
@@ -4,50 +4,85 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable, signal, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, switchMap, tap, of } from 'rxjs';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
interface CsrfTokenResponse {
|
||||||
|
csrfToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ApiService {
|
export class ApiService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
private readonly apiUrl = environment.apiUrl;
|
private readonly apiUrl = environment.apiUrl;
|
||||||
|
private csrfToken = signal<string | null>(null);
|
||||||
constructor(private http: HttpClient) {}
|
|
||||||
|
|
||||||
private getHeaders(): HttpHeaders {
|
private getHeaders(): HttpHeaders {
|
||||||
// Auth token is sent as httpOnly cookie, no need to add manually
|
const headers: Record<string, string> = {
|
||||||
return new HttpHeaders({
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const token = this.csrfToken();
|
||||||
|
if (token) {
|
||||||
|
headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HttpHeaders(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCsrfToken(): Observable<string> {
|
||||||
|
const existingToken = this.csrfToken();
|
||||||
|
if (existingToken) {
|
||||||
|
return of(existingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<CsrfTokenResponse>(`${this.apiUrl}/auth/csrf-token`, {
|
||||||
|
withCredentials: true
|
||||||
|
}).pipe(
|
||||||
|
tap(response => this.csrfToken.set(response.csrfToken)),
|
||||||
|
switchMap(response => of(response.csrfToken))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T>(endpoint: string): Observable<T> {
|
get<T>(endpoint: string): Observable<T> {
|
||||||
return this.http.get<T>(`${this.apiUrl}${endpoint}`, {
|
return this.http.get<T>(`${this.apiUrl}${endpoint}`, {
|
||||||
headers: this.getHeaders(),
|
|
||||||
withCredentials: true // Important for cookies
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
post<T>(endpoint: string, body: any): Observable<T> {
|
|
||||||
return this.http.post<T>(`${this.apiUrl}${endpoint}`, body, {
|
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
put<T>(endpoint: string, body: any): Observable<T> {
|
post<T>(endpoint: string, body: unknown): Observable<T> {
|
||||||
return this.http.put<T>(`${this.apiUrl}${endpoint}`, body, {
|
return this.ensureCsrfToken().pipe(
|
||||||
|
switchMap(() => this.http.post<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
});
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
put<T>(endpoint: string, body: unknown): Observable<T> {
|
||||||
|
return this.ensureCsrfToken().pipe(
|
||||||
|
switchMap(() => this.http.put<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
withCredentials: true
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete<T>(endpoint: string): Observable<T> {
|
delete<T>(endpoint: string): Observable<T> {
|
||||||
return this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
|
return this.ensureCsrfToken().pipe(
|
||||||
|
switchMap(() => this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
});
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCsrfToken(): void {
|
||||||
|
this.csrfToken.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import type { AuditLog, AuditLogFilters, AuditAction, AuditCategory } from '@library/shared-types';
|
||||||
|
|
||||||
|
interface AuditLogResponse {
|
||||||
|
logs: AuditLog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuditLogService {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
|
||||||
|
async getLogs(filters: AuditLogFilters = {}): Promise<AuditLogResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.action) params.set('action', filters.action);
|
||||||
|
if (filters.category) params.set('category', filters.category);
|
||||||
|
if (filters.userId) params.set('userId', filters.userId);
|
||||||
|
if (filters.success !== undefined) params.set('success', String(filters.success));
|
||||||
|
if (filters.startDate) params.set('startDate', filters.startDate.toISOString());
|
||||||
|
if (filters.endDate) params.set('endDate', filters.endDate.toISOString());
|
||||||
|
if (filters.page) params.set('page', String(filters.page));
|
||||||
|
if (filters.limit) params.set('limit', String(filters.limit));
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `/audit?${queryString}` : '/audit';
|
||||||
|
|
||||||
|
return firstValueFrom(this.api.get<AuditLogResponse>(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSecurityLogs(page = 1, limit = 50): Promise<AuditLogResponse> {
|
||||||
|
return firstValueFrom(this.api.get<AuditLogResponse>(`/audit/security?page=${page}&limit=${limit}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserLogs(userId: string, page = 1, limit = 50): Promise<AuditLogResponse> {
|
||||||
|
return firstValueFrom(this.api.get<AuditLogResponse>(`/audit/user/${userId}?page=${page}&limit=${limit}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
getActionLabel(action: AuditAction): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
LOGIN: 'Login',
|
||||||
|
LOGOUT: 'Logout',
|
||||||
|
LOGIN_FAILED: 'Login Failed',
|
||||||
|
COMMENT_CREATE: 'Comment Created',
|
||||||
|
COMMENT_DELETE: 'Comment Deleted',
|
||||||
|
ENTRY_CREATE: 'Entry Created',
|
||||||
|
ENTRY_UPDATE: 'Entry Updated',
|
||||||
|
ENTRY_DELETE: 'Entry Deleted',
|
||||||
|
USER_BAN: 'User Banned',
|
||||||
|
USER_UNBAN: 'User Unbanned',
|
||||||
|
RATE_LIMIT_EXCEEDED: 'Rate Limit Exceeded',
|
||||||
|
CSRF_VALIDATION_FAILED: 'CSRF Validation Failed',
|
||||||
|
UNAUTHORIZED_ACCESS: 'Unauthorized Access',
|
||||||
|
};
|
||||||
|
return labels[action] ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryLabel(category: AuditCategory): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
AUTH: 'Authentication',
|
||||||
|
CONTENT: 'Content',
|
||||||
|
ADMIN: 'Administration',
|
||||||
|
SECURITY: 'Security',
|
||||||
|
};
|
||||||
|
return labels[category] ?? category;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryColor(category: AuditCategory): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
AUTH: '#3b82f6', // blue
|
||||||
|
CONTENT: '#10b981', // green
|
||||||
|
ADMIN: '#8b5cf6', // purple
|
||||||
|
SECURITY: '#ef4444', // red
|
||||||
|
};
|
||||||
|
return colors[category] ?? '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export class AuthService {
|
|||||||
return this.api.post<{ message: string }>('/auth/logout', {}).pipe(
|
return this.api.post<{ message: string }>('/auth/logout', {}).pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.currentUser.set(null);
|
this.currentUser.set(null);
|
||||||
|
this.api.clearCsrfToken();
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, SecurityContext, inject } from '@angular/core';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for sanitizing HTML content on the frontend.
|
||||||
|
* Provides defence-in-depth XSS protection alongside backend sanitization.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class SanitizeService {
|
||||||
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes HTML content for safe rendering.
|
||||||
|
* This provides a second layer of protection after backend sanitization.
|
||||||
|
*/
|
||||||
|
sanitizeHtml(html: string): SafeHtml {
|
||||||
|
const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, html);
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(sanitized ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { User } from '@library/shared-types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class UserService {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
|
||||||
|
getAllUsers(): Observable<User[]> {
|
||||||
|
return this.api.get<User[]>('/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
banUser(userId: string): Observable<User> {
|
||||||
|
return this.api.post<User>(`/users/${userId}/ban`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
unbanUser(userId: string): Observable<User> {
|
||||||
|
return this.api.post<User>(`/users/${userId}/unban`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -24,13 +24,17 @@
|
|||||||
"@angular/router": "21.1.2",
|
"@angular/router": "21.1.2",
|
||||||
"@fastify/autoload": "6.0.3",
|
"@fastify/autoload": "6.0.3",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/cors": "^11.0.0",
|
||||||
|
"@fastify/csrf-protection": "^7.1.0",
|
||||||
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/oauth2": "^8.1.2",
|
"@fastify/oauth2": "^8.1.2",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/sensible": "6.0.4",
|
"@fastify/sensible": "6.0.4",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@prisma/client": "6.19.2",
|
"@prisma/client": "6.19.2",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"fastify": "5.2.2",
|
"fastify": "5.7.3",
|
||||||
"fastify-plugin": "5.0.1",
|
"fastify-plugin": "5.0.1",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
|
|||||||
Generated
+80
-22
@@ -32,12 +32,24 @@ importers:
|
|||||||
'@fastify/cookie':
|
'@fastify/cookie':
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.2
|
version: 11.0.2
|
||||||
|
'@fastify/cors':
|
||||||
|
specifier: ^11.0.0
|
||||||
|
version: 11.2.0
|
||||||
|
'@fastify/csrf-protection':
|
||||||
|
specifier: ^7.1.0
|
||||||
|
version: 7.1.0
|
||||||
|
'@fastify/helmet':
|
||||||
|
specifier: ^13.0.2
|
||||||
|
version: 13.0.2
|
||||||
'@fastify/jwt':
|
'@fastify/jwt':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
'@fastify/oauth2':
|
'@fastify/oauth2':
|
||||||
specifier: ^8.1.2
|
specifier: ^8.1.2
|
||||||
version: 8.1.2
|
version: 8.1.2
|
||||||
|
'@fastify/rate-limit':
|
||||||
|
specifier: ^10.3.0
|
||||||
|
version: 10.3.0
|
||||||
'@fastify/sensible':
|
'@fastify/sensible':
|
||||||
specifier: 6.0.4
|
specifier: 6.0.4
|
||||||
version: 6.0.4
|
version: 6.0.4
|
||||||
@@ -51,8 +63,8 @@ importers:
|
|||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
fastify:
|
fastify:
|
||||||
specifier: 5.2.2
|
specifier: 5.7.3
|
||||||
version: 5.2.2
|
version: 5.7.3
|
||||||
fastify-plugin:
|
fastify-plugin:
|
||||||
specifier: 5.0.1
|
specifier: 5.0.1
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
@@ -1599,6 +1611,15 @@ packages:
|
|||||||
'@fastify/cookie@11.0.2':
|
'@fastify/cookie@11.0.2':
|
||||||
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
||||||
|
|
||||||
|
'@fastify/cors@11.2.0':
|
||||||
|
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||||
|
|
||||||
|
'@fastify/csrf-protection@7.1.0':
|
||||||
|
resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==}
|
||||||
|
|
||||||
|
'@fastify/csrf@8.0.1':
|
||||||
|
resolution: {integrity: sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw==}
|
||||||
|
|
||||||
'@fastify/error@4.2.0':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
@@ -1608,6 +1629,9 @@ packages:
|
|||||||
'@fastify/forwarded@3.0.1':
|
'@fastify/forwarded@3.0.1':
|
||||||
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
||||||
|
|
||||||
|
'@fastify/helmet@13.0.2':
|
||||||
|
resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==}
|
||||||
|
|
||||||
'@fastify/jwt@10.0.0':
|
'@fastify/jwt@10.0.0':
|
||||||
resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==}
|
resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==}
|
||||||
|
|
||||||
@@ -1620,6 +1644,9 @@ packages:
|
|||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||||
|
|
||||||
|
'@fastify/rate-limit@10.3.0':
|
||||||
|
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
|
||||||
|
|
||||||
'@fastify/send@4.1.0':
|
'@fastify/send@4.1.0':
|
||||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
||||||
|
|
||||||
@@ -5378,8 +5405,8 @@ packages:
|
|||||||
fastify-plugin@5.0.1:
|
fastify-plugin@5.0.1:
|
||||||
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
|
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
|
||||||
|
|
||||||
fastify@5.2.2:
|
fastify@5.7.3:
|
||||||
resolution: {integrity: sha512-22T/PnhquWozuFXg3Ish4md5ipsF1Nx1mJ9ulLdZPXSk14WFj/wMlyNB/yll9sQOojKRgOIxT2inK3Xpjg5hyw==}
|
resolution: {integrity: sha512-QHzWSmTNUg9Ba8tNXzb92FTH77K+c8yeQPH80EeSIc9wyZj85jbPisMP0rwmyKv8oJwUFPe1UpN8HkNIXwCnUQ==}
|
||||||
|
|
||||||
fastparallel@2.4.1:
|
fastparallel@2.4.1:
|
||||||
resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==}
|
resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==}
|
||||||
@@ -5745,6 +5772,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
helmet@8.1.0:
|
||||||
|
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
homedir-polyfill@1.0.3:
|
homedir-polyfill@1.0.3:
|
||||||
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
|
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -7298,14 +7329,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
pino-abstract-transport@2.0.0:
|
pino-abstract-transport@3.0.0:
|
||||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||||
|
|
||||||
pino-std-serializers@7.1.0:
|
pino-std-serializers@7.1.0:
|
||||||
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||||
|
|
||||||
pino@9.14.0:
|
pino@10.3.0:
|
||||||
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
|
resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
pirates@4.0.7:
|
pirates@4.0.7:
|
||||||
@@ -8090,8 +8121,8 @@ packages:
|
|||||||
secure-compare@3.0.1:
|
secure-compare@3.0.1:
|
||||||
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
|
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
|
||||||
|
|
||||||
secure-json-parse@3.0.2:
|
secure-json-parse@4.1.0:
|
||||||
resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==}
|
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||||
|
|
||||||
select-hose@2.0.0:
|
select-hose@2.0.0:
|
||||||
resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
|
resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
|
||||||
@@ -8538,8 +8569,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
tslib: ^2
|
tslib: ^2
|
||||||
|
|
||||||
thread-stream@3.1.0:
|
thread-stream@4.0.0:
|
||||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
throttleit@1.0.1:
|
throttleit@1.0.1:
|
||||||
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
||||||
@@ -11526,6 +11558,19 @@ snapshots:
|
|||||||
cookie: 1.1.1
|
cookie: 1.1.1
|
||||||
fastify-plugin: 5.0.1
|
fastify-plugin: 5.0.1
|
||||||
|
|
||||||
|
'@fastify/cors@11.2.0':
|
||||||
|
dependencies:
|
||||||
|
fastify-plugin: 5.0.1
|
||||||
|
toad-cache: 3.7.0
|
||||||
|
|
||||||
|
'@fastify/csrf-protection@7.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@fastify/csrf': 8.0.1
|
||||||
|
'@fastify/error': 4.2.0
|
||||||
|
fastify-plugin: 5.0.1
|
||||||
|
|
||||||
|
'@fastify/csrf@8.0.1': {}
|
||||||
|
|
||||||
'@fastify/error@4.2.0': {}
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||||
@@ -11534,6 +11579,11 @@ snapshots:
|
|||||||
|
|
||||||
'@fastify/forwarded@3.0.1': {}
|
'@fastify/forwarded@3.0.1': {}
|
||||||
|
|
||||||
|
'@fastify/helmet@13.0.2':
|
||||||
|
dependencies:
|
||||||
|
fastify-plugin: 5.0.1
|
||||||
|
helmet: 8.1.0
|
||||||
|
|
||||||
'@fastify/jwt@10.0.0':
|
'@fastify/jwt@10.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/error': 4.2.0
|
'@fastify/error': 4.2.0
|
||||||
@@ -11559,6 +11609,12 @@ snapshots:
|
|||||||
'@fastify/forwarded': 3.0.1
|
'@fastify/forwarded': 3.0.1
|
||||||
ipaddr.js: 2.3.0
|
ipaddr.js: 2.3.0
|
||||||
|
|
||||||
|
'@fastify/rate-limit@10.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@lukeed/ms': 2.0.2
|
||||||
|
fastify-plugin: 5.0.1
|
||||||
|
toad-cache: 3.7.0
|
||||||
|
|
||||||
'@fastify/send@4.1.0':
|
'@fastify/send@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lukeed/ms': 2.0.2
|
'@lukeed/ms': 2.0.2
|
||||||
@@ -16392,7 +16448,7 @@ snapshots:
|
|||||||
|
|
||||||
fastify-plugin@5.0.1: {}
|
fastify-plugin@5.0.1: {}
|
||||||
|
|
||||||
fastify@5.2.2:
|
fastify@5.7.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/ajv-compiler': 4.0.5
|
'@fastify/ajv-compiler': 4.0.5
|
||||||
'@fastify/error': 4.2.0
|
'@fastify/error': 4.2.0
|
||||||
@@ -16403,10 +16459,10 @@ snapshots:
|
|||||||
fast-json-stringify: 6.2.0
|
fast-json-stringify: 6.2.0
|
||||||
find-my-way: 9.4.0
|
find-my-way: 9.4.0
|
||||||
light-my-request: 6.6.0
|
light-my-request: 6.6.0
|
||||||
pino: 9.14.0
|
pino: 10.3.0
|
||||||
process-warning: 4.0.1
|
process-warning: 5.0.0
|
||||||
rfdc: 1.4.1
|
rfdc: 1.4.1
|
||||||
secure-json-parse: 3.0.2
|
secure-json-parse: 4.1.0
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
toad-cache: 3.7.0
|
toad-cache: 3.7.0
|
||||||
|
|
||||||
@@ -16814,6 +16870,8 @@ snapshots:
|
|||||||
|
|
||||||
he@1.2.0: {}
|
he@1.2.0: {}
|
||||||
|
|
||||||
|
helmet@8.1.0: {}
|
||||||
|
|
||||||
homedir-polyfill@1.0.3:
|
homedir-polyfill@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
parse-passwd: 1.0.0
|
parse-passwd: 1.0.0
|
||||||
@@ -18687,25 +18745,25 @@ snapshots:
|
|||||||
pify@4.0.1:
|
pify@4.0.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
pino-abstract-transport@2.0.0:
|
pino-abstract-transport@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
split2: 4.2.0
|
split2: 4.2.0
|
||||||
|
|
||||||
pino-std-serializers@7.1.0: {}
|
pino-std-serializers@7.1.0: {}
|
||||||
|
|
||||||
pino@9.14.0:
|
pino@10.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@pinojs/redact': 0.4.0
|
'@pinojs/redact': 0.4.0
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
on-exit-leak-free: 2.1.2
|
on-exit-leak-free: 2.1.2
|
||||||
pino-abstract-transport: 2.0.0
|
pino-abstract-transport: 3.0.0
|
||||||
pino-std-serializers: 7.1.0
|
pino-std-serializers: 7.1.0
|
||||||
process-warning: 5.0.0
|
process-warning: 5.0.0
|
||||||
quick-format-unescaped: 4.0.4
|
quick-format-unescaped: 4.0.4
|
||||||
real-require: 0.2.0
|
real-require: 0.2.0
|
||||||
safe-stable-stringify: 2.5.0
|
safe-stable-stringify: 2.5.0
|
||||||
sonic-boom: 4.2.0
|
sonic-boom: 4.2.0
|
||||||
thread-stream: 3.1.0
|
thread-stream: 4.0.0
|
||||||
|
|
||||||
pirates@4.0.7: {}
|
pirates@4.0.7: {}
|
||||||
|
|
||||||
@@ -19516,7 +19574,7 @@ snapshots:
|
|||||||
|
|
||||||
secure-compare@3.0.1: {}
|
secure-compare@3.0.1: {}
|
||||||
|
|
||||||
secure-json-parse@3.0.2: {}
|
secure-json-parse@4.1.0: {}
|
||||||
|
|
||||||
select-hose@2.0.0: {}
|
select-hose@2.0.0: {}
|
||||||
|
|
||||||
@@ -20102,7 +20160,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
thread-stream@3.1.0:
|
thread-stream@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
real-require: 0.2.0
|
real-require: 0.2.0
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from "./lib/show.types";
|
|||||||
export * from "./lib/manga.types";
|
export * from "./lib/manga.types";
|
||||||
export type * from "./lib/auth.types";
|
export type * from "./lib/auth.types";
|
||||||
export * from "./lib/comment.types";
|
export * from "./lib/comment.types";
|
||||||
|
export * from "./lib/audit.types";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export enum AuditAction {
|
||||||
|
LOGIN = "LOGIN",
|
||||||
|
LOGOUT = "LOGOUT",
|
||||||
|
LOGIN_FAILED = "LOGIN_FAILED",
|
||||||
|
COMMENT_CREATE = "COMMENT_CREATE",
|
||||||
|
COMMENT_DELETE = "COMMENT_DELETE",
|
||||||
|
ENTRY_CREATE = "ENTRY_CREATE",
|
||||||
|
ENTRY_UPDATE = "ENTRY_UPDATE",
|
||||||
|
ENTRY_DELETE = "ENTRY_DELETE",
|
||||||
|
USER_BAN = "USER_BAN",
|
||||||
|
USER_UNBAN = "USER_UNBAN",
|
||||||
|
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
|
||||||
|
CSRF_VALIDATION_FAILED = "CSRF_VALIDATION_FAILED",
|
||||||
|
UNAUTHORIZED_ACCESS = "UNAUTHORIZED_ACCESS",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AuditCategory {
|
||||||
|
AUTH = "AUTH",
|
||||||
|
CONTENT = "CONTENT",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
SECURITY = "SECURITY",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
action: AuditAction;
|
||||||
|
category: AuditCategory;
|
||||||
|
userId?: string;
|
||||||
|
targetUserId?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
success: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
action?: AuditAction;
|
||||||
|
category?: AuditCategory;
|
||||||
|
userId?: string;
|
||||||
|
success?: boolean;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export interface User {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
discordId: string;
|
discordId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isBanned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user