diff --git a/backend/prisma/migrations/20260417231000_enterprise_security_system/migration.sql b/backend/prisma/migrations/20260417231000_enterprise_security_system/migration.sql new file mode 100644 index 0000000..16033cc --- /dev/null +++ b/backend/prisma/migrations/20260417231000_enterprise_security_system/migration.sql @@ -0,0 +1,110 @@ +-- AlterTable +ALTER TABLE "User" +ADD COLUMN "avatar_url" TEXT, +ADD COLUMN "profile_metadata" JSONB NOT NULL DEFAULT '{}', +ADD COLUMN "must_change_password" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "mfa_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "mfa_secret" TEXT, +ADD COLUMN "mfa_recovery_codes" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "password_changed_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Tenant" +ADD COLUMN "trial_starts_at" TIMESTAMP(3), +ADD COLUMN "trial_ends_at" TIMESTAMP(3), +ADD COLUMN "trial_grace_ends_at" TIMESTAMP(3), +ADD COLUMN "trial_days" INTEGER, +ADD COLUMN "trial_locked" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "AuthSession" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "refresh_token_hash" TEXT NOT NULL, + "ip_address" TEXT, + "user_agent" TEXT, + "issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "last_used_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AuthSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CmsPage" ( + "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "section" TEXT NOT NULL DEFAULT 'general', + "content" JSONB NOT NULL DEFAULT '{}', + "is_published" BOOLEAN NOT NULL DEFAULT false, + "created_by" TEXT, + "updated_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CmsPage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SiteNavigationItem" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "href" TEXT NOT NULL, + "position" TEXT NOT NULL DEFAULT 'header', + "sort_order" INTEGER NOT NULL DEFAULT 100, + "is_enabled" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SiteNavigationItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthSession_refresh_token_hash_key" ON "AuthSession"("refresh_token_hash"); + +-- CreateIndex +CREATE INDEX "AuthSession_user_id_revoked_at_idx" ON "AuthSession"("user_id", "revoked_at"); + +-- CreateIndex +CREATE INDEX "AuthSession_expires_at_idx" ON "AuthSession"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_token_hash_key" ON "PasswordResetToken"("token_hash"); + +-- CreateIndex +CREATE INDEX "PasswordResetToken_user_id_expires_at_idx" ON "PasswordResetToken"("user_id", "expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "CmsPage_slug_key" ON "CmsPage"("slug"); + +-- CreateIndex +CREATE INDEX "CmsPage_section_is_published_idx" ON "CmsPage"("section", "is_published"); + +-- CreateIndex +CREATE INDEX "SiteNavigationItem_position_sort_order_idx" ON "SiteNavigationItem"("position", "sort_order"); + +-- CreateIndex +CREATE INDEX "Tenant_trial_ends_at_idx" ON "Tenant"("trial_ends_at"); + +-- AddForeignKey +ALTER TABLE "AuthSession" ADD CONSTRAINT "AuthSession_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bc394e1..043b8ed 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -302,14 +302,23 @@ model User { email String @unique password_hash String full_name String? + avatar_url String? + profile_metadata Json @default("{}") role Role @default(VIEWER) tenant_id String? is_active Boolean @default(true) + must_change_password Boolean @default(false) + mfa_enabled Boolean @default(false) + mfa_secret String? + mfa_recovery_codes Json @default("[]") + password_changed_at DateTime? last_login_at DateTime? created_at DateTime @default(now()) updated_at DateTime @updatedAt tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) + auth_sessions AuthSession[] + password_reset_tokens PasswordResetToken[] @@index([tenant_id]) } @@ -319,6 +328,11 @@ model Tenant { name String slug String @unique status TenantStatus @default(ACTIVE) + trial_starts_at DateTime? + trial_ends_at DateTime? + trial_grace_ends_at DateTime? + trial_days Int? + trial_locked Boolean @default(false) plan String @default("starter") owner_email String member_emails Json @default("[]") @@ -351,9 +365,42 @@ model Tenant { monitoring_alert_events MonitoringAlertEvent[] @@index([status]) + @@index([trial_ends_at]) @@index([owner_email]) } +model AuthSession { + id String @id @default(cuid()) + user_id String + refresh_token_hash String @unique + ip_address String? + user_agent String? + issued_at DateTime @default(now()) + expires_at DateTime + last_used_at DateTime? + revoked_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id, revoked_at]) + @@index([expires_at]) +} + +model PasswordResetToken { + id String @id @default(cuid()) + user_id String + token_hash String @unique + expires_at DateTime + used_at DateTime? + created_at DateTime @default(now()) + + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id, expires_at]) +} + model ProxmoxNode { id String @id @default(cuid()) name String @@ -1203,3 +1250,32 @@ model Setting { created_at DateTime @default(now()) updated_at DateTime @updatedAt } + +model CmsPage { + id String @id @default(cuid()) + slug String @unique + title String + section String @default("general") + content Json @default("{}") + is_published Boolean @default(false) + created_by String? + updated_by String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([section, is_published]) +} + +model SiteNavigationItem { + id String @id @default(cuid()) + label String + href String + position String @default("header") + sort_order Int @default(100) + is_enabled Boolean @default(true) + metadata Json @default("{}") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([position, sort_order]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 9dbea75..772b8ba 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -18,6 +18,9 @@ import backupRoutes from "./routes/backup.routes"; import networkRoutes from "./routes/network.routes"; import monitoringRoutes from "./routes/monitoring.routes"; 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 { errorHandler, notFoundHandler } from "./middleware/error-handler"; import { createRateLimit } from "./middleware/rate-limit"; @@ -82,6 +85,9 @@ export function createApp() { app.use("/api/network", networkRoutes); app.use("/api/monitoring", monitoringRoutes); app.use("/api/client", clientRoutes); + app.use("/api/profile", profileRoutes); + app.use("/api/admin", adminUsersRoutes); + app.use("/api/system", systemRoutes); app.use(notFoundHandler); app.use(errorHandler); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 6f11e7c..d2eb072 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -8,6 +8,7 @@ const envSchema = z.object({ PORT: z.coerce.number().default(8080), DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), JWT_SECRET: z.string().min(16, "JWT_SECRET must be at least 16 characters"), + SETTINGS_ENCRYPTION_KEY: z.string().min(16).optional(), JWT_EXPIRES_IN: z.string().default("7d"), JWT_REFRESH_SECRET: z.string().min(16, "JWT_REFRESH_SECRET must be at least 16 characters").optional(), JWT_REFRESH_EXPIRES_IN: z.string().default("30d"), diff --git a/backend/src/lib/security.ts b/backend/src/lib/security.ts new file mode 100644 index 0000000..fa99dd1 --- /dev/null +++ b/backend/src/lib/security.ts @@ -0,0 +1,105 @@ +import crypto from "crypto"; +import { env } from "../config/env"; + +const PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*"; + +type EncryptedEnvelope = { + __enc: "v1"; + iv: string; + tag: string; + data: string; +}; + +function getEncryptionKey() { + const seed = env.SETTINGS_ENCRYPTION_KEY ?? env.JWT_SECRET; + return crypto.createHash("sha256").update(seed).digest(); +} + +function normalizeSecret(value: string) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +export function generateSecurePassword(length = 20) { + const bytes = crypto.randomBytes(length * 2); + let output = ""; + for (let i = 0; i < bytes.length && output.length < length; i += 1) { + output += PASSWORD_ALPHABET[bytes[i] % PASSWORD_ALPHABET.length]; + } + return output; +} + +export function hashToken(token: string) { + return normalizeSecret(token); +} + +export function timingSafeEqualHash(candidate: string, storedHash: string) { + const candidateHash = Buffer.from(normalizeSecret(candidate), "utf8"); + const knownHash = Buffer.from(storedHash, "utf8"); + if (candidateHash.length !== knownHash.length) return false; + return crypto.timingSafeEqual(candidateHash, knownHash); +} + +export function generateRecoveryCodes(count = 8) { + const codes: string[] = []; + for (let i = 0; i < count; i += 1) { + const raw = crypto.randomBytes(5).toString("hex").toUpperCase(); + codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`); + } + return codes; +} + +export function hashRecoveryCodes(codes: string[]) { + return codes.map((code) => normalizeSecret(code.trim().toUpperCase())); +} + +export function consumeRecoveryCode(input: string, hashes: string[]) { + const normalized = normalizeSecret(input.trim().toUpperCase()); + const matchIndex = hashes.findIndex((hash) => hash === normalized); + if (matchIndex < 0) { + return { matched: false, remainingHashes: hashes }; + } + const remainingHashes = [...hashes.slice(0, matchIndex), ...hashes.slice(matchIndex + 1)]; + return { matched: true, remainingHashes }; +} + +function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope { + return ( + typeof value === "object" && + value !== null && + (value as Record).__enc === "v1" && + typeof (value as Record).iv === "string" && + typeof (value as Record).tag === "string" && + typeof (value as Record).data === "string" + ); +} + +export function encryptJson(value: unknown): EncryptedEnvelope { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const payload = Buffer.from(JSON.stringify(value), "utf8"); + const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + __enc: "v1", + iv: iv.toString("base64"), + tag: tag.toString("base64"), + data: encrypted.toString("base64") + }; +} + +export function decryptJson(value: unknown): T { + if (!isEncryptedEnvelope(value)) { + return value as T; + } + + const key = getEncryptionKey(); + const iv = Buffer.from(value.iv, "base64"); + const tag = Buffer.from(value.tag, "base64"); + const encrypted = Buffer.from(value.data, "base64"); + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); + return JSON.parse(decrypted) as T; +} diff --git a/backend/src/lib/totp.ts b/backend/src/lib/totp.ts new file mode 100644 index 0000000..387a162 --- /dev/null +++ b/backend/src/lib/totp.ts @@ -0,0 +1,100 @@ +import crypto from "crypto"; + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function base32Encode(buffer: Buffer) { + let bits = 0; + let value = 0; + let output = ""; + + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + + return output; +} + +function base32Decode(input: string) { + const normalized = input.toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, ""); + let bits = 0; + let value = 0; + const bytes: number[] = []; + + for (const char of normalized) { + const index = BASE32_ALPHABET.indexOf(char); + if (index < 0) continue; + value = (value << 5) | index; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + + return Buffer.from(bytes); +} + +function hotp(secret: string, counter: number, digits = 6) { + const key = base32Decode(secret); + const counterBuffer = Buffer.alloc(8); + const high = Math.floor(counter / 0x100000000); + const low = counter % 0x100000000; + counterBuffer.writeUInt32BE(high >>> 0, 0); + counterBuffer.writeUInt32BE(low >>> 0, 4); + + const hmac = crypto.createHmac("sha1", key).update(counterBuffer).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const codeInt = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + + return String(codeInt % 10 ** digits).padStart(digits, "0"); +} + +export function generateTotpSecret(bytes = 20) { + return base32Encode(crypto.randomBytes(bytes)); +} + +export function generateTotpCode(secret: string, timestampMs = Date.now(), stepSeconds = 30, digits = 6) { + const counter = Math.floor(timestampMs / 1000 / stepSeconds); + return hotp(secret, counter, digits); +} + +export function verifyTotpCode( + token: string, + secret: string, + options?: { window?: number; timestampMs?: number; stepSeconds?: number; digits?: number } +) { + const window = options?.window ?? 1; + const timestampMs = options?.timestampMs ?? Date.now(); + const stepSeconds = options?.stepSeconds ?? 30; + const digits = options?.digits ?? 6; + + const normalizedToken = token.replace(/\s+/g, ""); + if (!/^\d{6,8}$/.test(normalizedToken)) return false; + + const baseCounter = Math.floor(timestampMs / 1000 / stepSeconds); + for (let i = -window; i <= window; i += 1) { + if (hotp(secret, baseCounter + i, digits) === normalizedToken) { + return true; + } + } + return false; +} + +export function buildTotpUri(issuer: string, accountLabel: string, secret: string) { + const safeIssuer = encodeURIComponent(issuer); + const safeAccount = encodeURIComponent(accountLabel); + return `otpauth://totp/${safeIssuer}:${safeAccount}?secret=${secret}&issuer=${safeIssuer}&algorithm=SHA1&digits=6&period=30`; +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index e117c7f..74fa84b 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -26,7 +26,10 @@ type Permission = | "security:manage" | "security:read" | "user:manage" - | "user:read"; + | "user:read" + | "profile:read" + | "profile:manage" + | "session:manage"; const rolePermissions: Record> = { SUPER_ADMIN: new Set([ @@ -51,7 +54,10 @@ const rolePermissions: Record> = { "security:manage", "security:read", "user:manage", - "user:read" + "user:read", + "profile:read", + "profile:manage", + "session:manage" ]), TENANT_ADMIN: new Set([ "vm:create", @@ -68,7 +74,10 @@ const rolePermissions: Record> = { "settings:read", "audit:read", "security:read", - "user:read" + "user:read", + "profile:read", + "profile:manage", + "session:manage" ]), OPERATOR: new Set([ "vm:read", @@ -81,7 +90,9 @@ const rolePermissions: Record> = { "backup:read", "audit:read", "security:manage", - "security:read" + "security:read", + "profile:read", + "profile:manage" ]), VIEWER: new Set([ "vm:read", @@ -92,7 +103,9 @@ const rolePermissions: Record> = { "audit:read", "security:read", "settings:read", - "user:read" + "user:read", + "profile:read", + "profile:manage" ]) }; @@ -120,7 +133,8 @@ export function verifyRefreshToken(token: string): Express.UserToken | null { id: decoded.id, email: decoded.email, role: decoded.role, - tenant_id: decoded.tenant_id + tenant_id: decoded.tenant_id, + sid: decoded.sid }; } catch { return null; diff --git a/backend/src/routes/admin-users.routes.ts b/backend/src/routes/admin-users.routes.ts new file mode 100644 index 0000000..3576b46 --- /dev/null +++ b/backend/src/routes/admin-users.routes.ts @@ -0,0 +1,272 @@ +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import { Role } from "@prisma/client"; +import { z } from "zod"; +import { authorize, requireAuth } from "../middleware/auth"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { generateSecurePassword } from "../lib/security"; +import { logAudit } from "../services/audit.service"; +import { toPrismaJsonValue } from "../lib/prisma-json"; + +const router = Router(); + +const createUserSchema = z.object({ + email: z.string().email(), + full_name: z.string().min(1).max(120), + role: z.nativeEnum(Role), + tenant_id: z.string().optional(), + password: z.string().min(10).max(120).optional(), + generate_password: z.boolean().default(true), + is_active: z.boolean().default(true) +}); + +const updateUserSchema = z.object({ + full_name: z.string().min(1).max(120).optional(), + role: z.nativeEnum(Role).optional(), + tenant_id: z.string().nullable().optional(), + is_active: z.boolean().optional() +}); + +function rolesCatalog() { + return [ + { + role: "SUPER_ADMIN", + label: "Super Admin", + scope: "Global", + description: "Full platform control including billing, security, and system configuration." + }, + { + role: "TENANT_ADMIN", + label: "Tenant Admin", + scope: "Tenant", + description: "Owns a tenant environment, users, workloads, and tenant-level billing views." + }, + { + role: "OPERATOR", + label: "Operator", + scope: "Ops", + description: "Runs day-2 operations for compute, backup, and node workflows." + }, + { + role: "VIEWER", + label: "Viewer", + scope: "Read-only", + description: "Read-only access for auditors and stakeholders." + } + ]; +} + +router.get("/roles", requireAuth, authorize("rbac:manage"), async (_req, res) => { + res.json(rolesCatalog()); +}); + +router.get("/users", requireAuth, authorize("user:read"), async (req, res, next) => { + try { + const tenantId = typeof req.query.tenant_id === "string" ? req.query.tenant_id : undefined; + const role = typeof req.query.role === "string" ? req.query.role.toUpperCase() : undefined; + + const where: Record = {}; + if (tenantId) where.tenant_id = tenantId; + if (role && Object.values(Role).includes(role as Role)) where.role = role; + + const users = await prisma.user.findMany({ + where, + orderBy: { created_at: "desc" }, + select: { + id: true, + email: true, + full_name: true, + role: true, + tenant_id: true, + is_active: true, + mfa_enabled: true, + must_change_password: true, + created_at: true, + updated_at: true, + last_login_at: true, + tenant: { + select: { + id: true, + name: true, + slug: true + } + } + } + }); + + res.json(users); + } catch (error) { + next(error); + } +}); + +router.post("/users", requireAuth, authorize("user:manage"), async (req, res, next) => { + try { + const payload = createUserSchema.parse(req.body ?? {}); + if (req.user?.role !== "SUPER_ADMIN") { + throw new HttpError(403, "Only SUPER_ADMIN can create administrative users", "FORBIDDEN"); + } + if (payload.role === "TENANT_ADMIN" && !payload.tenant_id) { + throw new HttpError(400, "tenant_id is required for TENANT_ADMIN users", "VALIDATION_ERROR"); + } + + const existing = await prisma.user.findUnique({ where: { email: payload.email.toLowerCase().trim() } }); + if (existing) { + throw new HttpError(409, "A user with this email already exists", "USER_EXISTS"); + } + + const tempPassword = payload.generate_password || !payload.password ? generateSecurePassword(20) : payload.password; + const passwordHash = await bcrypt.hash(tempPassword, 12); + + const user = await prisma.user.create({ + data: { + email: payload.email.toLowerCase().trim(), + full_name: payload.full_name, + role: payload.role, + tenant_id: payload.tenant_id ?? null, + is_active: payload.is_active, + password_hash: passwordHash, + must_change_password: true, + password_changed_at: new Date() + }, + select: { + id: true, + email: true, + full_name: true, + role: true, + tenant_id: true, + is_active: true, + must_change_password: true, + created_at: true + } + }); + + await logAudit({ + action: "rbac.user.create", + resource_type: "USER", + resource_id: user.id, + resource_name: user.email, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + role: user.role, + tenant_id: user.tenant_id + }), + ip_address: req.ip + }); + + res.status(201).json({ + user, + temporary_password: tempPassword + }); + } catch (error) { + next(error); + } +}); + +router.patch("/users/:id", requireAuth, authorize("user:manage"), async (req, res, next) => { + try { + const payload = updateUserSchema.parse(req.body ?? {}); + const existing = await prisma.user.findUnique({ where: { id: req.params.id } }); + if (!existing) { + throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + } + + if (req.user?.role !== "SUPER_ADMIN" && existing.role === "SUPER_ADMIN") { + throw new HttpError(403, "Only SUPER_ADMIN can modify this user", "FORBIDDEN"); + } + if (payload.role === "SUPER_ADMIN" && req.user?.role !== "SUPER_ADMIN") { + throw new HttpError(403, "Only SUPER_ADMIN can assign SUPER_ADMIN role", "FORBIDDEN"); + } + + const updated = await prisma.user.update({ + where: { id: req.params.id }, + data: payload, + select: { + id: true, + email: true, + full_name: true, + role: true, + tenant_id: true, + is_active: true, + must_change_password: true, + created_at: true, + updated_at: true, + last_login_at: true + } + }); + + await logAudit({ + action: "rbac.user.update", + resource_type: "USER", + resource_id: updated.id, + resource_name: updated.email, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ changes: payload }), + ip_address: req.ip + }); + + res.json(updated); + } catch (error) { + next(error); + } +}); + +router.post("/users/:id/reset-password", requireAuth, authorize("user:manage"), async (req, res, next) => { + try { + const existing = await prisma.user.findUnique({ where: { id: req.params.id } }); + if (!existing) { + throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + } + + if (existing.role === "SUPER_ADMIN" && req.user?.role !== "SUPER_ADMIN") { + throw new HttpError(403, "Only SUPER_ADMIN can reset this account", "FORBIDDEN"); + } + + const tempPassword = generateSecurePassword(20); + const passwordHash = await bcrypt.hash(tempPassword, 12); + + await prisma.user.update({ + where: { id: existing.id }, + data: { + password_hash: passwordHash, + must_change_password: true, + password_changed_at: new Date(), + mfa_enabled: false, + mfa_secret: null, + mfa_recovery_codes: [] + } + }); + + await prisma.authSession.updateMany({ + where: { + user_id: existing.id, + revoked_at: null + }, + data: { + revoked_at: new Date() + } + }); + + await logAudit({ + action: "rbac.user.reset_password", + resource_type: "USER", + resource_id: existing.id, + resource_name: existing.email, + actor_email: req.user!.email, + actor_role: req.user!.role, + ip_address: req.ip + }); + + res.json({ + success: true, + temporary_password: tempPassword + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index b5fbd8d..c8895cc 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -1,25 +1,80 @@ import { Router } from "express"; +import crypto from "crypto"; import bcrypt from "bcryptjs"; import { z } from "zod"; +import jwt from "jsonwebtoken"; import { prisma } from "../lib/prisma"; 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"; const router = Router(); const loginSchema = z.object({ email: z.string().email(), - password: z.string().min(1) + password: z.string().min(1), + mfa_code: z.string().optional(), + recovery_code: z.string().optional() }); const refreshSchema = z.object({ refresh_token: z.string().min(1) }); +const logoutSchema = z.object({ + refresh_token: z.string().min(1).optional() +}); + +function tokenExpiryDate(refreshToken: string) { + const decoded = jwt.decode(refreshToken) as { exp?: number } | null; + const exp = decoded?.exp ? new Date(decoded.exp * 1000) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + return exp; +} + +async function createAuthSession(input: { + user: { + id: string; + email: string; + role: Express.UserToken["role"]; + tenant_id?: string | null; + }; + ipAddress?: string; + userAgent?: string; +}) { + const sessionId = crypto.randomUUID(); + const basePayload = { + id: input.user.id, + email: input.user.email, + role: input.user.role, + tenant_id: input.user.tenant_id, + sid: sessionId + }; + const accessToken = createJwtToken(basePayload); + const refreshToken = createRefreshToken(basePayload); + + await prisma.authSession.create({ + data: { + id: sessionId, + user_id: input.user.id, + refresh_token_hash: hashToken(refreshToken), + ip_address: input.ipAddress, + user_agent: input.userAgent, + expires_at: tokenExpiryDate(refreshToken), + last_used_at: new Date() + } + }); + + return { + token: accessToken, + refresh_token: refreshToken + }; +} + router.post("/login", async (req, res, next) => { try { const payload = loginSchema.parse(req.body); - const user = await prisma.user.findUnique({ where: { email: payload.email } }); + const user = await prisma.user.findUnique({ where: { email: payload.email.toLowerCase().trim() } }); if (!user || !user.is_active) { throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS"); } @@ -28,6 +83,35 @@ router.post("/login", async (req, res, next) => { throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS"); } + if (user.mfa_enabled) { + const mfaCode = payload.mfa_code?.trim(); + const recoveryCode = payload.recovery_code?.trim(); + if (!mfaCode && !recoveryCode) { + throw new HttpError(401, "MFA code is required", "MFA_REQUIRED"); + } + + let mfaPassed = false; + if (mfaCode && user.mfa_secret) { + mfaPassed = verifyTotpCode(mfaCode, user.mfa_secret, { window: 1 }); + } + + if (!mfaPassed && recoveryCode) { + const existingHashes = Array.isArray(user.mfa_recovery_codes) ? (user.mfa_recovery_codes as string[]) : []; + const result = consumeRecoveryCode(recoveryCode, existingHashes); + if (result.matched) { + mfaPassed = true; + await prisma.user.update({ + where: { id: user.id }, + data: { mfa_recovery_codes: result.remainingHashes } + }); + } + } + + if (!mfaPassed) { + throw new HttpError(401, "Invalid MFA code", "MFA_INVALID"); + } + } + await prisma.user.update({ where: { id: user.id }, data: { last_login_at: new Date() } @@ -39,18 +123,24 @@ router.post("/login", async (req, res, next) => { role: user.role, tenant_id: user.tenant_id }; - const token = createJwtToken(userPayload); - const refreshToken = createRefreshToken(userPayload); + const tokens = await createAuthSession({ + user: userPayload, + ipAddress: req.ip, + userAgent: req.get("user-agent") ?? undefined + }); res.json({ - token, - refresh_token: refreshToken, + token: tokens.token, + refresh_token: tokens.refresh_token, + must_change_password: user.must_change_password, user: { id: user.id, email: user.email, full_name: user.full_name, role: user.role, - tenant_id: user.tenant_id + tenant_id: user.tenant_id, + avatar_url: user.avatar_url, + mfa_enabled: user.mfa_enabled } }); } catch (error) { @@ -62,10 +152,27 @@ router.post("/refresh", async (req, res, next) => { try { const payload = refreshSchema.parse(req.body ?? {}); const decoded = verifyRefreshToken(payload.refresh_token); - if (!decoded) { + if (!decoded || !decoded.sid) { throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN"); } + const session = await prisma.authSession.findFirst({ + where: { + id: decoded.sid, + user_id: decoded.id, + revoked_at: null + } + }); + if (!session) { + throw new HttpError(401, "Refresh session not found", "INVALID_REFRESH_TOKEN"); + } + if (session.expires_at.getTime() < Date.now()) { + throw new HttpError(401, "Refresh session expired", "INVALID_REFRESH_TOKEN"); + } + if (session.refresh_token_hash !== hashToken(payload.refresh_token)) { + throw new HttpError(401, "Refresh token mismatch", "INVALID_REFRESH_TOKEN"); + } + const user = await prisma.user.findUnique({ where: { id: decoded.id }, select: { @@ -84,11 +191,23 @@ router.post("/refresh", async (req, res, next) => { id: user.id, email: user.email, role: user.role, - tenant_id: user.tenant_id + tenant_id: user.tenant_id, + sid: decoded.sid }; const token = createJwtToken(userPayload); const refreshToken = createRefreshToken(userPayload); + await prisma.authSession.update({ + where: { id: decoded.sid }, + data: { + refresh_token_hash: hashToken(refreshToken), + expires_at: tokenExpiryDate(refreshToken), + last_used_at: new Date(), + ip_address: req.ip, + user_agent: req.get("user-agent") ?? session.user_agent + } + }); + res.json({ token, refresh_token: refreshToken @@ -98,6 +217,46 @@ router.post("/refresh", async (req, res, next) => { } }); +router.post("/logout", requireAuth, async (req, res, next) => { + try { + const payload = logoutSchema.parse(req.body ?? {}); + const refreshToken = payload.refresh_token; + if (!refreshToken) { + await prisma.authSession.updateMany({ + where: { + user_id: req.user!.id, + revoked_at: null + }, + data: { + revoked_at: new Date() + } + }); + return res.json({ success: true, revoked: "all" }); + } + + const decoded = verifyRefreshToken(refreshToken); + if (!decoded?.sid || decoded.id !== req.user!.id) { + throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN"); + } + + await prisma.authSession.updateMany({ + where: { + id: decoded.sid, + user_id: req.user!.id, + refresh_token_hash: hashToken(refreshToken), + revoked_at: null + }, + data: { + revoked_at: new Date() + } + }); + + return res.json({ success: true, revoked: decoded.sid }); + } catch (error) { + return next(error); + } +}); + router.get("/me", requireAuth, async (req, res, next) => { try { const user = await prisma.user.findUnique({ @@ -109,7 +268,12 @@ router.get("/me", requireAuth, async (req, res, next) => { role: true, tenant_id: true, is_active: true, - created_at: true + created_at: true, + avatar_url: true, + profile_metadata: true, + mfa_enabled: true, + must_change_password: true, + last_login_at: true } }); if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND"); diff --git a/backend/src/routes/profile.routes.ts b/backend/src/routes/profile.routes.ts new file mode 100644 index 0000000..8c72104 --- /dev/null +++ b/backend/src/routes/profile.routes.ts @@ -0,0 +1,381 @@ +import { Router } from "express"; +import crypto from "crypto"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; +import { requireAuth } from "../middleware/auth"; +import { prisma } from "../lib/prisma"; +import { HttpError } from "../lib/http-error"; +import { buildTotpUri, generateTotpSecret, verifyTotpCode } from "../lib/totp"; +import { generateRecoveryCodes, hashRecoveryCodes, hashToken } from "../lib/security"; +import { logAudit } from "../services/audit.service"; +import { toPrismaJsonValue } from "../lib/prisma-json"; + +const router = Router(); + +const updateProfileSchema = z.object({ + full_name: z.string().min(1).max(120).optional(), + avatar_url: z.string().url().max(500).optional(), + profile_metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional() +}); + +const changePasswordSchema = z.object({ + current_password: z.string().min(1), + new_password: z.string().min(10).max(120) +}); + +const mfaSetupSchema = z.object({ + password: z.string().min(1) +}); + +const mfaEnableSchema = z.object({ + code: z.string().min(6).max(8) +}); + +const mfaDisableSchema = z.object({ + password: z.string().min(1), + code: z.string().min(6).max(8).optional() +}); + +router.get("/", requireAuth, async (req, res, next) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + email: true, + full_name: true, + avatar_url: true, + profile_metadata: true, + role: true, + tenant_id: true, + mfa_enabled: true, + must_change_password: true, + created_at: true, + updated_at: true, + last_login_at: true + } + }); + if (!user) { + throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + } + return res.json(user); + } catch (error) { + return next(error); + } +}); + +router.patch("/", requireAuth, async (req, res, next) => { + try { + const payload = updateProfileSchema.parse(req.body ?? {}); + if (Object.keys(payload).length === 0) { + throw new HttpError(400, "No profile fields were provided", "VALIDATION_ERROR"); + } + + const user = await prisma.user.update({ + where: { id: req.user!.id }, + data: payload, + select: { + id: true, + email: true, + full_name: true, + avatar_url: true, + profile_metadata: true, + role: true, + tenant_id: true, + mfa_enabled: true, + must_change_password: true, + created_at: true, + updated_at: true, + last_login_at: true + } + }); + + await logAudit({ + action: "profile.update", + resource_type: "USER", + resource_id: user.id, + resource_name: user.email, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ updated_fields: Object.keys(payload) }), + ip_address: req.ip + }); + + return res.json(user); + } catch (error) { + return next(error); + } +}); + +router.post("/change-password", requireAuth, async (req, res, next) => { + try { + const payload = changePasswordSchema.parse(req.body ?? {}); + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { id: true, email: true, password_hash: true } + }); + if (!user) { + throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + } + + const matched = await bcrypt.compare(payload.current_password, user.password_hash); + if (!matched) { + throw new HttpError(401, "Current password is incorrect", "INVALID_CREDENTIALS"); + } + + const newHash = await bcrypt.hash(payload.new_password, 12); + await prisma.user.update({ + where: { id: user.id }, + data: { + password_hash: newHash, + must_change_password: false, + password_changed_at: new Date() + } + }); + + await prisma.authSession.updateMany({ + where: { + user_id: user.id, + revoked_at: null + }, + data: { + revoked_at: new Date() + } + }); + + await logAudit({ + action: "profile.password.change", + resource_type: "USER", + resource_id: user.id, + resource_name: user.email, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ revoked_sessions: true }), + ip_address: req.ip + }); + + return res.json({ success: true, message: "Password changed. All active sessions were revoked." }); + } catch (error) { + return next(error); + } +}); + +router.post("/mfa/setup", requireAuth, async (req, res, next) => { + try { + const payload = mfaSetupSchema.parse(req.body ?? {}); + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + email: true, + password_hash: true + } + }); + if (!user) { + throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + } + + const matched = await bcrypt.compare(payload.password, user.password_hash); + if (!matched) { + throw new HttpError(401, "Password is incorrect", "INVALID_CREDENTIALS"); + } + + const secret = generateTotpSecret(); + const recoveryCodes = generateRecoveryCodes(); + await prisma.user.update({ + where: { id: user.id }, + data: { + mfa_secret: secret, + mfa_enabled: false, + mfa_recovery_codes: hashRecoveryCodes(recoveryCodes) + } + }); + + return res.json({ + secret, + otpauth_uri: buildTotpUri("ProxPanel", user.email, secret), + recovery_codes: recoveryCodes + }); + } catch (error) { + return next(error); + } +}); + +router.post("/mfa/enable", requireAuth, async (req, res, next) => { + try { + const payload = mfaEnableSchema.parse(req.body ?? {}); + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + email: true, + mfa_secret: true + } + }); + if (!user || !user.mfa_secret) { + throw new HttpError(400, "MFA setup is not initialized", "MFA_NOT_CONFIGURED"); + } + + if (!verifyTotpCode(payload.code, user.mfa_secret, { window: 1 })) { + throw new HttpError(401, "Invalid MFA code", "MFA_INVALID"); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + mfa_enabled: true + } + }); + + await logAudit({ + action: "profile.mfa.enable", + resource_type: "USER", + resource_id: user.id, + resource_name: user.email, + actor_email: req.user!.email, + actor_role: req.user!.role, + ip_address: req.ip + }); + + return res.json({ success: true, mfa_enabled: true }); + } catch (error) { + return next(error); + } +}); + +router.post("/mfa/disable", requireAuth, async (req, res, next) => { + try { + const payload = mfaDisableSchema.parse(req.body ?? {}); + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + email: true, + password_hash: true, + mfa_enabled: true, + mfa_secret: true + } + }); + if (!user) { + throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + } + + const matched = await bcrypt.compare(payload.password, user.password_hash); + if (!matched) { + throw new HttpError(401, "Password is incorrect", "INVALID_CREDENTIALS"); + } + + if (user.mfa_enabled && user.mfa_secret && payload.code && !verifyTotpCode(payload.code, user.mfa_secret, { window: 1 })) { + throw new HttpError(401, "Invalid MFA code", "MFA_INVALID"); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + mfa_enabled: false, + mfa_secret: null, + mfa_recovery_codes: [] + } + }); + + await logAudit({ + action: "profile.mfa.disable", + resource_type: "USER", + resource_id: user.id, + resource_name: user.email, + actor_email: req.user!.email, + actor_role: req.user!.role, + ip_address: req.ip + }); + + return res.json({ success: true, mfa_enabled: false }); + } catch (error) { + return next(error); + } +}); + +router.get("/sessions", requireAuth, async (req, res, next) => { + try { + const sessions = await prisma.authSession.findMany({ + where: { user_id: req.user!.id }, + orderBy: { issued_at: "desc" }, + select: { + id: true, + ip_address: true, + user_agent: true, + issued_at: true, + last_used_at: true, + expires_at: true, + revoked_at: true + } + }); + return res.json(sessions); + } catch (error) { + return next(error); + } +}); + +router.post("/sessions/:id/revoke", requireAuth, async (req, res, next) => { + try { + const updated = await prisma.authSession.updateMany({ + where: { + id: req.params.id, + user_id: req.user!.id, + revoked_at: null + }, + data: { + revoked_at: new Date() + } + }); + return res.json({ success: true, revoked: updated.count }); + } catch (error) { + return next(error); + } +}); + +router.post("/sessions/revoke-all", requireAuth, async (req, res, next) => { + try { + const result = await prisma.authSession.updateMany({ + where: { + user_id: req.user!.id, + revoked_at: null + }, + data: { revoked_at: new Date() } + }); + return res.json({ success: true, revoked: result.count }); + } catch (error) { + return next(error); + } +}); + +router.post("/password-reset/request", requireAuth, async (req, res, next) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { id: true } + }); + if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND"); + + const token = crypto.randomUUID().replace(/-/g, ""); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + 30 * 60 * 1000); + + await prisma.passwordResetToken.create({ + data: { + user_id: user.id, + token_hash: tokenHash, + expires_at: expiresAt + } + }); + + return res.json({ + success: true, + token, + expires_at: expiresAt + }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/backend/src/routes/resources.routes.ts b/backend/src/routes/resources.routes.ts index 4bf94ba..97967be 100644 --- a/backend/src/routes/resources.routes.ts +++ b/backend/src/routes/resources.routes.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import bcrypt from "bcryptjs"; import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; import { HttpError } from "../lib/http-error"; import { toPrismaJsonValue } from "../lib/prisma-json"; @@ -274,6 +275,31 @@ const resourceMap: Record = { readPermission: "security:read", tenantScoped: true, searchFields: ["destination", "provider_message"] + }, + "cms-pages": { + model: "cmsPage", + readPermission: "settings:read", + createPermission: "settings:manage", + updatePermission: "settings:manage", + deletePermission: "settings:manage", + tenantScoped: false, + searchFields: ["slug", "title", "section"] + }, + "site-navigation-items": { + model: "siteNavigationItem", + readPermission: "settings:read", + createPermission: "settings:manage", + updatePermission: "settings:manage", + deletePermission: "settings:manage", + tenantScoped: false, + searchFields: ["label", "href", "position"] + }, + "auth-sessions": { + model: "authSession", + readPermission: "user:manage", + updatePermission: "user:manage", + deletePermission: "user:manage", + tenantScoped: false } }; @@ -624,6 +650,25 @@ router.post("/:resource", requireAuth, async (req, res, next) => { const model = getModel(meta); const payload = normalizePayload(resource, req.body ?? {}); + if (resource === "users") { + const email = typeof payload.email === "string" ? payload.email.toLowerCase().trim() : ""; + if (!email) { + throw new HttpError(400, "email is required for users.create", "VALIDATION_ERROR"); + } + payload.email = email; + + const plainPassword = typeof payload.password === "string" ? payload.password : undefined; + if (!plainPassword || plainPassword.length < 10) { + throw new HttpError(400, "password (min 10 chars) is required for users.create", "VALIDATION_ERROR"); + } + + payload.password_hash = await bcrypt.hash(plainPassword, 12); + payload.must_change_password = true; + payload.password_changed_at = new Date(); + delete payload.password; + delete payload.password_hash_raw; + } + if (meta.tenantScoped && isTenantScopedUser(req) && req.user?.tenant_id) { if ( meta.model !== "backupRestoreTask" && @@ -666,6 +711,23 @@ router.patch("/:resource/:id", requireAuth, async (req, res, next) => { await ensureItemTenantScope(req, meta, existing); const payload = normalizePayload(resource, req.body ?? {}); + if (resource === "users") { + if (typeof payload.email === "string") { + payload.email = payload.email.toLowerCase().trim(); + } + if ("password_hash" in payload) { + delete payload.password_hash; + } + if (typeof payload.password === "string") { + if (payload.password.length < 10) { + throw new HttpError(400, "password must be at least 10 characters", "VALIDATION_ERROR"); + } + payload.password_hash = await bcrypt.hash(payload.password, 12); + payload.must_change_password = false; + payload.password_changed_at = new Date(); + } + delete payload.password; + } const updated = await model.update({ where: { id: req.params.id }, data: payload diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 4414d73..e626eab 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -2,6 +2,8 @@ import { Router } from "express"; import { z } from "zod"; import { authorize, requireAuth } from "../middleware/auth"; import { prisma } from "../lib/prisma"; +import { toPrismaJsonValue } from "../lib/prisma-json"; +import { decryptJson, encryptJson } from "../lib/security"; import { getOperationsPolicy } from "../services/operations.service"; import { getSchedulerRuntimeSnapshot, reconfigureSchedulers, schedulerDefaults } from "../services/scheduler.service"; @@ -79,10 +81,38 @@ const notificationsSchema = z.object({ ops_email: z.string().email().optional() }); +function decodeSettingValue(raw: unknown, fallback: T): T { + const value = decryptJson(raw); + if (value === null || value === undefined) return fallback; + return value; +} + +async function loadSetting(key: string, fallback: T): Promise { + const setting = await prisma.setting.findUnique({ where: { key } }); + if (!setting) return fallback; + return decodeSettingValue(setting.value, fallback); +} + +async function saveSetting(input: { + key: string; + type: "PROXMOX" | "PAYMENT" | "GENERAL" | "EMAIL"; + value: T; + encrypted: boolean; +}) { + const payloadValue = input.encrypted ? encryptJson(input.value) : input.value; + const normalizedValue = toPrismaJsonValue(payloadValue); + const setting = await prisma.setting.upsert({ + where: { key: input.key }, + update: { value: normalizedValue, is_encrypted: input.encrypted }, + create: { key: input.key, type: input.type, value: normalizedValue, is_encrypted: input.encrypted } + }); + return decodeSettingValue(setting.value, input.value); +} + router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res, next) => { try { - const setting = await prisma.setting.findUnique({ where: { key: "proxmox" } }); - res.json(setting?.value ?? {}); + const value = await loadSetting("proxmox", {}); + res.json(value); } catch (error) { next(error); } @@ -91,12 +121,13 @@ router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, res, next) => { try { const payload = proxmoxSchema.parse(req.body); - const setting = await prisma.setting.upsert({ - where: { key: "proxmox" }, - update: { value: payload }, - create: { key: "proxmox", type: "PROXMOX", value: payload, is_encrypted: true } + const value = await saveSetting({ + key: "proxmox", + type: "PROXMOX", + value: payload, + encrypted: true }); - res.json(setting.value); + res.json(value); } catch (error) { next(error); } @@ -104,8 +135,8 @@ router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, re router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res, next) => { try { - const setting = await prisma.setting.findUnique({ where: { key: "payment" } }); - res.json(setting?.value ?? {}); + const value = await loadSetting("payment", {}); + res.json(value); } catch (error) { next(error); } @@ -114,12 +145,13 @@ router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res router.put("/payment", requireAuth, authorize("settings:manage"), async (req, res, next) => { try { const payload = paymentSchema.parse(req.body); - const setting = await prisma.setting.upsert({ - where: { key: "payment" }, - update: { value: payload }, - create: { key: "payment", type: "PAYMENT", value: payload, is_encrypted: true } + const value = await saveSetting({ + key: "payment", + type: "PAYMENT", + value: payload, + encrypted: true }); - res.json(setting.value); + res.json(value); } catch (error) { next(error); } @@ -127,8 +159,8 @@ router.put("/payment", requireAuth, authorize("settings:manage"), async (req, re router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res, next) => { try { - const setting = await prisma.setting.findUnique({ where: { key: "backup" } }); - res.json(setting?.value ?? {}); + const value = await loadSetting("backup", {}); + res.json(value); } catch (error) { next(error); } @@ -137,12 +169,13 @@ router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res, router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res, next) => { try { const payload = backupSchema.parse(req.body); - const setting = await prisma.setting.upsert({ - where: { key: "backup" }, - update: { value: payload }, - create: { key: "backup", type: "GENERAL", value: payload, is_encrypted: false } + const value = await saveSetting({ + key: "backup", + type: "GENERAL", + value: payload, + encrypted: false }); - res.json(setting.value); + res.json(value); } catch (error) { next(error); } @@ -150,13 +183,12 @@ router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_req, res, next) => { try { - const setting = await prisma.setting.findUnique({ where: { key: "console_proxy" } }); res.json( - setting?.value ?? { + (await loadSetting("console_proxy", { mode: "cluster", cluster: {}, nodes: {} - } + })) ); } catch (error) { next(error); @@ -166,12 +198,13 @@ router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_re router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (req, res, next) => { try { const payload = consoleProxySchema.parse(req.body); - const setting = await prisma.setting.upsert({ - where: { key: "console_proxy" }, - update: { value: payload }, - create: { key: "console_proxy", type: "PROXMOX", value: payload, is_encrypted: false } + const value = await saveSetting({ + key: "console_proxy", + type: "PROXMOX", + value: payload, + encrypted: false }); - res.json(setting.value); + res.json(value); } catch (error) { next(error); } @@ -179,12 +212,8 @@ router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (r router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, res, next) => { try { - const setting = await prisma.setting.findUnique({ where: { key: "scheduler" } }); const defaults = schedulerDefaults(); - const persisted = - setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value) - ? (setting.value as Record) - : {}; + const persisted = await loadSetting>("scheduler", {}); const config = { ...defaults, ...persisted @@ -201,15 +230,16 @@ router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, r router.put("/scheduler", requireAuth, authorize("settings:manage"), async (req, res, next) => { try { const payload = schedulerSchema.parse(req.body); - const setting = await prisma.setting.upsert({ - where: { key: "scheduler" }, - update: { value: payload }, - create: { key: "scheduler", type: "GENERAL", value: payload, is_encrypted: false } + const config = await saveSetting({ + key: "scheduler", + type: "GENERAL", + value: payload, + encrypted: false }); const runtime = await reconfigureSchedulers(payload); return res.json({ - config: setting.value, + config, runtime }); } catch (error) { @@ -229,10 +259,11 @@ router.get("/operations-policy", requireAuth, authorize("settings:read"), async router.put("/operations-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => { try { const payload = operationsPolicySchema.parse(req.body); - await prisma.setting.upsert({ - where: { key: "operations_policy" }, - update: { value: payload }, - create: { key: "operations_policy", type: "GENERAL", value: payload, is_encrypted: false } + await saveSetting({ + key: "operations_policy", + type: "GENERAL", + value: payload, + encrypted: true }); const policy = await getOperationsPolicy(); @@ -244,9 +275,8 @@ router.put("/operations-policy", requireAuth, authorize("settings:manage"), asyn router.get("/notifications", requireAuth, authorize("settings:read"), async (_req, res, next) => { try { - const setting = await prisma.setting.findUnique({ where: { key: "notifications" } }); return res.json( - setting?.value ?? { + await loadSetting("notifications", { email_alerts: true, backup_alerts: true, billing_alerts: true, @@ -256,7 +286,7 @@ router.get("/notifications", requireAuth, authorize("settings:read"), async (_re email_gateway_url: "", notification_email_webhook: "", ops_email: "" - } + }) ); } catch (error) { return next(error); @@ -266,12 +296,13 @@ router.get("/notifications", requireAuth, authorize("settings:read"), async (_re router.put("/notifications", requireAuth, authorize("settings:manage"), async (req, res, next) => { try { const payload = notificationsSchema.parse(req.body); - const setting = await prisma.setting.upsert({ - where: { key: "notifications" }, - update: { value: payload }, - create: { key: "notifications", type: "EMAIL", value: payload, is_encrypted: false } + const value = await saveSetting({ + key: "notifications", + type: "EMAIL", + value: payload, + encrypted: true }); - return res.json(setting.value); + return res.json(value); } catch (error) { return next(error); } diff --git a/backend/src/routes/system.routes.ts b/backend/src/routes/system.routes.ts new file mode 100644 index 0000000..4b1f15e --- /dev/null +++ b/backend/src/routes/system.routes.ts @@ -0,0 +1,421 @@ +import { Router } from "express"; +import { z } from "zod"; +import { TenantStatus } from "@prisma/client"; +import { authorize, 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"; + +const router = Router(); + +const brandingSchema = z.object({ + app_name: z.string().min(2).max(120), + logo_url: z.string().url().optional(), + primary_color: z.string().optional(), + accent_color: z.string().optional(), + support_email: z.string().email().optional(), + website_url: z.string().url().optional(), + legal_company_name: z.string().optional(), + copyright_notice: z.string().optional() +}); + +const subscriptionPolicySchema = z.object({ + default_trial_days: z.number().int().min(1).max(90).default(14), + default_grace_days: z.number().int().min(0).max(30).default(3), + trial_vm_limit: z.number().int().min(1).max(200).default(2), + auto_suspend_on_expiry: z.boolean().default(true) +}); + +const startTrialSchema = z.object({ + days: z.number().int().min(1).max(90).optional(), + grace_days: z.number().int().min(0).max(30).optional(), + vm_limit: z.number().int().min(1).max(200).optional() +}); + +const cmsPageSchema = z.object({ + slug: z.string().min(2).max(180).regex(/^[a-z0-9-]+$/), + title: z.string().min(2).max(180), + section: z.string().min(2).max(80).default("general"), + content: z.record(z.string(), z.any()).default({}), + is_published: z.boolean().default(false) +}); + +const navItemSchema = z.object({ + label: z.string().min(1).max(120), + href: z.string().min(1).max(260), + position: z.enum(["header", "footer", "legal"]).default("header"), + sort_order: z.number().int().min(0).max(10000).default(100), + is_enabled: z.boolean().default(true), + metadata: z.record(z.string(), z.any()).default({}) +}); + +async function getSetting(key: string, fallback: T): Promise { + const setting = await prisma.setting.findUnique({ where: { key } }); + if (!setting) return fallback; + return (setting.value as T) ?? fallback; +} + +async function upsertSetting(input: { key: string; type?: "GENERAL" | "SECURITY" | "NETWORK" | "PROXMOX" | "PAYMENT" | "EMAIL"; value: T }) { + const normalizedValue = toPrismaJsonValue(input.value); + return prisma.setting.upsert({ + where: { key: input.key }, + update: { + value: normalizedValue + }, + create: { + key: input.key, + type: input.type ?? "GENERAL", + value: normalizedValue, + is_encrypted: false + } + }); +} + +router.get("/public/site", async (_req, res, next) => { + try { + const [branding, pages, navigation] = await Promise.all([ + getSetting("branding", { + app_name: "VotCloud", + legal_company_name: "VotCloud", + copyright_notice: "" + }), + prisma.cmsPage.findMany({ + where: { is_published: true }, + orderBy: [{ section: "asc" }, { updated_at: "desc" }] + }), + prisma.siteNavigationItem.findMany({ + where: { is_enabled: true }, + orderBy: [{ position: "asc" }, { sort_order: "asc" }] + }) + ]); + + res.json({ + branding, + pages, + navigation + }); + } catch (error) { + next(error); + } +}); + +router.get("/branding", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const branding = await getSetting("branding", { + app_name: "VotCloud", + legal_company_name: "VotCloud", + copyright_notice: "" + }); + res.json(branding); + } catch (error) { + next(error); + } +}); + +router.put("/branding", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = brandingSchema.parse(req.body ?? {}); + const setting = await upsertSetting({ + key: "branding", + value: payload + }); + + await logAudit({ + action: "system.branding.update", + resource_type: "SYSTEM", + resource_id: setting.id, + resource_name: "branding", + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + res.json(setting.value); + } catch (error) { + next(error); + } +}); + +router.get("/subscription-policy", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const policy = await getSetting("subscription_policy", { + default_trial_days: 14, + default_grace_days: 3, + trial_vm_limit: 2, + auto_suspend_on_expiry: true + }); + res.json(policy); + } catch (error) { + next(error); + } +}); + +router.put("/subscription-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = subscriptionPolicySchema.parse(req.body ?? {}); + const setting = await upsertSetting({ + key: "subscription_policy", + value: payload + }); + + await logAudit({ + action: "system.subscription_policy.update", + resource_type: "SYSTEM", + resource_id: setting.id, + resource_name: "subscription_policy", + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue(payload), + ip_address: req.ip + }); + + res.json(setting.value); + } catch (error) { + next(error); + } +}); + +router.post("/trials/:tenantId/start", requireAuth, authorize("tenant:manage"), async (req, res, next) => { + try { + const payload = startTrialSchema.parse(req.body ?? {}); + const policy = await getSetting("subscription_policy", { + default_trial_days: 14, + default_grace_days: 3, + trial_vm_limit: 2 + }); + const now = new Date(); + const days = payload.days ?? Number(policy.default_trial_days ?? 14); + const graceDays = payload.grace_days ?? Number(policy.default_grace_days ?? 3); + const trialEndsAt = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); + const trialGraceEndsAt = new Date(trialEndsAt.getTime() + graceDays * 24 * 60 * 60 * 1000); + + const tenant = await prisma.tenant.update({ + where: { id: req.params.tenantId }, + data: { + status: TenantStatus.TRIAL, + trial_days: days, + trial_starts_at: now, + trial_ends_at: trialEndsAt, + trial_grace_ends_at: trialGraceEndsAt, + trial_locked: false, + vm_limit: payload.vm_limit ?? Number(policy.trial_vm_limit ?? 2) + } + }); + + await logAudit({ + action: "tenant.trial.start", + resource_type: "TENANT", + resource_id: tenant.id, + resource_name: tenant.name, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + days, + trial_ends_at: trialEndsAt.toISOString(), + trial_grace_ends_at: trialGraceEndsAt.toISOString() + }), + ip_address: req.ip + }); + + res.json(tenant); + } catch (error) { + next(error); + } +}); + +router.post("/trials/expire", requireAuth, authorize("tenant:manage"), async (req, res, next) => { + try { + const now = new Date(); + const policy = await getSetting("subscription_policy", { + auto_suspend_on_expiry: true + }); + if (!policy.auto_suspend_on_expiry) { + return res.json({ success: true, expired_count: 0, skipped: true }); + } + + const expiredTenants = await prisma.tenant.findMany({ + where: { + status: TenantStatus.TRIAL, + trial_ends_at: { lt: now } + }, + select: { + id: true, + name: true + } + }); + + if (expiredTenants.length === 0) { + return res.json({ success: true, expired_count: 0 }); + } + + const ids = expiredTenants.map((tenant) => tenant.id); + await prisma.tenant.updateMany({ + where: { id: { in: ids } }, + data: { + status: TenantStatus.SUSPENDED, + trial_locked: true + } + }); + + await logAudit({ + action: "tenant.trial.expire.batch", + resource_type: "TENANT", + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ + expired_tenants: expiredTenants + }), + ip_address: req.ip + }); + + return res.json({ + success: true, + expired_count: expiredTenants.length, + tenants: expiredTenants + }); + } catch (error) { + return next(error); + } +}); + +router.get("/cms/pages", requireAuth, authorize("settings:read"), async (req, res, next) => { + try { + const includeDrafts = req.query.include_drafts === "true"; + const pages = await prisma.cmsPage.findMany({ + where: includeDrafts ? undefined : { is_published: true }, + orderBy: [{ section: "asc" }, { updated_at: "desc" }] + }); + res.json(pages); + } catch (error) { + next(error); + } +}); + +router.post("/cms/pages", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = cmsPageSchema.parse(req.body ?? {}); + const page = await prisma.cmsPage.create({ + data: { + ...payload, + created_by: req.user!.email, + updated_by: req.user!.email + } + }); + + await logAudit({ + action: "cms.page.create", + resource_type: "SYSTEM", + resource_id: page.id, + resource_name: page.slug, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ section: page.section, is_published: page.is_published }), + ip_address: req.ip + }); + + res.status(201).json(page); + } catch (error) { + next(error); + } +}); + +router.patch("/cms/pages/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const partial = cmsPageSchema.partial().parse(req.body ?? {}); + if (Object.keys(partial).length === 0) { + throw new HttpError(400, "No fields provided", "VALIDATION_ERROR"); + } + + const page = await prisma.cmsPage.update({ + where: { id: req.params.id }, + data: { + ...partial, + updated_by: req.user!.email + } + }); + + await logAudit({ + action: "cms.page.update", + resource_type: "SYSTEM", + resource_id: page.id, + resource_name: page.slug, + actor_email: req.user!.email, + actor_role: req.user!.role, + details: toPrismaJsonValue({ updated_fields: Object.keys(partial) }), + ip_address: req.ip + }); + + res.json(page); + } catch (error) { + next(error); + } +}); + +router.delete("/cms/pages/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const page = await prisma.cmsPage.delete({ + where: { id: req.params.id } + }); + + await logAudit({ + action: "cms.page.delete", + resource_type: "SYSTEM", + resource_id: page.id, + resource_name: page.slug, + actor_email: req.user!.email, + actor_role: req.user!.role, + ip_address: req.ip + }); + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +router.get("/cms/navigation", requireAuth, authorize("settings:read"), async (_req, res, next) => { + try { + const items = await prisma.siteNavigationItem.findMany({ + orderBy: [{ position: "asc" }, { sort_order: "asc" }] + }); + res.json(items); + } catch (error) { + next(error); + } +}); + +router.post("/cms/navigation", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = navItemSchema.parse(req.body ?? {}); + const item = await prisma.siteNavigationItem.create({ data: payload }); + res.status(201).json(item); + } catch (error) { + next(error); + } +}); + +router.patch("/cms/navigation/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + const payload = navItemSchema.partial().parse(req.body ?? {}); + const item = await prisma.siteNavigationItem.update({ + where: { id: req.params.id }, + data: payload + }); + res.json(item); + } catch (error) { + next(error); + } +}); + +router.delete("/cms/navigation/:id", requireAuth, authorize("settings:manage"), async (req, res, next) => { + try { + await prisma.siteNavigationItem.delete({ where: { id: req.params.id } }); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/services/operations.service.ts b/backend/src/services/operations.service.ts index 9402438..486ca2e 100644 --- a/backend/src/services/operations.service.ts +++ b/backend/src/services/operations.service.ts @@ -8,6 +8,7 @@ import axios from "axios"; import { prisma } from "../lib/prisma"; import { HttpError } from "../lib/http-error"; +import { decryptJson } from "../lib/security"; import { restartVm, shutdownVm, startVm, stopVm } from "./proxmox.service"; type TaskCreateInput = { @@ -255,13 +256,16 @@ export async function getOperationsPolicy(): Promise { }) ]); + const settingValue = decryptJson(setting?.value) as unknown; + const notificationsRaw = decryptJson(notificationsSetting?.value) as unknown; + const value = - setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value) - ? (setting.value as Record) + settingValue && typeof settingValue === "object" && !Array.isArray(settingValue) + ? (settingValue as Record) : {}; const notificationsValue = - notificationsSetting?.value && typeof notificationsSetting.value === "object" && !Array.isArray(notificationsSetting.value) - ? (notificationsSetting.value as Record) + notificationsRaw && typeof notificationsRaw === "object" && !Array.isArray(notificationsRaw) + ? (notificationsRaw as Record) : {}; const maxRetryAttemptsRaw = Number(value.max_retry_attempts); diff --git a/backend/src/services/payment.service.ts b/backend/src/services/payment.service.ts index 4725fd1..ce1025c 100644 --- a/backend/src/services/payment.service.ts +++ b/backend/src/services/payment.service.ts @@ -3,6 +3,7 @@ import crypto from "crypto"; import { PaymentProvider } from "@prisma/client"; import { prisma } from "../lib/prisma"; import { HttpError } from "../lib/http-error"; +import { decryptJson } from "../lib/security"; import { markInvoicePaid } from "./billing.service"; type PaymentSettings = { @@ -19,7 +20,7 @@ async function getPaymentSettings(): Promise { const setting = await prisma.setting.findUnique({ where: { key: "payment" } }); - return (setting?.value as PaymentSettings) ?? {}; + return decryptJson(setting?.value) ?? {}; } function normalizeProvider(provider: string | undefined, fallback: string): PaymentProvider { diff --git a/backend/src/services/proxmox.service.ts b/backend/src/services/proxmox.service.ts index 008002b..4d59b48 100644 --- a/backend/src/services/proxmox.service.ts +++ b/backend/src/services/proxmox.service.ts @@ -4,6 +4,7 @@ import { TemplateType, VmType } from "@prisma/client"; import { prisma } from "../lib/prisma"; import { env } from "../config/env"; import { HttpError } from "../lib/http-error"; +import { decryptJson } from "../lib/security"; type ProxmoxSettings = { host: string; @@ -52,7 +53,7 @@ async function getProxmoxSettings(): Promise { throw new HttpError(400, "Proxmox settings have not been configured", "PROXMOX_NOT_CONFIGURED"); } - const value = setting.value as Partial; + const value = decryptJson(setting.value) as Partial; if (!value.host || !value.username || !value.token_id || !value.token_secret) { throw new HttpError(400, "Proxmox credentials are incomplete", "PROXMOX_INCOMPLETE_CONFIG"); } diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts index 533a40f..21adf5a 100644 --- a/backend/src/types/express.d.ts +++ b/backend/src/types/express.d.ts @@ -7,6 +7,7 @@ declare global { email: string; role: Role; tenant_id?: string | null; + sid?: string; } interface Request { diff --git a/src/App.jsx b/src/App.jsx index 13dc41c..8393986 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -22,6 +22,8 @@ import Provisioning from './pages/Provisioning'; import NetworkIpam from './pages/NetworkIpam'; import ClientArea from './pages/ClientArea'; import Security from './pages/Security'; +import Profile from './pages/Profile'; +import SystemManagement from './pages/SystemManagement'; const AuthenticatedApp = () => { const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated } = useAuth(); @@ -58,6 +60,8 @@ const AuthenticatedApp = () => { } /> } /> } /> + } /> + } /> } /> : } /> diff --git a/src/api/appClient.js b/src/api/appClient.js index d663bc2..5f584a1 100644 --- a/src/api/appClient.js +++ b/src/api/appClient.js @@ -4,11 +4,13 @@ const STORAGE_REFRESH_TOKEN_KEY = "proxpanel_refresh_token"; const resourceMap = { AuditLog: "audit-logs", AppTemplate: "app-templates", + AuthSession: "auth-sessions", ApplicationGroup: "application-groups", Backup: "backups", BackupPolicy: "backup-policies", BackupRestoreTask: "backup-restore-tasks", BillingPlan: "billing-plans", + CmsPage: "cms-pages", FirewallRule: "firewall-rules", Invoice: "invoices", IpAddressPool: "ip-addresses", @@ -27,6 +29,7 @@ const resourceMap = { ProxmoxNode: "nodes", ProvisionedService: "provisioned-services", SecurityEvent: "security-events", + SiteNavigationItem: "site-navigation-items", Tenant: "tenants", UsageRecord: "usage-records", User: "users", @@ -243,8 +246,13 @@ async function request(path, options = {}, hasRetried = false) { if (!response.ok) { const errorBody = await response.json().catch(() => ({})); - const error = new Error(errorBody?.message ?? `Request failed: ${response.status}`); + const error = new Error( + errorBody?.error?.message ?? + errorBody?.message ?? + `Request failed: ${response.status}` + ); error.status = response.status; + error.code = errorBody?.error?.code; error.data = errorBody; throw error; } @@ -309,10 +317,15 @@ const entities = new Proxy( ); const auth = { - async login(email, password) { + async login(email, password, options = {}) { const payload = await request("/api/auth/login", { method: "POST", - body: JSON.stringify({ email, password }) + body: JSON.stringify({ + email, + password, + mfa_code: options.mfa_code, + recovery_code: options.recovery_code + }) }); if (payload?.token) { @@ -329,9 +342,21 @@ const auth = { return request("/api/auth/me"); }, - logout(redirectTo) { - setToken(null); - setRefreshToken(null); + async logout(redirectTo) { + const refreshToken = getRefreshToken(); + try { + if (getToken()) { + await request("/api/auth/logout", { + method: "POST", + body: JSON.stringify(refreshToken ? { refresh_token: refreshToken } : {}) + }); + } + } catch { + // Intentionally ignore and continue with local token cleanup. + } finally { + setToken(null); + setRefreshToken(null); + } if (redirectTo && typeof window !== "undefined") { window.location.href = redirectTo; @@ -351,6 +376,197 @@ const auth = { getRefreshToken }; +const adminUsers = { + async listRoles() { + return request("/api/admin/roles"); + }, + + async listUsers(params = {}) { + const query = new URLSearchParams(); + if (params.tenant_id) query.set("tenant_id", params.tenant_id); + if (params.role) query.set("role", params.role); + const qs = query.toString(); + return request(`/api/admin/users${qs ? `?${qs}` : ""}`); + }, + + async createUser(payload) { + return request("/api/admin/users", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateUser(id, payload) { + return request(`/api/admin/users/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async resetPassword(id) { + return request(`/api/admin/users/${id}/reset-password`, { + method: "POST" + }); + } +}; + +const profile = { + async get() { + return request("/api/profile"); + }, + + async update(payload) { + return request("/api/profile", { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async changePassword(payload) { + return request("/api/profile/change-password", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async mfaSetup(password) { + return request("/api/profile/mfa/setup", { + method: "POST", + body: JSON.stringify({ password }) + }); + }, + + async mfaEnable(code) { + return request("/api/profile/mfa/enable", { + method: "POST", + body: JSON.stringify({ code }) + }); + }, + + async mfaDisable(payload) { + return request("/api/profile/mfa/disable", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async listSessions() { + return request("/api/profile/sessions"); + }, + + async revokeSession(id) { + return request(`/api/profile/sessions/${id}/revoke`, { + method: "POST" + }); + }, + + async revokeAllSessions() { + return request("/api/profile/sessions/revoke-all", { + method: "POST" + }); + }, + + async requestPasswordResetToken() { + return request("/api/profile/password-reset/request", { + method: "POST" + }); + } +}; + +const system = { + async publicSite() { + return request("/api/system/public/site"); + }, + + async getBranding() { + return request("/api/system/branding"); + }, + + async saveBranding(payload) { + return request("/api/system/branding", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async getSubscriptionPolicy() { + return request("/api/system/subscription-policy"); + }, + + async saveSubscriptionPolicy(payload) { + return request("/api/system/subscription-policy", { + method: "PUT", + body: JSON.stringify(payload ?? {}) + }); + }, + + async startTrial(tenantId, payload) { + return request(`/api/system/trials/${tenantId}/start`, { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async expireTrials() { + return request("/api/system/trials/expire", { + method: "POST" + }); + }, + + async listCmsPages(params = {}) { + const query = new URLSearchParams(); + if (typeof params.include_drafts === "boolean") { + query.set("include_drafts", String(params.include_drafts)); + } + const qs = query.toString(); + return request(`/api/system/cms/pages${qs ? `?${qs}` : ""}`); + }, + + async createCmsPage(payload) { + return request("/api/system/cms/pages", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateCmsPage(id, payload) { + return request(`/api/system/cms/pages/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async deleteCmsPage(id) { + return request(`/api/system/cms/pages/${id}`, { + method: "DELETE" + }); + }, + + async listNavigationItems() { + return request("/api/system/cms/navigation"); + }, + + async createNavigationItem(payload) { + return request("/api/system/cms/navigation", { + method: "POST", + body: JSON.stringify(payload ?? {}) + }); + }, + + async updateNavigationItem(id, payload) { + return request(`/api/system/cms/navigation/${id}`, { + method: "PATCH", + body: JSON.stringify(payload ?? {}) + }); + }, + + async deleteNavigationItem(id) { + return request(`/api/system/cms/navigation/${id}`, { + method: "DELETE" + }); + } +}; + const operations = { async listTasks(params = {}) { const query = new URLSearchParams(); @@ -1116,6 +1332,9 @@ const clientArea = { export const appClient = { auth, + adminUsers, + profile, + system, entities, dashboard, monitoring, diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 5c33b92..1dd198a 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -17,11 +17,14 @@ import { Server, Settings, Shield, - Users + UserCircle2, + Users, + Wrench } from "lucide-react"; import { cn } from "@/lib/utils"; import { appClient } from "@/api/appClient"; import { navigationGroups } from "./nav-config"; +import { useAuth } from "@/lib/AuthContext"; const iconMap = { dashboard: LayoutDashboard, @@ -38,13 +41,17 @@ const iconMap = { client: Users, billing: CreditCard, rbac: Shield, + profile: UserCircle2, + system: Wrench, settings: Settings }; export default function Sidebar() { const location = useLocation(); + const { appPublicSettings } = useAuth(); const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); + const brandName = appPublicSettings?.branding?.app_name || "ProxPanel Cloud"; const isActive = (path) => { if (path === "/") return location.pathname === "/"; @@ -65,7 +72,7 @@ export default function Sidebar() { {!collapsed && (
-

ProxPanel Cloud

+

{brandName}

Enterprise Control Console

)} @@ -120,7 +127,9 @@ export default function Sidebar() { + + ) : null} diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx new file mode 100644 index 0000000..0c2e4b8 --- /dev/null +++ b/src/pages/Profile.jsx @@ -0,0 +1,613 @@ +import { useEffect, useMemo, useState } from "react"; +import { 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"; + +function formatDate(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleString(); +} + +function prettyJson(value) { + try { + return JSON.stringify(value ?? {}, null, 2); + } catch { + return "{}"; + } +} + +export default function Profile() { + const { toast } = useToast(); + + const [loading, setLoading] = useState(true); + const [savingProfile, setSavingProfile] = useState(false); + const [changingPassword, setChangingPassword] = useState(false); + const [loadingMfaSetup, setLoadingMfaSetup] = useState(false); + const [enablingMfa, setEnablingMfa] = useState(false); + const [disablingMfa, setDisablingMfa] = useState(false); + const [requestingResetToken, setRequestingResetToken] = useState(false); + const [sessionsLoading, setSessionsLoading] = useState(false); + + const [profile, setProfile] = useState(null); + const [sessions, setSessions] = useState([]); + const [lastResetToken, setLastResetToken] = useState(null); + + const [profileForm, setProfileForm] = useState({ + full_name: "", + avatar_url: "", + profile_metadata_text: "{}" + }); + + const [passwordForm, setPasswordForm] = useState({ + current_password: "", + new_password: "", + confirm_password: "" + }); + + const [mfaSetupPassword, setMfaSetupPassword] = useState(""); + const [mfaSetupPayload, setMfaSetupPayload] = useState(null); + const [mfaEnableCode, setMfaEnableCode] = useState(""); + const [mfaDisableForm, setMfaDisableForm] = useState({ + password: "", + code: "" + }); + + async function loadProfileAndSessions() { + try { + setLoading(true); + const [profilePayload, sessionsPayload] = await Promise.all([ + appClient.profile.get(), + appClient.profile.listSessions() + ]); + setProfile(profilePayload); + setSessions(sessionsPayload || []); + setProfileForm({ + full_name: profilePayload?.full_name || "", + avatar_url: profilePayload?.avatar_url || "", + profile_metadata_text: prettyJson(profilePayload?.profile_metadata) + }); + } catch (error) { + toast({ + title: "Profile load failed", + description: error?.message || "Unable to load profile information.", + variant: "destructive" + }); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadProfileAndSessions(); + }, []); + + const activeSessionCount = useMemo( + () => sessions.filter((session) => !session.revoked_at).length, + [sessions] + ); + + async function refreshSessions() { + try { + setSessionsLoading(true); + const payload = await appClient.profile.listSessions(); + setSessions(payload || []); + } catch (error) { + toast({ + title: "Session refresh failed", + description: error?.message || "Unable to refresh active sessions.", + variant: "destructive" + }); + } finally { + setSessionsLoading(false); + } + } + + 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 + }); + setProfile(payload); + setProfileForm((prev) => ({ + ...prev, + profile_metadata_text: prettyJson(payload?.profile_metadata) + })); + toast({ + title: "Profile updated", + description: "Your profile details were saved successfully." + }); + } catch (error) { + toast({ + title: "Profile update failed", + description: error?.message || "Could not save profile data.", + variant: "destructive" + }); + } finally { + setSavingProfile(false); + } + } + + async function handleChangePassword() { + if (passwordForm.new_password !== passwordForm.confirm_password) { + toast({ + title: "Password mismatch", + description: "New password and confirmation do not match.", + variant: "destructive" + }); + return; + } + + try { + setChangingPassword(true); + await appClient.profile.changePassword({ + current_password: passwordForm.current_password, + new_password: passwordForm.new_password + }); + setPasswordForm({ + current_password: "", + new_password: "", + confirm_password: "" + }); + toast({ + title: "Password changed", + description: "All previous sessions were revoked by policy." + }); + await refreshSessions(); + } catch (error) { + toast({ + title: "Password update failed", + description: error?.message || "Could not change password.", + variant: "destructive" + }); + } finally { + setChangingPassword(false); + } + } + + async function handleStartMfaSetup() { + try { + setLoadingMfaSetup(true); + const payload = await appClient.profile.mfaSetup(mfaSetupPassword); + setMfaSetupPayload(payload); + setMfaEnableCode(""); + toast({ + title: "MFA secret generated", + description: "Use your authenticator app, then confirm with a code." + }); + } catch (error) { + toast({ + title: "MFA setup failed", + description: error?.message || "Could not generate MFA configuration.", + variant: "destructive" + }); + } finally { + setLoadingMfaSetup(false); + } + } + + async function handleEnableMfa() { + try { + setEnablingMfa(true); + await appClient.profile.mfaEnable(mfaEnableCode); + setMfaSetupPassword(""); + setMfaSetupPayload(null); + await loadProfileAndSessions(); + toast({ + title: "MFA enabled", + description: "Authenticator-based MFA is now active." + }); + } catch (error) { + toast({ + title: "MFA enable failed", + description: error?.message || "The verification code is invalid.", + variant: "destructive" + }); + } finally { + setEnablingMfa(false); + } + } + + async function handleDisableMfa() { + try { + setDisablingMfa(true); + await appClient.profile.mfaDisable({ + password: mfaDisableForm.password, + code: mfaDisableForm.code || undefined + }); + setMfaDisableForm({ password: "", code: "" }); + await loadProfileAndSessions(); + toast({ + title: "MFA disabled", + description: "Multi-factor authentication has been turned off." + }); + } catch (error) { + toast({ + title: "MFA disable failed", + description: error?.message || "Could not disable MFA for this account.", + variant: "destructive" + }); + } finally { + setDisablingMfa(false); + } + } + + async function handleRequestResetToken() { + try { + setRequestingResetToken(true); + const payload = await appClient.profile.requestPasswordResetToken(); + setLastResetToken(payload); + toast({ + title: "Reset token issued", + description: "A short-lived reset token has been generated." + }); + } catch (error) { + toast({ + title: "Token request failed", + description: error?.message || "Could not generate reset token.", + variant: "destructive" + }); + } finally { + setRequestingResetToken(false); + } + } + + async function revokeSession(sessionId) { + try { + await appClient.profile.revokeSession(sessionId); + await refreshSessions(); + toast({ + title: "Session revoked", + description: "The selected session has been revoked." + }); + } catch (error) { + toast({ + title: "Revoke failed", + description: error?.message || "Could not revoke the session.", + variant: "destructive" + }); + } + } + + async function revokeAllSessions() { + try { + await appClient.profile.revokeAllSessions(); + await refreshSessions(); + toast({ + title: "Sessions revoked", + description: "All active sessions were revoked." + }); + } catch (error) { + toast({ + title: "Revoke all failed", + description: error?.message || "Could not revoke all sessions.", + variant: "destructive" + }); + } + } + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ + +
+
+
+
+ +

Profile

+
+
+
+ + +
+
+ + +
+
+ + + setProfileForm((prev) => ({ ...prev, full_name: event.target.value })) + } + className="mt-1" + /> +
+
+ + + setProfileForm((prev) => ({ ...prev, avatar_url: event.target.value })) + } + className="mt-1" + placeholder="https://example.com/avatar.png" + /> +
+
+ +