feat: add enterprise profile center, announcements, search, and module access controls
Some checks failed
CI / Frontend Build + Lint (push) Has been cancelled
CI / Backend Build + Test + Prisma Checks (push) Has been cancelled

This commit is contained in:
Austin A
2026-04-18 10:43:51 +01:00
parent 81be9c5e42
commit 8c2d15225b
16 changed files with 2237 additions and 96 deletions

View File

@@ -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);

View 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;

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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 ?? {});

View 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 };
}

View 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
};
}