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

View File

@@ -1,6 +1,7 @@
import { Toaster } from "@/components/ui/toaster"
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClientInstance } from '@/lib/query-client'
import { useMemo } from "react";
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom';
import PageNotFound from './lib/PageNotFound';
import { AuthProvider, useAuth } from '@/lib/AuthContext';
@@ -26,7 +27,41 @@ import Profile from './pages/Profile';
import SystemManagement from './pages/SystemManagement';
const AuthenticatedApp = () => {
const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated } = useAuth();
const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated, user } = useAuth();
const fallbackPath = useMemo(() => {
const ordered = [
["dashboard", "/"],
["monitoring", "/monitoring"],
["operations", "/operations"],
["audit_logs", "/audit-logs"],
["vms", "/vms"],
["nodes", "/nodes"],
["provisioning", "/provisioning"],
["backups", "/backups"],
["network", "/network"],
["security", "/security"],
["tenants", "/tenants"],
["client_area", "/client"],
["billing", "/billing"],
["rbac", "/rbac"],
["profile", "/profile"],
["system_management", "/system"],
["settings", "/settings"]
];
const access = user?.module_access || {};
const firstAllowed = ordered.find(([key]) => access[key]?.allowed !== false);
return firstAllowed ? firstAllowed[1] : "/profile";
}, [user?.module_access]);
const moduleRoute = (moduleKey, element) => {
const allowed = user?.module_access?.[moduleKey]?.allowed;
if (allowed === false) {
return <Navigate to={fallbackPath} replace />;
}
return element;
};
// Show loading spinner while checking app public settings or auth
if (isLoadingPublicSettings || isLoadingAuth) {
@@ -46,23 +81,23 @@ const AuthenticatedApp = () => {
<Routes>
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
<Route element={isAuthenticated ? <AppLayout /> : <Navigate to="/login" replace />}>
<Route path="/" element={<Dashboard />} />
<Route path="/vms" element={<VirtualMachines />} />
<Route path="/nodes" element={<Nodes />} />
<Route path="/tenants" element={<Tenants />} />
<Route path="/billing" element={<Billing />} />
<Route path="/backups" element={<Backups />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/provisioning" element={<Provisioning />} />
<Route path="/network" element={<NetworkIpam />} />
<Route path="/security" element={<Security />} />
<Route path="/client" element={<ClientArea />} />
<Route path="/operations" element={<Operations />} />
<Route path="/audit-logs" element={<AuditLogs />} />
<Route path="/rbac" element={<RBAC />} />
<Route path="/profile" element={<Profile />} />
<Route path="/system" element={<SystemManagement />} />
<Route path="/settings" element={<Settings />} />
<Route path="/" element={moduleRoute("dashboard", <Dashboard />)} />
<Route path="/vms" element={moduleRoute("vms", <VirtualMachines />)} />
<Route path="/nodes" element={moduleRoute("nodes", <Nodes />)} />
<Route path="/tenants" element={moduleRoute("tenants", <Tenants />)} />
<Route path="/billing" element={moduleRoute("billing", <Billing />)} />
<Route path="/backups" element={moduleRoute("backups", <Backups />)} />
<Route path="/monitoring" element={moduleRoute("monitoring", <Monitoring />)} />
<Route path="/provisioning" element={moduleRoute("provisioning", <Provisioning />)} />
<Route path="/network" element={moduleRoute("network", <NetworkIpam />)} />
<Route path="/security" element={moduleRoute("security", <Security />)} />
<Route path="/client" element={moduleRoute("client_area", <ClientArea />)} />
<Route path="/operations" element={moduleRoute("operations", <Operations />)} />
<Route path="/audit-logs" element={moduleRoute("audit_logs", <AuditLogs />)} />
<Route path="/rbac" element={moduleRoute("rbac", <RBAC />)} />
<Route path="/profile" element={moduleRoute("profile", <Profile />)} />
<Route path="/system" element={moduleRoute("system_management", <SystemManagement />)} />
<Route path="/settings" element={moduleRoute("settings", <Settings />)} />
</Route>
<Route path="*" element={isAuthenticated ? <PageNotFound /> : <Navigate to="/login" replace />} />
</Routes>

View File

@@ -422,6 +422,13 @@ const profile = {
});
},
async uploadAvatar(dataUrl) {
return request("/api/profile/avatar", {
method: "POST",
body: JSON.stringify({ data_url: dataUrl })
});
},
async changePassword(payload) {
return request("/api/profile/change-password", {
method: "POST",
@@ -489,6 +496,29 @@ const system = {
});
},
async moduleAccess() {
return request("/api/system/module-access");
},
async getModulesPolicy() {
return request("/api/system/modules-policy");
},
async saveModulesPolicy(payload) {
return request("/api/system/modules-policy", {
method: "PUT",
body: JSON.stringify(payload ?? {})
});
},
async globalSearch(query, limit = 12) {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (typeof limit === "number") params.set("limit", String(limit));
const qs = params.toString();
return request(`/api/system/search${qs ? `?${qs}` : ""}`);
},
async getSubscriptionPolicy() {
return request("/api/system/subscription-policy");
},
@@ -567,6 +597,48 @@ const system = {
}
};
const announcements = {
async inbox() {
return request("/api/announcements/inbox");
},
async markRead(id) {
return request(`/api/announcements/${id}/read`, {
method: "POST"
});
},
async markAllRead() {
return request("/api/announcements/read-all", {
method: "POST"
});
},
async adminList() {
return request("/api/announcements/admin");
},
async adminCreate(payload) {
return request("/api/announcements/admin", {
method: "POST",
body: JSON.stringify(payload ?? {})
});
},
async adminUpdate(id, payload) {
return request(`/api/announcements/admin/${id}`, {
method: "PATCH",
body: JSON.stringify(payload ?? {})
});
},
async adminDelete(id) {
return request(`/api/announcements/admin/${id}`, {
method: "DELETE"
});
}
};
const operations = {
async listTasks(params = {}) {
const query = new URLSearchParams();
@@ -1335,6 +1407,7 @@ export const appClient = {
adminUsers,
profile,
system,
announcements,
entities,
dashboard,
monitoring,

View File

@@ -1,13 +1,173 @@
import { Bell, ChevronRight, Search } from "lucide-react";
import { Outlet, useLocation } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bell,
ChevronDown,
ChevronRight,
LogOut,
Moon,
Search,
Sun,
UserCircle2
} from "lucide-react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Input } from "@/components/ui/input";
import { appClient } from "@/api/appClient";
import { useAuth } from "@/lib/AuthContext";
import Sidebar from "./Sidebar";
import { resolveNavigation } from "./nav-config";
function useOutsideClose(ref, onClose) {
useEffect(() => {
function onDocumentClick(event) {
if (!ref.current) return;
if (!ref.current.contains(event.target)) {
onClose();
}
}
document.addEventListener("mousedown", onDocumentClick);
return () => document.removeEventListener("mousedown", onDocumentClick);
}, [onClose, ref]);
}
export default function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const currentNav = resolveNavigation(location.pathname);
const [search, setSearch] = useState("");
const [searchLoading, setSearchLoading] = useState(false);
const [searchResults, setSearchResults] = useState([]);
const [searchOpen, setSearchOpen] = useState(false);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [announcementInbox, setAnnouncementInbox] = useState({
items: [],
unread_count: 0,
total_count: 0
});
const [theme, setTheme] = useState(() => {
const saved = typeof window !== "undefined" ? window.localStorage.getItem("proxpanel_theme") : null;
return saved === "dark" ? "dark" : "light";
});
const searchContainerRef = useRef(null);
const notificationRef = useRef(null);
const profileRef = useRef(null);
const searchInputRef = useRef(null);
useOutsideClose(searchContainerRef, () => setSearchOpen(false));
useOutsideClose(notificationRef, () => setNotificationsOpen(false));
useOutsideClose(profileRef, () => setProfileOpen(false));
const moduleAccess = user?.module_access || {};
const filteredSearchResults = useMemo(
() =>
searchResults.filter((item) =>
item?.module_key ? moduleAccess[item.module_key]?.allowed !== false : true
),
[moduleAccess, searchResults]
);
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
window.localStorage.setItem("proxpanel_theme", theme);
}, [theme]);
useEffect(() => {
function onShortcut(event) {
const isCtrlK = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k";
if (!isCtrlK) return;
event.preventDefault();
searchInputRef.current?.focus();
setSearchOpen(true);
}
window.addEventListener("keydown", onShortcut);
return () => window.removeEventListener("keydown", onShortcut);
}, []);
useEffect(() => {
const query = search.trim();
if (query.length < 2) {
setSearchResults([]);
setSearchLoading(false);
return;
}
let active = true;
setSearchLoading(true);
const timeoutId = window.setTimeout(async () => {
try {
const payload = await appClient.system.globalSearch(query, 12);
if (!active) return;
setSearchResults(payload?.results || []);
} catch {
if (!active) return;
setSearchResults([]);
} finally {
if (active) setSearchLoading(false);
}
}, 260);
return () => {
active = false;
window.clearTimeout(timeoutId);
};
}, [search]);
useEffect(() => {
let active = true;
async function loadInbox() {
try {
const payload = await appClient.announcements.inbox();
if (!active) return;
setAnnouncementInbox(payload || { items: [], unread_count: 0, total_count: 0 });
} catch {
if (!active) return;
setAnnouncementInbox({ items: [], unread_count: 0, total_count: 0 });
}
}
void loadInbox();
const interval = window.setInterval(() => {
void loadInbox();
}, 45_000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [location.pathname]);
async function markAnnouncementRead(id) {
try {
await appClient.announcements.markRead(id);
const payload = await appClient.announcements.inbox();
setAnnouncementInbox(payload || { items: [], unread_count: 0, total_count: 0 });
} catch {
// no-op
}
}
async function markAllAnnouncementsRead() {
try {
await appClient.announcements.markAllRead();
const payload = await appClient.announcements.inbox();
setAnnouncementInbox(payload || { items: [], unread_count: 0, total_count: 0 });
} catch {
// no-op
}
}
function onSelectSearchResult(item) {
setSearch("");
setSearchResults([]);
setSearchOpen(false);
navigate(item.path || "/");
}
const displayName = user?.full_name || user?.email || "User";
const displayRole = user?.role ? String(user.role).replace(/_/g, " ") : "User";
return (
<div className="min-h-screen bg-background text-foreground app-shell-bg flex">
<Sidebar />
@@ -22,24 +182,156 @@ export default function AppLayout() {
<span className="font-semibold text-foreground">{currentNav?.label ?? "Overview"}</span>
</div>
<div className="relative ml-auto w-full max-w-sm">
<div ref={searchContainerRef} className="relative ml-auto w-full max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
aria-label="Global search"
placeholder="Search resources, tenants, events..."
className="h-9 rounded-lg border-border bg-card/70 pl-9 pr-16 text-sm"
value={search}
onFocus={() => setSearchOpen(true)}
onChange={(event) => setSearch(event.target.value)}
/>
<span className="pointer-events-none absolute right-2 top-1/2 hidden -translate-y-1/2 rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground md:block">
Ctrl K
</span>
{searchOpen ? (
<div className="absolute left-0 right-0 top-[42px] z-40 max-h-[360px] overflow-auto rounded-xl border border-border bg-card p-2 shadow-lg">
{searchLoading ? (
<p className="px-3 py-2 text-xs text-muted-foreground">Searching...</p>
) : filteredSearchResults.length > 0 ? (
filteredSearchResults.map((item) => (
<button
key={`${item.type}-${item.id}`}
type="button"
onClick={() => onSelectSearchResult(item)}
className="mb-1 w-full rounded-lg border border-transparent px-3 py-2 text-left hover:border-border hover:bg-muted/35"
>
<p className="text-sm font-medium text-foreground">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.subtitle}</p>
</button>
))
) : (
<p className="px-3 py-2 text-xs text-muted-foreground">
{search.trim().length < 2 ? "Type at least 2 characters to search." : "No results found."}
</p>
)}
</div>
) : null}
</div>
<button
type="button"
onClick={() => setTheme((prev) => (prev === "dark" ? "light" : "dark"))}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card/70 text-muted-foreground transition-colors hover:text-foreground"
title="Toggle theme"
>
<Bell className="h-4 w-4" />
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<div ref={notificationRef} className="relative">
<button
type="button"
onClick={() => setNotificationsOpen((value) => !value)}
className="relative inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card/70 text-muted-foreground transition-colors hover:text-foreground"
title="Announcements"
>
<Bell className="h-4 w-4" />
{announcementInbox.unread_count > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground">
{announcementInbox.unread_count > 9 ? "9+" : announcementInbox.unread_count}
</span>
) : null}
</button>
{notificationsOpen ? (
<div className="absolute right-0 top-11 z-40 w-[320px] rounded-xl border border-border bg-card p-3 shadow-lg">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Notifications</p>
<button
type="button"
onClick={markAllAnnouncementsRead}
className="text-xs font-medium text-primary hover:underline"
>
Mark all read
</button>
</div>
<div className="max-h-[320px] space-y-2 overflow-auto">
{announcementInbox.items.length > 0 ? (
announcementInbox.items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => markAnnouncementRead(item.id)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
item.is_read ? "border-border bg-muted/20" : "border-primary/25 bg-primary/5"
}`}
>
<p className="text-xs font-semibold text-foreground">{item.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{item.message}</p>
</button>
))
) : (
<p className="rounded-lg border border-border bg-muted/20 px-3 py-3 text-xs text-muted-foreground">
No notifications available.
</p>
)}
</div>
</div>
) : null}
</div>
<div ref={profileRef} className="relative">
<button
type="button"
onClick={() => setProfileOpen((value) => !value)}
className="flex items-center gap-2 rounded-xl border border-border bg-card/75 px-2 py-1.5 transition-colors hover:bg-muted/45"
>
<div className="h-8 w-8 overflow-hidden rounded-md border border-border bg-background">
{user?.avatar_url ? (
<img src={user.avatar_url} alt="User avatar" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<UserCircle2 className="h-4 w-4" />
</div>
)}
</div>
<div className="hidden text-left md:block">
<p className="max-w-[140px] truncate text-sm font-semibold text-foreground">{displayName}</p>
<p className="max-w-[140px] truncate text-[11px] text-muted-foreground">{displayRole}</p>
</div>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{profileOpen ? (
<div className="absolute right-0 top-11 z-40 w-[210px] rounded-xl border border-border bg-card p-2 shadow-lg">
<button
type="button"
onClick={() => {
setProfileOpen(false);
navigate("/profile");
}}
className="mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-foreground hover:bg-muted/40"
>
<UserCircle2 className="h-4 w-4 text-muted-foreground" />
Profile
</button>
<button
type="button"
onClick={() => {
setProfileOpen(false);
logout(true);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-rose-700 hover:bg-rose-50"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
</div>
) : null}
</div>
</div>
</header>

View File

@@ -23,7 +23,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { appClient } from "@/api/appClient";
import { navigationGroups } from "./nav-config";
import { canAccessModule, navigationGroups } from "./nav-config";
import { useAuth } from "@/lib/AuthContext";
const iconMap = {
@@ -48,10 +48,12 @@ const iconMap = {
export default function Sidebar() {
const location = useLocation();
const { appPublicSettings } = useAuth();
const { appPublicSettings, user } = useAuth();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const brandName = appPublicSettings?.branding?.app_name || "ProxPanel Cloud";
const brandLogo = appPublicSettings?.branding?.logo_url || "";
const moduleAccess = user?.module_access || null;
const isActive = (path) => {
if (path === "/") return location.pathname === "/";
@@ -67,8 +69,12 @@ export default function Sidebar() {
>
<div className="border-b border-sidebar-border px-4 py-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary ring-1 ring-primary/20">
<Server className="h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl bg-primary/10 text-primary ring-1 ring-primary/20">
{brandLogo ? (
<img src={brandLogo} alt="Brand logo" className="h-full w-full object-cover" />
) : (
<Server className="h-5 w-5" />
)}
</div>
{!collapsed && (
<div className="min-w-0">
@@ -81,7 +87,13 @@ export default function Sidebar() {
<nav className="flex-1 overflow-y-auto px-3 py-3">
<div className="space-y-5">
{navigationGroups.map((group) => (
{navigationGroups
.map((group) => ({
...group,
items: group.items.filter((item) => canAccessModule(item, moduleAccess))
}))
.filter((group) => group.items.length > 0)
.map((group) => (
<div key={group.id} className="space-y-1.5">
{!collapsed && (
<p className="px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/90">

View File

@@ -3,41 +3,41 @@ export const navigationGroups = [
id: "overview",
label: "Overview",
items: [
{ path: "/", label: "Dashboard", iconKey: "dashboard" },
{ path: "/monitoring", label: "Monitoring", iconKey: "monitoring" },
{ path: "/operations", label: "Operations", iconKey: "operations" },
{ path: "/audit-logs", label: "Audit Logs", iconKey: "audit" }
{ path: "/", label: "Dashboard", iconKey: "dashboard", moduleKey: "dashboard" },
{ path: "/monitoring", label: "Monitoring", iconKey: "monitoring", moduleKey: "monitoring" },
{ path: "/operations", label: "Operations", iconKey: "operations", moduleKey: "operations" },
{ path: "/audit-logs", label: "Audit Logs", iconKey: "audit", moduleKey: "audit_logs" }
]
},
{
id: "compute",
label: "Compute",
items: [
{ path: "/vms", label: "Virtual Machines", iconKey: "vms" },
{ path: "/nodes", label: "Nodes", iconKey: "nodes" },
{ path: "/provisioning", label: "Provisioning", iconKey: "provisioning" },
{ path: "/backups", label: "Backups", iconKey: "backups" }
{ path: "/vms", label: "Virtual Machines", iconKey: "vms", moduleKey: "vms" },
{ path: "/nodes", label: "Nodes", iconKey: "nodes", moduleKey: "nodes" },
{ path: "/provisioning", label: "Provisioning", iconKey: "provisioning", moduleKey: "provisioning" },
{ path: "/backups", label: "Backups", iconKey: "backups", moduleKey: "backups" }
]
},
{
id: "network",
label: "Network",
items: [
{ path: "/network", label: "IPAM & Pools", iconKey: "network" },
{ path: "/security", label: "Security", iconKey: "security" }
{ path: "/network", label: "IPAM & Pools", iconKey: "network", moduleKey: "network" },
{ path: "/security", label: "Security", iconKey: "security", moduleKey: "security" }
]
},
{
id: "tenant",
label: "Tenants",
items: [
{ path: "/tenants", label: "Tenants", iconKey: "tenants" },
{ path: "/client", label: "Client Area", iconKey: "client" },
{ path: "/billing", label: "Billing", iconKey: "billing" },
{ path: "/rbac", label: "RBAC", iconKey: "rbac" },
{ path: "/profile", label: "Profile", iconKey: "profile" },
{ path: "/system", label: "System", iconKey: "system" },
{ path: "/settings", label: "Settings", iconKey: "settings" }
{ path: "/tenants", label: "Tenants", iconKey: "tenants", moduleKey: "tenants" },
{ path: "/client", label: "Client Area", iconKey: "client", moduleKey: "client_area" },
{ path: "/billing", label: "Billing", iconKey: "billing", moduleKey: "billing" },
{ path: "/rbac", label: "RBAC", iconKey: "rbac", moduleKey: "rbac" },
{ path: "/profile", label: "Profile", iconKey: "profile", moduleKey: "profile" },
{ path: "/system", label: "System", iconKey: "system", moduleKey: "system_management" },
{ path: "/settings", label: "Settings", iconKey: "settings", moduleKey: "settings" }
]
}
];
@@ -54,3 +54,9 @@ export function resolveNavigation(pathname) {
const sortedBySpecificity = [...flatNavigation].sort((a, b) => b.path.length - a.path.length);
return sortedBySpecificity.find((item) => item.path !== "/" && pathname.startsWith(item.path)) ?? null;
}
export function canAccessModule(item, moduleAccess) {
if (!item?.moduleKey) return true;
if (!moduleAccess || typeof moduleAccess !== "object") return true;
return moduleAccess[item.moduleKey]?.allowed !== false;
}

View File

@@ -28,7 +28,19 @@ export const AuthProvider = ({ children }) => {
setIsLoadingAuth(true);
setAuthError(null);
const currentUser = await appClient.auth.me();
setUser(currentUser);
let userPayload = currentUser;
if (!currentUser?.module_access) {
try {
const moduleAccess = await appClient.system.moduleAccess();
userPayload = {
...currentUser,
module_access: moduleAccess?.access || null
};
} catch {
userPayload = currentUser;
}
}
setUser(userPayload);
setIsAuthenticated(true);
} catch (error) {
setUser(null);
@@ -76,6 +88,7 @@ export const AuthProvider = ({ children }) => {
authError,
appPublicSettings,
logout,
setUser,
navigateToLogin,
checkAppState,
loadPublicSettings

View File

@@ -32,6 +32,7 @@ export default function Login() {
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
const [submitting, setSubmitting] = useState(false);
const brandName = appPublicSettings?.branding?.app_name || "ProxPanel Cloud";
const brandLogo = appPublicSettings?.branding?.logo_url || "";
const handleSubmit = async (event) => {
event.preventDefault();
@@ -69,7 +70,12 @@ export default function Login() {
<div className="mx-auto grid min-h-[calc(100vh-4rem)] w-full max-w-6xl overflow-hidden rounded-3xl border border-border bg-card shadow-[0_18px_60px_rgba(15,23,42,0.12)] md:grid-cols-[1.1fr_0.9fr]">
<section className="hidden bg-gradient-to-br from-blue-700 to-sky-700 p-10 text-white md:flex md:flex-col md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-blue-100">{brandName}</p>
<div className="flex items-center gap-3">
{brandLogo ? (
<img src={brandLogo} alt="Brand logo" className="h-10 w-10 rounded-lg object-cover ring-1 ring-white/35" />
) : null}
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-blue-100">{brandName}</p>
</div>
<h1 className="mt-4 text-4xl font-semibold leading-tight">Operate enterprise infrastructure with precision.</h1>
<p className="mt-4 max-w-md text-sm text-blue-100/90">
Unified control for compute, network, billing, and tenant operations, built for high-trust production environments.

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { KeyRound, Lock, ShieldCheck, UserCircle2 } from "lucide-react";
import { ImagePlus, KeyRound, Lock, ShieldCheck, UserCircle2 } from "lucide-react";
import { appClient } from "@/api/appClient";
import PageHeader from "../components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import { useAuth } from "@/lib/AuthContext";
function formatDate(value) {
if (!value) return "-";
@@ -22,8 +23,19 @@ function prettyJson(value) {
}
}
const defaultProfileFields = {
full_name: "",
phone: "",
telephone: "",
address: "",
state: "",
city: "",
country: ""
};
export default function Profile() {
const { toast } = useToast();
const { checkAppState } = useAuth();
const [loading, setLoading] = useState(true);
const [savingProfile, setSavingProfile] = useState(false);
@@ -37,12 +49,9 @@ export default function Profile() {
const [profile, setProfile] = useState(null);
const [sessions, setSessions] = useState([]);
const [lastResetToken, setLastResetToken] = useState(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [profileForm, setProfileForm] = useState({
full_name: "",
avatar_url: "",
profile_metadata_text: "{}"
});
const [profileForm, setProfileForm] = useState(defaultProfileFields);
const [passwordForm, setPasswordForm] = useState({
current_password: "",
@@ -69,8 +78,12 @@ export default function Profile() {
setSessions(sessionsPayload || []);
setProfileForm({
full_name: profilePayload?.full_name || "",
avatar_url: profilePayload?.avatar_url || "",
profile_metadata_text: prettyJson(profilePayload?.profile_metadata)
phone: profilePayload?.profile_fields?.phone || "",
telephone: profilePayload?.profile_fields?.telephone || "",
address: profilePayload?.profile_fields?.address || "",
state: profilePayload?.profile_fields?.state || "",
city: profilePayload?.profile_fields?.city || "",
country: profilePayload?.profile_fields?.country || ""
});
} catch (error) {
toast({
@@ -111,20 +124,17 @@ export default function Profile() {
async function handleSaveProfile() {
try {
setSavingProfile(true);
let parsedMetadata = {};
if (profileForm.profile_metadata_text.trim()) {
parsedMetadata = JSON.parse(profileForm.profile_metadata_text);
}
const payload = await appClient.profile.update({
full_name: profileForm.full_name,
avatar_url: profileForm.avatar_url || undefined,
profile_metadata: parsedMetadata
phone: profileForm.phone || undefined,
telephone: profileForm.telephone || undefined,
address: profileForm.address || undefined,
state: profileForm.state || undefined,
city: profileForm.city || undefined,
country: profileForm.country || undefined
});
setProfile(payload);
setProfileForm((prev) => ({
...prev,
profile_metadata_text: prettyJson(payload?.profile_metadata)
}));
await checkAppState();
toast({
title: "Profile updated",
description: "Your profile details were saved successfully."
@@ -140,6 +150,57 @@ export default function Profile() {
}
}
async function handleAvatarUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!/^image\/(png|jpe?g|webp|gif)$/i.test(file.type)) {
toast({
title: "Unsupported file type",
description: "Please upload PNG, JPG, WEBP, or GIF image files only.",
variant: "destructive"
});
return;
}
const maxBytes = 1_000_000;
if (file.size > maxBytes) {
toast({
title: "Image too large",
description: "Avatar image must be 1MB or smaller.",
variant: "destructive"
});
return;
}
try {
setUploadingAvatar(true);
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("Unable to read image file."));
reader.readAsDataURL(file);
});
await appClient.profile.uploadAvatar(dataUrl);
await loadProfileAndSessions();
await checkAppState();
toast({
title: "Avatar updated",
description: "Your profile picture has been uploaded successfully."
});
} catch (error) {
toast({
title: "Avatar upload failed",
description: error?.message || "Could not upload the selected image.",
variant: "destructive"
});
} finally {
setUploadingAvatar(false);
event.target.value = "";
}
}
async function handleChangePassword() {
if (passwordForm.new_password !== passwordForm.confirm_password) {
toast({
@@ -320,6 +381,36 @@ export default function Profile() {
<UserCircle2 className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Profile</h3>
</div>
<div className="mb-6 flex flex-wrap items-center gap-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="h-16 w-16 overflow-hidden rounded-xl border border-border bg-card">
{profile?.avatar_url ? (
<img src={profile.avatar_url} alt="Profile avatar" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<UserCircle2 className="h-7 w-7" />
</div>
)}
</div>
<div className="min-w-[220px] flex-1">
<p className="text-sm font-semibold text-foreground">{profile?.full_name || profile?.email}</p>
<p className="text-xs text-muted-foreground">{profile?.role || "-"}</p>
<Label
htmlFor="avatar-upload"
className="mt-2 inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground hover:bg-muted"
>
<ImagePlus className="h-3.5 w-3.5" />
{uploadingAvatar ? "Uploading..." : "Upload profile picture"}
</Label>
<input
id="avatar-upload"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,image/gif"
className="hidden"
onChange={handleAvatarUpload}
disabled={uploadingAvatar}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label>Email</Label>
@@ -340,27 +431,69 @@ export default function Profile() {
/>
</div>
<div>
<Label>Avatar URL</Label>
<Label>Phone</Label>
<Input
value={profileForm.avatar_url}
value={profileForm.phone}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, avatar_url: event.target.value }))
setProfileForm((prev) => ({ ...prev, phone: event.target.value }))
}
className="mt-1"
placeholder="https://example.com/avatar.png"
placeholder="+2348012345678"
/>
</div>
<div>
<Label>Telephone</Label>
<Input
value={profileForm.telephone}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, telephone: event.target.value }))
}
className="mt-1"
placeholder="+23417001234"
/>
</div>
<div>
<Label>Country</Label>
<Input
value={profileForm.country}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, country: event.target.value }))
}
className="mt-1"
placeholder="Nigeria"
/>
</div>
<div>
<Label>State</Label>
<Input
value={profileForm.state}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, state: event.target.value }))
}
className="mt-1"
placeholder="Lagos"
/>
</div>
<div>
<Label>City</Label>
<Input
value={profileForm.city}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, city: event.target.value }))
}
className="mt-1"
placeholder="Ikeja"
/>
</div>
<div className="md:col-span-2">
<Label>Profile Metadata (JSON)</Label>
<textarea
value={profileForm.profile_metadata_text}
<Label>Address</Label>
<Input
value={profileForm.address}
onChange={(event) =>
setProfileForm((prev) => ({
...prev,
profile_metadata_text: event.target.value
}))
setProfileForm((prev) => ({ ...prev, address: event.target.value }))
}
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground"
className="mt-1"
placeholder="No. 5 Example Street"
/>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Brush, ClipboardList, Globe, Rocket } from "lucide-react";
import { Brush, ClipboardList, Globe, Megaphone, Rocket, Shield } from "lucide-react";
import { appClient } from "@/api/appClient";
import PageHeader from "../components/shared/PageHeader";
import { Button } from "@/components/ui/button";
@@ -10,6 +10,7 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import { useAuth } from "@/lib/AuthContext";
const defaultBranding = {
app_name: "VotCloud",
@@ -46,8 +47,19 @@ const defaultNavForm = {
metadata_text: "{}"
};
const roleOptions = ["SUPER_ADMIN", "TENANT_ADMIN", "OPERATOR", "VIEWER"];
const defaultAnnouncementForm = {
title: "",
message: "",
severity: "INFO",
is_active: true,
audience_roles: []
};
export default function SystemManagement() {
const { toast } = useToast();
const { loadPublicSettings } = useAuth();
const [loading, setLoading] = useState(true);
const [savingBranding, setSavingBranding] = useState(false);
@@ -56,6 +68,8 @@ export default function SystemManagement() {
const [expiringTrials, setExpiringTrials] = useState(false);
const [savingPage, setSavingPage] = useState(false);
const [savingNav, setSavingNav] = useState(false);
const [savingModules, setSavingModules] = useState(false);
const [savingAnnouncement, setSavingAnnouncement] = useState(false);
const [branding, setBranding] = useState(defaultBranding);
const [subscriptionPolicy, setSubscriptionPolicy] = useState(defaultSubscriptionPolicy);
@@ -74,17 +88,31 @@ export default function SystemManagement() {
const [navigationItems, setNavigationItems] = useState([]);
const [navForm, setNavForm] = useState(defaultNavForm);
const [editingNavId, setEditingNavId] = useState(null);
const [modulePolicies, setModulePolicies] = useState([]);
const [announcements, setAnnouncements] = useState([]);
const [announcementForm, setAnnouncementForm] = useState(defaultAnnouncementForm);
const [editingAnnouncementId, setEditingAnnouncementId] = useState(null);
async function loadData() {
try {
setLoading(true);
const [brandingPayload, policyPayload, tenantsPayload, pagesPayload, navigationPayload] =
const [
brandingPayload,
policyPayload,
tenantsPayload,
pagesPayload,
navigationPayload,
modulesPayload,
announcementsPayload
] =
await Promise.all([
appClient.system.getBranding(),
appClient.system.getSubscriptionPolicy(),
appClient.entities.Tenant.list("-created_date", 500),
appClient.system.listCmsPages({ include_drafts: true }),
appClient.system.listNavigationItems()
appClient.system.listNavigationItems(),
appClient.system.getModulesPolicy(),
appClient.announcements.adminList()
]);
setBranding((prev) => ({ ...prev, ...(brandingPayload || {}) }));
@@ -92,6 +120,8 @@ export default function SystemManagement() {
setTenants(tenantsPayload || []);
setPages(pagesPayload || []);
setNavigationItems(navigationPayload || []);
setModulePolicies(modulesPayload?.modules || []);
setAnnouncements(announcementsPayload?.items || []);
} catch (error) {
toast({
title: "System data load failed",
@@ -112,6 +142,7 @@ export default function SystemManagement() {
setSavingBranding(true);
const payload = await appClient.system.saveBranding(branding);
setBranding((prev) => ({ ...prev, ...(payload || {}) }));
await loadPublicSettings();
toast({
title: "Branding updated",
description: "White-label branding settings were saved."
@@ -127,6 +158,51 @@ export default function SystemManagement() {
}
}
async function uploadBrandLogo(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!/^image\/(png|jpe?g|webp|gif)$/i.test(file.type)) {
toast({
title: "Unsupported logo format",
description: "Upload PNG, JPG, WEBP, or GIF logo file.",
variant: "destructive"
});
return;
}
if (file.size > 1_000_000) {
toast({
title: "Logo too large",
description: "Logo file must be 1MB or less.",
variant: "destructive"
});
return;
}
try {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("Unable to read logo file."));
reader.readAsDataURL(file);
});
setBranding((prev) => ({ ...prev, logo_url: dataUrl }));
toast({
title: "Logo selected",
description: "Save branding to publish this logo globally."
});
} catch (error) {
toast({
title: "Logo upload failed",
description: error?.message || "Could not process logo file.",
variant: "destructive"
});
} finally {
event.target.value = "";
}
}
async function savePolicy() {
try {
setSavingPolicy(true);
@@ -335,6 +411,122 @@ export default function SystemManagement() {
}
}
function toggleRoleForModule(moduleKey, role) {
setModulePolicies((prev) =>
prev.map((module) => {
if (module.key !== moduleKey) return module;
const nextRoles = new Set(module.roles || []);
if (nextRoles.has(role)) {
nextRoles.delete(role);
} else {
nextRoles.add(role);
}
return {
...module,
roles: [...nextRoles]
};
})
);
}
async function saveModulePolicies() {
try {
setSavingModules(true);
const payload = {
modules: modulePolicies.map((module) => ({
key: module.key,
enabled: Boolean(module.enabled),
roles: (module.roles || []).length > 0 ? module.roles : ["SUPER_ADMIN"]
}))
};
const result = await appClient.system.saveModulesPolicy(payload);
setModulePolicies(result?.modules || []);
toast({
title: "Module policy updated",
description: "Module access matrix has been saved."
});
} catch (error) {
toast({
title: "Module policy save failed",
description: error?.message || "Could not save module policy.",
variant: "destructive"
});
} finally {
setSavingModules(false);
}
}
function resetAnnouncementForm() {
setAnnouncementForm(defaultAnnouncementForm);
setEditingAnnouncementId(null);
}
function editAnnouncement(item) {
setEditingAnnouncementId(item.id);
setAnnouncementForm({
title: item.title || "",
message: item.message || "",
severity: item.severity || "INFO",
is_active: Boolean(item.is_active),
audience_roles: item.audience_roles || []
});
}
async function saveAnnouncement() {
try {
setSavingAnnouncement(true);
const payload = {
title: announcementForm.title.trim(),
message: announcementForm.message.trim(),
severity: announcementForm.severity,
is_active: announcementForm.is_active,
audience_roles: announcementForm.audience_roles
};
if (editingAnnouncementId) {
await appClient.announcements.adminUpdate(editingAnnouncementId, payload);
} else {
await appClient.announcements.adminCreate(payload);
}
const refreshed = await appClient.announcements.adminList();
setAnnouncements(refreshed?.items || []);
resetAnnouncementForm();
toast({
title: editingAnnouncementId ? "Announcement updated" : "Announcement published",
description: "Notification center content has been updated."
});
} catch (error) {
toast({
title: "Announcement save failed",
description: error?.message || "Could not save announcement.",
variant: "destructive"
});
} finally {
setSavingAnnouncement(false);
}
}
async function removeAnnouncement(id) {
try {
await appClient.announcements.adminDelete(id);
const refreshed = await appClient.announcements.adminList();
setAnnouncements(refreshed?.items || []);
if (editingAnnouncementId === id) {
resetAnnouncementForm();
}
toast({
title: "Announcement deleted",
description: "The selected announcement has been removed."
});
} catch (error) {
toast({
title: "Delete failed",
description: error?.message || "Could not delete announcement.",
variant: "destructive"
});
}
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -364,6 +556,12 @@ export default function SystemManagement() {
<TabsTrigger value="navigation" className="gap-1.5 text-xs">
<ClipboardList className="h-3.5 w-3.5" /> Navigation
</TabsTrigger>
<TabsTrigger value="modules" className="gap-1.5 text-xs">
<Shield className="h-3.5 w-3.5" /> Modules & Access
</TabsTrigger>
<TabsTrigger value="announcements" className="gap-1.5 text-xs">
<Megaphone className="h-3.5 w-3.5" /> Announcement Center
</TabsTrigger>
</TabsList>
<TabsContent value="branding">
@@ -384,14 +582,23 @@ export default function SystemManagement() {
/>
</div>
<div>
<Label>Logo URL</Label>
<Input
value={branding.logo_url || ""}
onChange={(event) =>
setBranding((prev) => ({ ...prev, logo_url: event.target.value }))
}
className="mt-1"
/>
<Label>Logo Upload</Label>
<div className="mt-1 flex items-center gap-3">
<div className="h-12 w-12 overflow-hidden rounded-lg border border-border bg-muted/25">
{branding.logo_url ? (
<img src={branding.logo_url} alt="Brand logo preview" className="h-full w-full object-cover" />
) : null}
</div>
<label className="inline-flex cursor-pointer items-center rounded-md border border-border bg-card px-3 py-2 text-xs font-medium text-foreground hover:bg-muted">
Upload Logo
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,image/gif"
className="hidden"
onChange={uploadBrandLogo}
/>
</label>
</div>
</div>
<div>
<Label>Primary Color</Label>
@@ -889,6 +1096,238 @@ export default function SystemManagement() {
</div>
</div>
</TabsContent>
<TabsContent value="modules">
<div className="surface-card p-6">
<h3 className="text-sm font-semibold text-foreground">Module Access Matrix</h3>
<p className="mt-1 text-xs text-muted-foreground">
Superadmin can globally enable or disable modules and choose exactly which user roles can access each module.
</p>
<div className="mt-4 space-y-3">
{modulePolicies.map((module) => (
<div key={module.key} className="rounded-xl border border-border bg-background/50 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">{module.label}</p>
<p className="text-xs text-muted-foreground">{module.description}</p>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Enabled</Label>
<Switch
checked={Boolean(module.enabled)}
onCheckedChange={(value) =>
setModulePolicies((prev) =>
prev.map((item) =>
item.key === module.key ? { ...item, enabled: value } : item
)
)
}
/>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 md:grid-cols-4">
{roleOptions.map((role) => {
const checked = (module.roles || []).includes(role);
return (
<button
key={`${module.key}-${role}`}
type="button"
onClick={() => toggleRoleForModule(module.key, role)}
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
checked
? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:bg-muted/35"
}`}
>
{role.replace(/_/g, " ")}
</button>
);
})}
</div>
</div>
))}
</div>
<Button className="mt-4" onClick={saveModulePolicies} disabled={savingModules}>
{savingModules ? "Saving..." : "Save Module Policy"}
</Button>
</div>
</TabsContent>
<TabsContent value="announcements">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<div className="surface-card p-6">
<h3 className="text-sm font-semibold text-foreground">
{editingAnnouncementId ? "Edit Announcement" : "Create Announcement"}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Push platform-wide announcements to users via top-bar notification center.
</p>
<div className="mt-4 space-y-3">
<div>
<Label>Title</Label>
<Input
value={announcementForm.title}
onChange={(event) =>
setAnnouncementForm((prev) => ({ ...prev, title: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Message</Label>
<textarea
value={announcementForm.message}
onChange={(event) =>
setAnnouncementForm((prev) => ({ ...prev, message: event.target.value }))
}
className="mt-1 h-28 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Severity</Label>
<Select
value={announcementForm.severity}
onValueChange={(value) =>
setAnnouncementForm((prev) => ({ ...prev, severity: value }))
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="INFO">Info</SelectItem>
<SelectItem value="WARNING">Warning</SelectItem>
<SelectItem value="CRITICAL">Critical</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
checked={announcementForm.is_active}
onCheckedChange={(value) =>
setAnnouncementForm((prev) => ({ ...prev, is_active: value }))
}
/>
<Label>Active</Label>
</div>
</div>
<div>
<Label>Audience Roles (empty means all users)</Label>
<div className="mt-2 grid grid-cols-2 gap-2">
{roleOptions.map((role) => {
const checked = announcementForm.audience_roles.includes(role);
return (
<button
key={`aud-${role}`}
type="button"
onClick={() =>
setAnnouncementForm((prev) => {
const next = new Set(prev.audience_roles);
if (next.has(role)) next.delete(role);
else next.add(role);
return {
...prev,
audience_roles: [...next]
};
})
}
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
checked
? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:bg-muted/35"
}`}
>
{role.replace(/_/g, " ")}
</button>
);
})}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={saveAnnouncement} disabled={savingAnnouncement}>
{savingAnnouncement
? "Saving..."
: editingAnnouncementId
? "Update Announcement"
: "Publish Announcement"}
</Button>
{editingAnnouncementId ? (
<Button variant="outline" onClick={resetAnnouncementForm}>
Cancel Edit
</Button>
) : null}
</div>
</div>
</div>
<div className="surface-card overflow-hidden">
<div className="border-b border-border bg-muted/40 px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">
Announcements ({announcements.length})
</h3>
</div>
<div className="max-h-[520px] overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/20">
{["Title", "Severity", "Audience", "State", "Actions"].map((column) => (
<th
key={column}
className="px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{announcements.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="px-4 py-2 text-sm text-foreground">{item.title}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{item.severity}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{(item.audience_roles || []).length > 0
? item.audience_roles.join(", ")
: "All Users"}
</td>
<td className="px-4 py-2 text-xs">
<span
className={`rounded-full px-2 py-0.5 ${
item.is_active
? "bg-emerald-50 text-emerald-700"
: "bg-slate-100 text-slate-600"
}`}
>
{item.is_active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => editAnnouncement(item)}>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => removeAnnouncement(item.id)}
>
Delete
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);