feat: add enterprise profile center, announcements, search, and module access controls
This commit is contained in:
@@ -21,6 +21,7 @@ import clientRoutes from "./routes/client.routes";
|
||||
import profileRoutes from "./routes/profile.routes";
|
||||
import adminUsersRoutes from "./routes/admin-users.routes";
|
||||
import systemRoutes from "./routes/system.routes";
|
||||
import announcementsRoutes from "./routes/announcements.routes";
|
||||
import { errorHandler, notFoundHandler } from "./middleware/error-handler";
|
||||
import { createRateLimit } from "./middleware/rate-limit";
|
||||
|
||||
@@ -88,6 +89,7 @@ export function createApp() {
|
||||
app.use("/api/profile", profileRoutes);
|
||||
app.use("/api/admin", adminUsersRoutes);
|
||||
app.use("/api/system", systemRoutes);
|
||||
app.use("/api/announcements", announcementsRoutes);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
189
backend/src/routes/announcements.routes.ts
Normal file
189
backend/src/routes/announcements.routes.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Role } from "@prisma/client";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { authorize, requireAuth } from "../middleware/auth";
|
||||
import { HttpError } from "../lib/http-error";
|
||||
import { logAudit } from "../services/audit.service";
|
||||
import { toPrismaJsonValue } from "../lib/prisma-json";
|
||||
import {
|
||||
ANNOUNCEMENT_SEVERITIES,
|
||||
buildInboxForUser,
|
||||
deleteAnnouncement,
|
||||
getAnnouncementState,
|
||||
markAllAnnouncementsRead,
|
||||
markAnnouncementRead,
|
||||
upsertAnnouncement
|
||||
} from "../services/announcement.service";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const roleEnum = z.nativeEnum(Role);
|
||||
const announcementSeverityEnum = z.enum(ANNOUNCEMENT_SEVERITIES);
|
||||
|
||||
const createAnnouncementSchema = z.object({
|
||||
title: z.string().min(2).max(180),
|
||||
message: z.string().min(2).max(5000),
|
||||
severity: announcementSeverityEnum.default("INFO"),
|
||||
audience_roles: z.array(roleEnum).default([]),
|
||||
is_active: z.boolean().default(true),
|
||||
published_at: z.string().datetime().optional(),
|
||||
expires_at: z.string().datetime().nullable().optional()
|
||||
});
|
||||
|
||||
const updateAnnouncementSchema = createAnnouncementSchema.partial();
|
||||
|
||||
router.get("/inbox", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const state = await getAnnouncementState();
|
||||
const inbox = buildInboxForUser(state, {
|
||||
user_id: req.user!.id,
|
||||
role: req.user!.role
|
||||
});
|
||||
return res.json(inbox);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/read", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await markAnnouncementRead({
|
||||
user_id: req.user!.id,
|
||||
announcement_id: req.params.id
|
||||
});
|
||||
if (!result.updated) {
|
||||
throw new HttpError(404, "Announcement not found", "ANNOUNCEMENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
const state = await getAnnouncementState();
|
||||
const inbox = buildInboxForUser(state, {
|
||||
user_id: req.user!.id,
|
||||
role: req.user!.role
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
unread_count: inbox.unread_count
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/read-all", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await markAllAnnouncementsRead({
|
||||
user_id: req.user!.id,
|
||||
role: req.user!.role
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
marked_count: result.updated
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/admin", requireAuth, authorize("settings:manage"), async (_req, res, next) => {
|
||||
try {
|
||||
const state = await getAnnouncementState();
|
||||
return res.json({
|
||||
items: state.items,
|
||||
total_count: state.items.length
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = createAnnouncementSchema.parse(req.body ?? {});
|
||||
const announcement = await upsertAnnouncement({
|
||||
...payload,
|
||||
actor_email: req.user!.email
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
action: "announcement.create",
|
||||
resource_type: "SYSTEM",
|
||||
resource_id: announcement.id,
|
||||
resource_name: announcement.title,
|
||||
actor_email: req.user!.email,
|
||||
actor_role: req.user!.role,
|
||||
details: toPrismaJsonValue(payload),
|
||||
ip_address: req.ip
|
||||
});
|
||||
|
||||
return res.status(201).json(announcement);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/admin/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = updateAnnouncementSchema.parse(req.body ?? {});
|
||||
if (Object.keys(payload).length === 0) {
|
||||
throw new HttpError(400, "No fields provided", "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
const state = await getAnnouncementState();
|
||||
const existing = state.items.find((item) => item.id === req.params.id);
|
||||
if (!existing) {
|
||||
throw new HttpError(404, "Announcement not found", "ANNOUNCEMENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
const announcement = await upsertAnnouncement({
|
||||
id: req.params.id,
|
||||
title: payload.title ?? existing.title,
|
||||
message: payload.message ?? existing.message,
|
||||
severity: payload.severity ?? existing.severity,
|
||||
audience_roles: payload.audience_roles ?? existing.audience_roles,
|
||||
is_active: payload.is_active ?? existing.is_active,
|
||||
published_at: payload.published_at ?? existing.published_at,
|
||||
expires_at: payload.expires_at ?? existing.expires_at,
|
||||
actor_email: req.user!.email
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
action: "announcement.update",
|
||||
resource_type: "SYSTEM",
|
||||
resource_id: announcement.id,
|
||||
resource_name: announcement.title,
|
||||
actor_email: req.user!.email,
|
||||
actor_role: req.user!.role,
|
||||
details: toPrismaJsonValue({ updated_fields: Object.keys(payload) }),
|
||||
ip_address: req.ip
|
||||
});
|
||||
|
||||
return res.json(announcement);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/admin/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const result = await deleteAnnouncement(req.params.id);
|
||||
if (!result.deleted) {
|
||||
throw new HttpError(404, "Announcement not found", "ANNOUNCEMENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
action: "announcement.delete",
|
||||
resource_type: "SYSTEM",
|
||||
resource_id: req.params.id,
|
||||
resource_name: req.params.id,
|
||||
actor_email: req.user!.email,
|
||||
actor_role: req.user!.role,
|
||||
ip_address: req.ip
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -8,6 +8,7 @@ import { HttpError } from "../lib/http-error";
|
||||
import { createJwtToken, createRefreshToken, requireAuth, verifyRefreshToken } from "../middleware/auth";
|
||||
import { consumeRecoveryCode, hashToken } from "../lib/security";
|
||||
import { verifyTotpCode } from "../lib/totp";
|
||||
import { getUserModuleAccess } from "../services/module-access.service";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -128,6 +129,7 @@ router.post("/login", async (req, res, next) => {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get("user-agent") ?? undefined
|
||||
});
|
||||
const moduleAccess = await getUserModuleAccess(user.role);
|
||||
|
||||
res.json({
|
||||
token: tokens.token,
|
||||
@@ -140,8 +142,10 @@ router.post("/login", async (req, res, next) => {
|
||||
role: user.role,
|
||||
tenant_id: user.tenant_id,
|
||||
avatar_url: user.avatar_url,
|
||||
mfa_enabled: user.mfa_enabled
|
||||
}
|
||||
mfa_enabled: user.mfa_enabled,
|
||||
module_access: moduleAccess.access
|
||||
},
|
||||
module_access: moduleAccess.access
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -278,7 +282,11 @@ router.get("/me", requireAuth, async (req, res, next) => {
|
||||
});
|
||||
if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND");
|
||||
if (!user.is_active) throw new HttpError(401, "User account is inactive", "USER_INACTIVE");
|
||||
res.json(user);
|
||||
const moduleAccess = await getUserModuleAccess(user.role);
|
||||
res.json({
|
||||
...user,
|
||||
module_access: moduleAccess.access
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,18 @@ import { toPrismaJsonValue } from "../lib/prisma-json";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const imageDataUrlRegex = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/i;
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
full_name: z.string().min(1).max(120).optional(),
|
||||
avatar_url: z.string().url().max(500).optional(),
|
||||
phone: z.string().min(3).max(40).optional(),
|
||||
telephone: z.string().min(3).max(40).optional(),
|
||||
address: z.string().min(3).max(280).optional(),
|
||||
state: z.string().min(2).max(80).optional(),
|
||||
city: z.string().min(2).max(80).optional(),
|
||||
country: z.string().min(2).max(80).optional(),
|
||||
avatar_url: z.union([z.string().url().max(1000), z.string().regex(imageDataUrlRegex).max(1_500_000)]).optional(),
|
||||
avatar_data_url: z.string().regex(imageDataUrlRegex).max(1_500_000).optional(),
|
||||
profile_metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional()
|
||||
});
|
||||
|
||||
@@ -36,6 +45,36 @@ const mfaDisableSchema = z.object({
|
||||
code: z.string().min(6).max(8).optional()
|
||||
});
|
||||
|
||||
const uploadAvatarSchema = z.object({
|
||||
data_url: z.string().regex(imageDataUrlRegex).max(1_500_000)
|
||||
});
|
||||
|
||||
function metadataObject(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function profileFieldsFromMetadata(value: unknown) {
|
||||
const meta = metadataObject(value);
|
||||
const asString = (entry: unknown) => (typeof entry === "string" ? entry : "");
|
||||
|
||||
return {
|
||||
phone: asString(meta.phone),
|
||||
telephone: asString(meta.telephone),
|
||||
address: asString(meta.address),
|
||||
state: asString(meta.state),
|
||||
city: asString(meta.city),
|
||||
country: asString(meta.country)
|
||||
};
|
||||
}
|
||||
|
||||
function withProfileFields<T extends { profile_metadata: unknown }>(user: T) {
|
||||
return {
|
||||
...user,
|
||||
profile_fields: profileFieldsFromMetadata(user.profile_metadata)
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -58,7 +97,7 @@ router.get("/", requireAuth, async (req, res, next) => {
|
||||
if (!user) {
|
||||
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
|
||||
}
|
||||
return res.json(user);
|
||||
return res.json(withProfileFields(user));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
@@ -71,9 +110,44 @@ router.patch("/", requireAuth, async (req, res, next) => {
|
||||
throw new HttpError(400, "No profile fields were provided", "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: {
|
||||
id: true,
|
||||
profile_metadata: true
|
||||
}
|
||||
});
|
||||
if (!existing) {
|
||||
throw new HttpError(404, "User not found", "USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
const nextMetadata = {
|
||||
...metadataObject(existing.profile_metadata),
|
||||
...(payload.profile_metadata ?? {})
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const metadataFields: Array<keyof ReturnType<typeof profileFieldsFromMetadata>> = [
|
||||
"phone",
|
||||
"telephone",
|
||||
"address",
|
||||
"state",
|
||||
"city",
|
||||
"country"
|
||||
];
|
||||
for (const field of metadataFields) {
|
||||
if (field in payload) {
|
||||
nextMetadata[field] = payload[field] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: payload,
|
||||
data: {
|
||||
...(payload.full_name !== undefined ? { full_name: payload.full_name } : {}),
|
||||
...(payload.avatar_url !== undefined ? { avatar_url: payload.avatar_url } : {}),
|
||||
...(payload.avatar_data_url !== undefined ? { avatar_url: payload.avatar_data_url } : {}),
|
||||
profile_metadata: toPrismaJsonValue(nextMetadata)
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
@@ -101,6 +175,39 @@ router.patch("/", requireAuth, async (req, res, next) => {
|
||||
ip_address: req.ip
|
||||
});
|
||||
|
||||
return res.json(withProfileFields(user));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/avatar", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const payload = uploadAvatarSchema.parse(req.body ?? {});
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: {
|
||||
avatar_url: payload.data_url
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
avatar_url: true,
|
||||
updated_at: true
|
||||
}
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
action: "profile.avatar.upload",
|
||||
resource_type: "USER",
|
||||
resource_id: user.id,
|
||||
resource_name: user.email,
|
||||
actor_email: req.user!.email,
|
||||
actor_role: req.user!.role,
|
||||
details: toPrismaJsonValue({ avatar_uploaded: true }),
|
||||
ip_address: req.ip
|
||||
});
|
||||
|
||||
return res.json(user);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { TenantStatus } from "@prisma/client";
|
||||
import { authorize, requireAuth } from "../middleware/auth";
|
||||
import { Role, TenantStatus } from "@prisma/client";
|
||||
import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { HttpError } from "../lib/http-error";
|
||||
import { logAudit } from "../services/audit.service";
|
||||
import { toPrismaJsonValue } from "../lib/prisma-json";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
getModulePolicy,
|
||||
getUserModuleAccess,
|
||||
toModulePolicyResponse,
|
||||
updateModulePolicy
|
||||
} from "../services/module-access.service";
|
||||
|
||||
const router = Router();
|
||||
const imageDataUrlRegex = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/i;
|
||||
|
||||
const brandingSchema = z.object({
|
||||
app_name: z.string().min(2).max(120),
|
||||
logo_url: z.string().url().optional(),
|
||||
logo_url: z.union([z.string().url(), z.string().regex(imageDataUrlRegex).max(1_500_000)]).optional(),
|
||||
primary_color: z.string().optional(),
|
||||
accent_color: z.string().optional(),
|
||||
support_email: z.string().email().optional(),
|
||||
@@ -50,6 +58,23 @@ const navItemSchema = z.object({
|
||||
metadata: z.record(z.string(), z.any()).default({})
|
||||
});
|
||||
|
||||
const modulePolicySchema = z.object({
|
||||
modules: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.enum(MODULE_KEYS),
|
||||
enabled: z.boolean(),
|
||||
roles: z.array(z.nativeEnum(Role)).min(1).max(4)
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
});
|
||||
|
||||
const systemSearchSchema = z.object({
|
||||
q: z.string().trim().min(2).max(120),
|
||||
limit: z.coerce.number().int().min(1).max(30).default(12)
|
||||
});
|
||||
|
||||
async function getSetting<T = unknown>(key: string, fallback: T): Promise<T> {
|
||||
const setting = await prisma.setting.findUnique({ where: { key } });
|
||||
if (!setting) return fallback;
|
||||
@@ -113,6 +138,263 @@ router.get("/branding", requireAuth, authorize("settings:read"), async (_req, re
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/module-access", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const access = await getUserModuleAccess(req.user!.role);
|
||||
return res.json(access);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/modules-policy", requireAuth, authorize("settings:manage"), async (_req, res, next) => {
|
||||
try {
|
||||
const policy = await getModulePolicy({ force_refresh: true });
|
||||
return res.json({
|
||||
modules: toModulePolicyResponse(policy)
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/modules-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = modulePolicySchema.parse(req.body ?? {});
|
||||
const policy = await updateModulePolicy(payload.modules);
|
||||
|
||||
await logAudit({
|
||||
action: "system.modules_policy.update",
|
||||
resource_type: "SYSTEM",
|
||||
resource_name: "module_policy",
|
||||
actor_email: req.user!.email,
|
||||
actor_role: req.user!.role,
|
||||
details: toPrismaJsonValue(payload),
|
||||
ip_address: req.ip
|
||||
});
|
||||
|
||||
return res.json({
|
||||
modules: toModulePolicyResponse(policy)
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/search", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = systemSearchSchema.parse({
|
||||
q: req.query.q,
|
||||
limit: req.query.limit
|
||||
});
|
||||
|
||||
const takePerType = Math.min(6, Math.max(2, Math.floor(parsed.limit / 2)));
|
||||
const isTenantScoped = isTenantScopedUser(req) && Boolean(req.user?.tenant_id);
|
||||
const tenantId = req.user?.tenant_id ?? null;
|
||||
|
||||
const vmWhere: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ name: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ ip_address: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ node: { contains: parsed.q, mode: "insensitive" } }
|
||||
]
|
||||
};
|
||||
if (isTenantScoped && tenantId) {
|
||||
vmWhere.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
const tenantWhere: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ name: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ owner_email: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ slug: { contains: parsed.q, mode: "insensitive" } }
|
||||
]
|
||||
};
|
||||
if (isTenantScoped && tenantId) {
|
||||
tenantWhere.id = tenantId;
|
||||
}
|
||||
|
||||
const userWhere: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ email: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ full_name: { contains: parsed.q, mode: "insensitive" } }
|
||||
]
|
||||
};
|
||||
if (isTenantScoped && tenantId) {
|
||||
userWhere.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
const invoiceWhere: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ invoice_number: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ tenant_name: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ payment_reference: { contains: parsed.q, mode: "insensitive" } }
|
||||
]
|
||||
};
|
||||
if (isTenantScoped && tenantId) {
|
||||
invoiceWhere.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
const [vms, tenants, users, invoices, logs, alerts] = await Promise.all([
|
||||
prisma.virtualMachine.findMany({
|
||||
where: vmWhere as any,
|
||||
take: takePerType,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
tenant_id: true,
|
||||
node: true,
|
||||
vmid: true,
|
||||
ip_address: true
|
||||
}
|
||||
}),
|
||||
prisma.tenant.findMany({
|
||||
where: tenantWhere as any,
|
||||
take: takePerType,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
owner_email: true
|
||||
}
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
where: userWhere as any,
|
||||
take: takePerType,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
full_name: true,
|
||||
role: true,
|
||||
tenant_id: true
|
||||
}
|
||||
}),
|
||||
prisma.invoice.findMany({
|
||||
where: invoiceWhere as any,
|
||||
take: takePerType,
|
||||
orderBy: { created_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
invoice_number: true,
|
||||
tenant_name: true,
|
||||
status: true,
|
||||
amount: true,
|
||||
currency: true
|
||||
}
|
||||
}),
|
||||
isTenantScoped
|
||||
? Promise.resolve([])
|
||||
: prisma.auditLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ action: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ resource_name: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ actor_email: { contains: parsed.q, mode: "insensitive" } }
|
||||
]
|
||||
},
|
||||
take: takePerType,
|
||||
orderBy: { created_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
actor_email: true,
|
||||
resource_name: true,
|
||||
created_at: true
|
||||
}
|
||||
}),
|
||||
prisma.monitoringAlertEvent.findMany({
|
||||
where: {
|
||||
...(isTenantScoped && tenantId ? { OR: [{ tenant_id: tenantId }, { tenant_id: null }] } : {}),
|
||||
OR: [
|
||||
{ title: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ message: { contains: parsed.q, mode: "insensitive" } },
|
||||
{ metric_key: { contains: parsed.q, mode: "insensitive" } }
|
||||
]
|
||||
},
|
||||
take: takePerType,
|
||||
orderBy: { created_at: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
vm_id: true,
|
||||
node_id: true
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const results = [
|
||||
...vms.map((item) => ({
|
||||
id: item.id,
|
||||
type: "virtual_machine",
|
||||
module_key: "vms",
|
||||
title: item.name,
|
||||
subtitle: `VM #${item.vmid} • ${item.status} • ${item.node}`,
|
||||
path: "/vms",
|
||||
context: item
|
||||
})),
|
||||
...tenants.map((item) => ({
|
||||
id: item.id,
|
||||
type: "tenant",
|
||||
module_key: "tenants",
|
||||
title: item.name,
|
||||
subtitle: `${item.slug} • ${item.status} • ${item.owner_email}`,
|
||||
path: "/tenants",
|
||||
context: item
|
||||
})),
|
||||
...users.map((item) => ({
|
||||
id: item.id,
|
||||
type: "user",
|
||||
module_key: "rbac",
|
||||
title: item.full_name || item.email,
|
||||
subtitle: `${item.email} • ${item.role}`,
|
||||
path: "/rbac",
|
||||
context: item
|
||||
})),
|
||||
...invoices.map((item) => ({
|
||||
id: item.id,
|
||||
type: "invoice",
|
||||
module_key: "billing",
|
||||
title: item.invoice_number,
|
||||
subtitle: `${item.tenant_name || "-"} • ${item.status} • ${item.amount} ${item.currency}`,
|
||||
path: "/billing",
|
||||
context: item
|
||||
})),
|
||||
...logs.map((item) => ({
|
||||
id: item.id,
|
||||
type: "audit_log",
|
||||
module_key: "audit_logs",
|
||||
title: item.action,
|
||||
subtitle: `${item.actor_email} • ${item.resource_name || "-"} • ${new Date(item.created_at).toLocaleString()}`,
|
||||
path: "/audit-logs",
|
||||
context: item
|
||||
})),
|
||||
...alerts.map((item) => ({
|
||||
id: item.id,
|
||||
type: "monitoring_alert",
|
||||
module_key: "monitoring",
|
||||
title: item.title,
|
||||
subtitle: `${item.severity} • ${item.status}`,
|
||||
path: "/monitoring",
|
||||
context: item
|
||||
}))
|
||||
];
|
||||
|
||||
return res.json({
|
||||
query: parsed.q,
|
||||
results: results.slice(0, parsed.limit)
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/branding", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = brandingSchema.parse(req.body ?? {});
|
||||
|
||||
240
backend/src/services/announcement.service.ts
Normal file
240
backend/src/services/announcement.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Role, SettingType, type Prisma } from "@prisma/client";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { toPrismaJsonValue } from "../lib/prisma-json";
|
||||
|
||||
const ANNOUNCEMENT_SETTING_KEY = "announcement_center";
|
||||
|
||||
export const ANNOUNCEMENT_SEVERITIES = ["INFO", "WARNING", "CRITICAL"] as const;
|
||||
export type AnnouncementSeverity = (typeof ANNOUNCEMENT_SEVERITIES)[number];
|
||||
|
||||
export type AnnouncementItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: AnnouncementSeverity;
|
||||
audience_roles: Role[];
|
||||
is_active: boolean;
|
||||
published_at: string;
|
||||
expires_at: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type AnnouncementCenterState = {
|
||||
items: AnnouncementItem[];
|
||||
reads: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const ROLE_VALUES = new Set(Object.values(Role));
|
||||
const SEVERITY_VALUES = new Set<string>(ANNOUNCEMENT_SEVERITIES);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeAnnouncementItem(value: unknown): AnnouncementItem | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const entry = value as Record<string, unknown>;
|
||||
const id = typeof entry.id === "string" ? entry.id : null;
|
||||
const title = typeof entry.title === "string" ? entry.title.trim() : "";
|
||||
const message = typeof entry.message === "string" ? entry.message.trim() : "";
|
||||
const severityRaw = typeof entry.severity === "string" ? entry.severity.toUpperCase() : "INFO";
|
||||
const severity = SEVERITY_VALUES.has(severityRaw) ? (severityRaw as AnnouncementSeverity) : "INFO";
|
||||
const audienceRolesRaw = Array.isArray(entry.audience_roles) ? entry.audience_roles : [];
|
||||
const audience_roles = audienceRolesRaw.filter(
|
||||
(role): role is Role => typeof role === "string" && ROLE_VALUES.has(role as Role)
|
||||
);
|
||||
const is_active = typeof entry.is_active === "boolean" ? entry.is_active : true;
|
||||
const published_at = typeof entry.published_at === "string" ? entry.published_at : nowIso();
|
||||
const expires_at = typeof entry.expires_at === "string" ? entry.expires_at : null;
|
||||
const created_by = typeof entry.created_by === "string" ? entry.created_by : "system@proxpanel.local";
|
||||
const created_at = typeof entry.created_at === "string" ? entry.created_at : nowIso();
|
||||
const updated_at = typeof entry.updated_at === "string" ? entry.updated_at : created_at;
|
||||
|
||||
if (!id || title.length < 2 || message.length < 2) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
message,
|
||||
severity,
|
||||
audience_roles,
|
||||
is_active,
|
||||
published_at,
|
||||
expires_at,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeState(value: unknown): AnnouncementCenterState {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { items: [], reads: {} };
|
||||
}
|
||||
|
||||
const entry = value as Record<string, unknown>;
|
||||
const rawItems = Array.isArray(entry.items) ? entry.items : [];
|
||||
const items = rawItems
|
||||
.map((item) => normalizeAnnouncementItem(item))
|
||||
.filter((item): item is AnnouncementItem => Boolean(item))
|
||||
.sort((a, b) => b.published_at.localeCompare(a.published_at))
|
||||
.slice(0, 250);
|
||||
|
||||
const reads: Record<string, string[]> = {};
|
||||
const rawReads = entry.reads;
|
||||
if (rawReads && typeof rawReads === "object" && !Array.isArray(rawReads)) {
|
||||
for (const [userId, rawIds] of Object.entries(rawReads as Record<string, unknown>)) {
|
||||
if (!userId || !Array.isArray(rawIds)) continue;
|
||||
reads[userId] = rawIds.filter((id): id is string => typeof id === "string").slice(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return { items, reads };
|
||||
}
|
||||
|
||||
async function saveState(state: AnnouncementCenterState) {
|
||||
const value = toPrismaJsonValue(state) as Prisma.InputJsonValue;
|
||||
await prisma.setting.upsert({
|
||||
where: { key: ANNOUNCEMENT_SETTING_KEY },
|
||||
update: {
|
||||
value,
|
||||
is_encrypted: false
|
||||
},
|
||||
create: {
|
||||
key: ANNOUNCEMENT_SETTING_KEY,
|
||||
type: SettingType.GENERAL,
|
||||
value,
|
||||
is_encrypted: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAnnouncementState() {
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: ANNOUNCEMENT_SETTING_KEY },
|
||||
select: { value: true }
|
||||
});
|
||||
|
||||
const normalized = normalizeState(setting?.value);
|
||||
if (!setting) {
|
||||
await saveState(normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isAnnouncementVisibleToRole(item: AnnouncementItem, role: Role) {
|
||||
if (!item.is_active) return false;
|
||||
if (item.audience_roles.length === 0) return true;
|
||||
return item.audience_roles.includes(role);
|
||||
}
|
||||
|
||||
function isAnnouncementActive(item: AnnouncementItem, now: Date) {
|
||||
if (!item.is_active) return false;
|
||||
const publishedAt = new Date(item.published_at);
|
||||
if (Number.isNaN(publishedAt.getTime())) return false;
|
||||
if (publishedAt.getTime() > now.getTime()) return false;
|
||||
if (!item.expires_at) return true;
|
||||
const expiresAt = new Date(item.expires_at);
|
||||
if (Number.isNaN(expiresAt.getTime())) return true;
|
||||
return expiresAt.getTime() > now.getTime();
|
||||
}
|
||||
|
||||
export function buildInboxForUser(state: AnnouncementCenterState, input: { user_id: string; role: Role }) {
|
||||
const now = new Date();
|
||||
const readSet = new Set(state.reads[input.user_id] ?? []);
|
||||
const items = state.items
|
||||
.filter((item) => isAnnouncementActive(item, now))
|
||||
.filter((item) => isAnnouncementVisibleToRole(item, input.role))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
is_read: readSet.has(item.id)
|
||||
}));
|
||||
|
||||
const unread_count = items.reduce((sum, item) => (item.is_read ? sum : sum + 1), 0);
|
||||
|
||||
return {
|
||||
items,
|
||||
unread_count,
|
||||
total_count: items.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertAnnouncement(input: {
|
||||
id?: string;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: AnnouncementSeverity;
|
||||
audience_roles: Role[];
|
||||
is_active: boolean;
|
||||
published_at?: string;
|
||||
expires_at?: string | null;
|
||||
actor_email: string;
|
||||
}) {
|
||||
const state = await getAnnouncementState();
|
||||
const now = nowIso();
|
||||
const existingIndex = input.id ? state.items.findIndex((item) => item.id === input.id) : -1;
|
||||
|
||||
const announcement: AnnouncementItem = {
|
||||
id: existingIndex >= 0 ? state.items[existingIndex].id : `ann_${Date.now()}_${Math.floor(Math.random() * 10000)}`,
|
||||
title: input.title.trim(),
|
||||
message: input.message.trim(),
|
||||
severity: input.severity,
|
||||
audience_roles: input.audience_roles,
|
||||
is_active: input.is_active,
|
||||
published_at: input.published_at ?? (existingIndex >= 0 ? state.items[existingIndex].published_at : now),
|
||||
expires_at: input.expires_at ?? null,
|
||||
created_by: existingIndex >= 0 ? state.items[existingIndex].created_by : input.actor_email,
|
||||
created_at: existingIndex >= 0 ? state.items[existingIndex].created_at : now,
|
||||
updated_at: now
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
state.items[existingIndex] = announcement;
|
||||
} else {
|
||||
state.items.unshift(announcement);
|
||||
}
|
||||
|
||||
state.items = state.items.slice(0, 250);
|
||||
await saveState(state);
|
||||
return announcement;
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(id: string) {
|
||||
const state = await getAnnouncementState();
|
||||
const before = state.items.length;
|
||||
state.items = state.items.filter((item) => item.id !== id);
|
||||
if (state.items.length === before) {
|
||||
return { deleted: false };
|
||||
}
|
||||
|
||||
for (const userId of Object.keys(state.reads)) {
|
||||
state.reads[userId] = (state.reads[userId] ?? []).filter((itemId) => itemId !== id);
|
||||
}
|
||||
|
||||
await saveState(state);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
export async function markAnnouncementRead(input: { user_id: string; announcement_id: string }) {
|
||||
const state = await getAnnouncementState();
|
||||
const exists = state.items.some((item) => item.id === input.announcement_id);
|
||||
if (!exists) {
|
||||
return { updated: false };
|
||||
}
|
||||
const current = new Set(state.reads[input.user_id] ?? []);
|
||||
current.add(input.announcement_id);
|
||||
state.reads[input.user_id] = [...current].slice(-1000);
|
||||
await saveState(state);
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
export async function markAllAnnouncementsRead(input: { user_id: string; role: Role }) {
|
||||
const state = await getAnnouncementState();
|
||||
const inbox = buildInboxForUser(state, input);
|
||||
const ids = inbox.items.map((item) => item.id);
|
||||
state.reads[input.user_id] = ids;
|
||||
await saveState(state);
|
||||
return { updated: ids.length };
|
||||
}
|
||||
304
backend/src/services/module-access.service.ts
Normal file
304
backend/src/services/module-access.service.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Role, SettingType, type Prisma } from "@prisma/client";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { toPrismaJsonValue } from "../lib/prisma-json";
|
||||
|
||||
export const MODULE_KEYS = [
|
||||
"dashboard",
|
||||
"monitoring",
|
||||
"operations",
|
||||
"audit_logs",
|
||||
"vms",
|
||||
"nodes",
|
||||
"provisioning",
|
||||
"backups",
|
||||
"network",
|
||||
"security",
|
||||
"tenants",
|
||||
"client_area",
|
||||
"billing",
|
||||
"rbac",
|
||||
"profile",
|
||||
"system_management",
|
||||
"settings"
|
||||
] as const;
|
||||
|
||||
export type ModuleKey = (typeof MODULE_KEYS)[number];
|
||||
|
||||
export type ModuleDefinition = {
|
||||
key: ModuleKey;
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
default_roles: Role[];
|
||||
};
|
||||
|
||||
type ModulePolicyEntry = {
|
||||
enabled: boolean;
|
||||
roles: Role[];
|
||||
};
|
||||
|
||||
export type ModulePolicy = Record<ModuleKey, ModulePolicyEntry>;
|
||||
|
||||
export const MODULE_DEFINITIONS: ModuleDefinition[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "Dashboard",
|
||||
description: "Executive dashboard and KPIs.",
|
||||
path: "/",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "monitoring",
|
||||
label: "Monitoring",
|
||||
description: "Health checks, alerts, and insights.",
|
||||
path: "/monitoring",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
description: "Operational queue and scheduled jobs.",
|
||||
path: "/operations",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.OPERATOR, Role.TENANT_ADMIN]
|
||||
},
|
||||
{
|
||||
key: "audit_logs",
|
||||
label: "Audit Logs",
|
||||
description: "Immutable administrative audit events.",
|
||||
path: "/audit-logs",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.OPERATOR, Role.TENANT_ADMIN, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "vms",
|
||||
label: "Virtual Machines",
|
||||
description: "VM inventory and lifecycle controls.",
|
||||
path: "/vms",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "nodes",
|
||||
label: "Nodes",
|
||||
description: "Hypervisor node visibility and status.",
|
||||
path: "/nodes",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "provisioning",
|
||||
label: "Provisioning",
|
||||
description: "Template, package, and service provisioning workflows.",
|
||||
path: "/provisioning",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR]
|
||||
},
|
||||
{
|
||||
key: "backups",
|
||||
label: "Backups",
|
||||
description: "Backup policies, snapshots, and restore tasks.",
|
||||
path: "/backups",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "network",
|
||||
label: "Network",
|
||||
description: "IPAM pools, quotas, and private network operations.",
|
||||
path: "/network",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
label: "Security",
|
||||
description: "Security events and enforcement controls.",
|
||||
path: "/security",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "tenants",
|
||||
label: "Tenants",
|
||||
description: "Tenant and subscription lifecycle management.",
|
||||
path: "/tenants",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "client_area",
|
||||
label: "Client Area",
|
||||
description: "Tenant-facing service and usage controls.",
|
||||
path: "/client",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: "Billing",
|
||||
description: "Invoices, usage records, and payment actions.",
|
||||
path: "/billing",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "rbac",
|
||||
label: "RBAC",
|
||||
description: "User lifecycle and access governance.",
|
||||
path: "/rbac",
|
||||
default_roles: [Role.SUPER_ADMIN]
|
||||
},
|
||||
{
|
||||
key: "profile",
|
||||
label: "Profile",
|
||||
description: "Identity profile and account security settings.",
|
||||
path: "/profile",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.TENANT_ADMIN, Role.OPERATOR, Role.VIEWER]
|
||||
},
|
||||
{
|
||||
key: "system_management",
|
||||
label: "System Management",
|
||||
description: "Branding, CMS, and platform lifecycle controls.",
|
||||
path: "/system",
|
||||
default_roles: [Role.SUPER_ADMIN]
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
label: "Settings",
|
||||
description: "Proxmox, billing, scheduler, and global settings.",
|
||||
path: "/settings",
|
||||
default_roles: [Role.SUPER_ADMIN, Role.OPERATOR]
|
||||
}
|
||||
];
|
||||
|
||||
const MODULE_POLICY_SETTING_KEY = "module_policy";
|
||||
|
||||
const MODULE_ROLE_VALUES = new Set(Object.values(Role));
|
||||
|
||||
let cache: { value: ModulePolicy; expires_at: number } | null = null;
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
function defaultPolicy(): ModulePolicy {
|
||||
return MODULE_DEFINITIONS.reduce((acc, item) => {
|
||||
acc[item.key] = {
|
||||
enabled: true,
|
||||
roles: [...item.default_roles]
|
||||
};
|
||||
return acc;
|
||||
}, {} as ModulePolicy);
|
||||
}
|
||||
|
||||
function normalizePolicy(value: unknown): ModulePolicy {
|
||||
const fallback = defaultPolicy();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
for (const module of MODULE_DEFINITIONS) {
|
||||
const candidate = input[module.key];
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = candidate as Record<string, unknown>;
|
||||
const enabled = typeof entry.enabled === "boolean" ? entry.enabled : true;
|
||||
const rawRoles = Array.isArray(entry.roles) ? entry.roles : module.default_roles;
|
||||
const roles = rawRoles
|
||||
.filter((role): role is Role => typeof role === "string" && MODULE_ROLE_VALUES.has(role as Role))
|
||||
.slice(0, 4);
|
||||
|
||||
fallback[module.key] = {
|
||||
enabled,
|
||||
roles: roles.length > 0 ? roles : [...module.default_roles]
|
||||
};
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function savePolicy(policy: ModulePolicy) {
|
||||
const value = toPrismaJsonValue(policy) as Prisma.InputJsonValue;
|
||||
await prisma.setting.upsert({
|
||||
where: { key: MODULE_POLICY_SETTING_KEY },
|
||||
update: { value, is_encrypted: false },
|
||||
create: {
|
||||
key: MODULE_POLICY_SETTING_KEY,
|
||||
type: SettingType.GENERAL,
|
||||
value,
|
||||
is_encrypted: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function toModulePolicyResponse(policy: ModulePolicy) {
|
||||
return MODULE_DEFINITIONS.map((module) => ({
|
||||
key: module.key,
|
||||
label: module.label,
|
||||
description: module.description,
|
||||
path: module.path,
|
||||
enabled: policy[module.key].enabled,
|
||||
roles: policy[module.key].roles
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getModulePolicy(options?: { force_refresh?: boolean }) {
|
||||
const forceRefresh = options?.force_refresh === true;
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && cache && cache.expires_at > now) {
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: MODULE_POLICY_SETTING_KEY },
|
||||
select: { value: true }
|
||||
});
|
||||
const normalized = normalizePolicy(setting?.value);
|
||||
|
||||
// Self-heal invalid/missing policy into DB once discovered.
|
||||
if (!setting) {
|
||||
await savePolicy(normalized);
|
||||
}
|
||||
|
||||
cache = {
|
||||
value: normalized,
|
||||
expires_at: now + CACHE_TTL_MS
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function updateModulePolicy(entries: Array<{ key: ModuleKey; enabled: boolean; roles: Role[] }>) {
|
||||
const merged = defaultPolicy();
|
||||
for (const entry of entries) {
|
||||
const roles = entry.roles.filter((role) => MODULE_ROLE_VALUES.has(role));
|
||||
merged[entry.key] = {
|
||||
enabled: entry.enabled,
|
||||
roles: roles.length > 0 ? roles : [...merged[entry.key].roles]
|
||||
};
|
||||
}
|
||||
|
||||
await savePolicy(merged);
|
||||
cache = {
|
||||
value: merged,
|
||||
expires_at: Date.now() + CACHE_TTL_MS
|
||||
};
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function getUserModuleAccess(role: Role) {
|
||||
const policy = await getModulePolicy();
|
||||
|
||||
const access = MODULE_DEFINITIONS.reduce(
|
||||
(acc, module) => {
|
||||
const entry = policy[module.key];
|
||||
const allowed = role === Role.SUPER_ADMIN ? true : entry.enabled && entry.roles.includes(role);
|
||||
acc[module.key] = {
|
||||
allowed,
|
||||
enabled: entry.enabled,
|
||||
roles: entry.roles
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<ModuleKey, { allowed: boolean; enabled: boolean; roles: Role[] }>
|
||||
);
|
||||
|
||||
return {
|
||||
modules: toModulePolicyResponse(policy).map((module) => ({
|
||||
...module,
|
||||
allowed: access[module.key].allowed
|
||||
})),
|
||||
access
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user