diff --git a/backend/src/app.ts b/backend/src/app.ts index 772b8ba..d0d4a7f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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); diff --git a/backend/src/routes/announcements.routes.ts b/backend/src/routes/announcements.routes.ts new file mode 100644 index 0000000..f8d8a64 --- /dev/null +++ b/backend/src/routes/announcements.routes.ts @@ -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; diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index c8895cc..a91efd3 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -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); } diff --git a/backend/src/routes/profile.routes.ts b/backend/src/routes/profile.routes.ts index 8c72104..18c551b 100644 --- a/backend/src/routes/profile.routes.ts +++ b/backend/src/routes/profile.routes.ts @@ -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; +} + +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(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; + + const metadataFields: Array> = [ + "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); diff --git a/backend/src/routes/system.routes.ts b/backend/src/routes/system.routes.ts index 4b1f15e..d63a9d3 100644 --- a/backend/src/routes/system.routes.ts +++ b/backend/src/routes/system.routes.ts @@ -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(key: string, fallback: T): Promise { 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 = { + 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 = { + 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 = { + OR: [ + { email: { contains: parsed.q, mode: "insensitive" } }, + { full_name: { contains: parsed.q, mode: "insensitive" } } + ] + }; + if (isTenantScoped && tenantId) { + userWhere.tenant_id = tenantId; + } + + const invoiceWhere: Record = { + 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 ?? {}); diff --git a/backend/src/services/announcement.service.ts b/backend/src/services/announcement.service.ts new file mode 100644 index 0000000..5826ec9 --- /dev/null +++ b/backend/src/services/announcement.service.ts @@ -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; +}; + +const ROLE_VALUES = new Set(Object.values(Role)); +const SEVERITY_VALUES = new Set(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; + 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; + 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 = {}; + const rawReads = entry.reads; + if (rawReads && typeof rawReads === "object" && !Array.isArray(rawReads)) { + for (const [userId, rawIds] of Object.entries(rawReads as Record)) { + 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 }; +} diff --git a/backend/src/services/module-access.service.ts b/backend/src/services/module-access.service.ts new file mode 100644 index 0000000..a8ad183 --- /dev/null +++ b/backend/src/services/module-access.service.ts @@ -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; + +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; + 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; + 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 + ); + + return { + modules: toModulePolicyResponse(policy).map((module) => ({ + ...module, + allowed: access[module.key].allowed + })), + access + }; +} diff --git a/src/App.jsx b/src/App.jsx index 8393986..531ec66 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ; + } + return element; + }; // Show loading spinner while checking app public settings or auth if (isLoadingPublicSettings || isLoadingAuth) { @@ -46,23 +81,23 @@ const AuthenticatedApp = () => { : } /> : }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> : } /> diff --git a/src/api/appClient.js b/src/api/appClient.js index 5f584a1..c632ce8 100644 --- a/src/api/appClient.js +++ b/src/api/appClient.js @@ -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, diff --git a/src/components/layout/AppLayout.jsx b/src/components/layout/AppLayout.jsx index e5319d7..ac7acc2 100644 --- a/src/components/layout/AppLayout.jsx +++ b/src/components/layout/AppLayout.jsx @@ -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 (
@@ -22,24 +182,156 @@ export default function AppLayout() { {currentNav?.label ?? "Overview"}
-
+
setSearchOpen(true)} + onChange={(event) => setSearch(event.target.value)} /> Ctrl K + + {searchOpen ? ( +
+ {searchLoading ? ( +

Searching...

+ ) : filteredSearchResults.length > 0 ? ( + filteredSearchResults.map((item) => ( + + )) + ) : ( +

+ {search.trim().length < 2 ? "Type at least 2 characters to search." : "No results found."} +

+ )} +
+ ) : null}
+ +
+ + + {notificationsOpen ? ( +
+
+

Notifications

+ +
+
+ {announcementInbox.items.length > 0 ? ( + announcementInbox.items.map((item) => ( + + )) + ) : ( +

+ No notifications available. +

+ )} +
+
+ ) : null} +
+ +
+ + + {profileOpen ? ( +
+ + +
+ ) : null} +
diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 1dd198a..6198883 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -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() { >
-
- +
+ {brandLogo ? ( + Brand logo + ) : ( + + )}
{!collapsed && (
@@ -81,7 +87,13 @@ export default function Sidebar() {