feat: implement enterprise RBAC, profile identity, system management, and audit stabilization

This commit is contained in:
Austin A
2026-04-17 23:36:07 +01:00
parent 5def26e0df
commit 6279347e4b
28 changed files with 4521 additions and 326 deletions

View File

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

View File

@@ -302,14 +302,23 @@ model User {
email String @unique email String @unique
password_hash String password_hash String
full_name String? full_name String?
avatar_url String?
profile_metadata Json @default("{}")
role Role @default(VIEWER) role Role @default(VIEWER)
tenant_id String? tenant_id String?
is_active Boolean @default(true) 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? last_login_at DateTime?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull) tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull)
auth_sessions AuthSession[]
password_reset_tokens PasswordResetToken[]
@@index([tenant_id]) @@index([tenant_id])
} }
@@ -319,6 +328,11 @@ model Tenant {
name String name String
slug String @unique slug String @unique
status TenantStatus @default(ACTIVE) 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") plan String @default("starter")
owner_email String owner_email String
member_emails Json @default("[]") member_emails Json @default("[]")
@@ -351,9 +365,42 @@ model Tenant {
monitoring_alert_events MonitoringAlertEvent[] monitoring_alert_events MonitoringAlertEvent[]
@@index([status]) @@index([status])
@@index([trial_ends_at])
@@index([owner_email]) @@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 { model ProxmoxNode {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -1203,3 +1250,32 @@ model Setting {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt 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])
}

View File

@@ -18,6 +18,9 @@ import backupRoutes from "./routes/backup.routes";
import networkRoutes from "./routes/network.routes"; import networkRoutes from "./routes/network.routes";
import monitoringRoutes from "./routes/monitoring.routes"; import monitoringRoutes from "./routes/monitoring.routes";
import clientRoutes from "./routes/client.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 { errorHandler, notFoundHandler } from "./middleware/error-handler";
import { createRateLimit } from "./middleware/rate-limit"; import { createRateLimit } from "./middleware/rate-limit";
@@ -82,6 +85,9 @@ export function createApp() {
app.use("/api/network", networkRoutes); app.use("/api/network", networkRoutes);
app.use("/api/monitoring", monitoringRoutes); app.use("/api/monitoring", monitoringRoutes);
app.use("/api/client", clientRoutes); 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(notFoundHandler);
app.use(errorHandler); app.use(errorHandler);

View File

@@ -8,6 +8,7 @@ const envSchema = z.object({
PORT: z.coerce.number().default(8080), PORT: z.coerce.number().default(8080),
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
JWT_SECRET: z.string().min(16, "JWT_SECRET must be at least 16 characters"), 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_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_SECRET: z.string().min(16, "JWT_REFRESH_SECRET must be at least 16 characters").optional(),
JWT_REFRESH_EXPIRES_IN: z.string().default("30d"), JWT_REFRESH_EXPIRES_IN: z.string().default("30d"),

105
backend/src/lib/security.ts Normal file
View File

@@ -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<string, unknown>).__enc === "v1" &&
typeof (value as Record<string, unknown>).iv === "string" &&
typeof (value as Record<string, unknown>).tag === "string" &&
typeof (value as Record<string, unknown>).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<T = unknown>(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;
}

100
backend/src/lib/totp.ts Normal file
View File

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

View File

@@ -26,7 +26,10 @@ type Permission =
| "security:manage" | "security:manage"
| "security:read" | "security:read"
| "user:manage" | "user:manage"
| "user:read"; | "user:read"
| "profile:read"
| "profile:manage"
| "session:manage";
const rolePermissions: Record<Role, Set<Permission>> = { const rolePermissions: Record<Role, Set<Permission>> = {
SUPER_ADMIN: new Set<Permission>([ SUPER_ADMIN: new Set<Permission>([
@@ -51,7 +54,10 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"security:manage", "security:manage",
"security:read", "security:read",
"user:manage", "user:manage",
"user:read" "user:read",
"profile:read",
"profile:manage",
"session:manage"
]), ]),
TENANT_ADMIN: new Set<Permission>([ TENANT_ADMIN: new Set<Permission>([
"vm:create", "vm:create",
@@ -68,7 +74,10 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"settings:read", "settings:read",
"audit:read", "audit:read",
"security:read", "security:read",
"user:read" "user:read",
"profile:read",
"profile:manage",
"session:manage"
]), ]),
OPERATOR: new Set<Permission>([ OPERATOR: new Set<Permission>([
"vm:read", "vm:read",
@@ -81,7 +90,9 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"backup:read", "backup:read",
"audit:read", "audit:read",
"security:manage", "security:manage",
"security:read" "security:read",
"profile:read",
"profile:manage"
]), ]),
VIEWER: new Set<Permission>([ VIEWER: new Set<Permission>([
"vm:read", "vm:read",
@@ -92,7 +103,9 @@ const rolePermissions: Record<Role, Set<Permission>> = {
"audit:read", "audit:read",
"security:read", "security:read",
"settings: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, id: decoded.id,
email: decoded.email, email: decoded.email,
role: decoded.role, role: decoded.role,
tenant_id: decoded.tenant_id tenant_id: decoded.tenant_id,
sid: decoded.sid
}; };
} catch { } catch {
return null; return null;

View File

@@ -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<string, unknown> = {};
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;

View File

@@ -1,25 +1,80 @@
import { Router } from "express"; import { Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
import jwt from "jsonwebtoken";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error"; import { HttpError } from "../lib/http-error";
import { createJwtToken, createRefreshToken, requireAuth, verifyRefreshToken } from "../middleware/auth"; import { createJwtToken, createRefreshToken, requireAuth, verifyRefreshToken } from "../middleware/auth";
import { consumeRecoveryCode, hashToken } from "../lib/security";
import { verifyTotpCode } from "../lib/totp";
const router = Router(); const router = Router();
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email(), 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({ const refreshSchema = z.object({
refresh_token: z.string().min(1) 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) => { router.post("/login", async (req, res, next) => {
try { try {
const payload = loginSchema.parse(req.body); 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) { if (!user || !user.is_active) {
throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS"); 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"); 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({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { last_login_at: new Date() } data: { last_login_at: new Date() }
@@ -39,18 +123,24 @@ router.post("/login", async (req, res, next) => {
role: user.role, role: user.role,
tenant_id: user.tenant_id tenant_id: user.tenant_id
}; };
const token = createJwtToken(userPayload); const tokens = await createAuthSession({
const refreshToken = createRefreshToken(userPayload); user: userPayload,
ipAddress: req.ip,
userAgent: req.get("user-agent") ?? undefined
});
res.json({ res.json({
token, token: tokens.token,
refresh_token: refreshToken, refresh_token: tokens.refresh_token,
must_change_password: user.must_change_password,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
full_name: user.full_name, full_name: user.full_name,
role: user.role, 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) { } catch (error) {
@@ -62,10 +152,27 @@ router.post("/refresh", async (req, res, next) => {
try { try {
const payload = refreshSchema.parse(req.body ?? {}); const payload = refreshSchema.parse(req.body ?? {});
const decoded = verifyRefreshToken(payload.refresh_token); const decoded = verifyRefreshToken(payload.refresh_token);
if (!decoded) { if (!decoded || !decoded.sid) {
throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN"); 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({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: decoded.id },
select: { select: {
@@ -84,11 +191,23 @@ router.post("/refresh", async (req, res, next) => {
id: user.id, id: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
tenant_id: user.tenant_id tenant_id: user.tenant_id,
sid: decoded.sid
}; };
const token = createJwtToken(userPayload); const token = createJwtToken(userPayload);
const refreshToken = createRefreshToken(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({ res.json({
token, token,
refresh_token: refreshToken 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) => { router.get("/me", requireAuth, async (req, res, next) => {
try { try {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -109,7 +268,12 @@ router.get("/me", requireAuth, async (req, res, next) => {
role: true, role: true,
tenant_id: true, tenant_id: true,
is_active: 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"); if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND");

View File

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

View File

@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import bcrypt from "bcryptjs";
import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth";
import { HttpError } from "../lib/http-error"; import { HttpError } from "../lib/http-error";
import { toPrismaJsonValue } from "../lib/prisma-json"; import { toPrismaJsonValue } from "../lib/prisma-json";
@@ -274,6 +275,31 @@ const resourceMap: Record<string, ResourceMeta> = {
readPermission: "security:read", readPermission: "security:read",
tenantScoped: true, tenantScoped: true,
searchFields: ["destination", "provider_message"] 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 model = getModel(meta);
const payload = normalizePayload(resource, req.body ?? {}); 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.tenantScoped && isTenantScopedUser(req) && req.user?.tenant_id) {
if ( if (
meta.model !== "backupRestoreTask" && meta.model !== "backupRestoreTask" &&
@@ -666,6 +711,23 @@ router.patch("/:resource/:id", requireAuth, async (req, res, next) => {
await ensureItemTenantScope(req, meta, existing); await ensureItemTenantScope(req, meta, existing);
const payload = normalizePayload(resource, req.body ?? {}); 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({ const updated = await model.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: payload data: payload

View File

@@ -2,6 +2,8 @@ import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { authorize, requireAuth } from "../middleware/auth"; import { authorize, requireAuth } from "../middleware/auth";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { toPrismaJsonValue } from "../lib/prisma-json";
import { decryptJson, encryptJson } from "../lib/security";
import { getOperationsPolicy } from "../services/operations.service"; import { getOperationsPolicy } from "../services/operations.service";
import { getSchedulerRuntimeSnapshot, reconfigureSchedulers, schedulerDefaults } from "../services/scheduler.service"; import { getSchedulerRuntimeSnapshot, reconfigureSchedulers, schedulerDefaults } from "../services/scheduler.service";
@@ -79,10 +81,38 @@ const notificationsSchema = z.object({
ops_email: z.string().email().optional() ops_email: z.string().email().optional()
}); });
function decodeSettingValue<T>(raw: unknown, fallback: T): T {
const value = decryptJson<T>(raw);
if (value === null || value === undefined) return fallback;
return value;
}
async function loadSetting<T>(key: string, fallback: T): Promise<T> {
const setting = await prisma.setting.findUnique({ where: { key } });
if (!setting) return fallback;
return decodeSettingValue<T>(setting.value, fallback);
}
async function saveSetting<T>(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<T>(setting.value, input.value);
}
router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res, next) => { router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try { try {
const setting = await prisma.setting.findUnique({ where: { key: "proxmox" } }); const value = await loadSetting("proxmox", {});
res.json(setting?.value ?? {}); res.json(value);
} catch (error) { } catch (error) {
next(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) => { router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try { try {
const payload = proxmoxSchema.parse(req.body); const payload = proxmoxSchema.parse(req.body);
const setting = await prisma.setting.upsert({ const value = await saveSetting({
where: { key: "proxmox" }, key: "proxmox",
update: { value: payload }, type: "PROXMOX",
create: { key: "proxmox", type: "PROXMOX", value: payload, is_encrypted: true } value: payload,
encrypted: true
}); });
res.json(setting.value); res.json(value);
} catch (error) { } catch (error) {
next(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) => { router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try { try {
const setting = await prisma.setting.findUnique({ where: { key: "payment" } }); const value = await loadSetting("payment", {});
res.json(setting?.value ?? {}); res.json(value);
} catch (error) { } catch (error) {
next(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) => { router.put("/payment", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try { try {
const payload = paymentSchema.parse(req.body); const payload = paymentSchema.parse(req.body);
const setting = await prisma.setting.upsert({ const value = await saveSetting({
where: { key: "payment" }, key: "payment",
update: { value: payload }, type: "PAYMENT",
create: { key: "payment", type: "PAYMENT", value: payload, is_encrypted: true } value: payload,
encrypted: true
}); });
res.json(setting.value); res.json(value);
} catch (error) { } catch (error) {
next(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) => { router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try { try {
const setting = await prisma.setting.findUnique({ where: { key: "backup" } }); const value = await loadSetting("backup", {});
res.json(setting?.value ?? {}); res.json(value);
} catch (error) { } catch (error) {
next(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) => { router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try { try {
const payload = backupSchema.parse(req.body); const payload = backupSchema.parse(req.body);
const setting = await prisma.setting.upsert({ const value = await saveSetting({
where: { key: "backup" }, key: "backup",
update: { value: payload }, type: "GENERAL",
create: { key: "backup", type: "GENERAL", value: payload, is_encrypted: false } value: payload,
encrypted: false
}); });
res.json(setting.value); res.json(value);
} catch (error) { } catch (error) {
next(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) => { router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try { try {
const setting = await prisma.setting.findUnique({ where: { key: "console_proxy" } });
res.json( res.json(
setting?.value ?? { (await loadSetting("console_proxy", {
mode: "cluster", mode: "cluster",
cluster: {}, cluster: {},
nodes: {} nodes: {}
} }))
); );
} catch (error) { } catch (error) {
next(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) => { router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try { try {
const payload = consoleProxySchema.parse(req.body); const payload = consoleProxySchema.parse(req.body);
const setting = await prisma.setting.upsert({ const value = await saveSetting({
where: { key: "console_proxy" }, key: "console_proxy",
update: { value: payload }, type: "PROXMOX",
create: { key: "console_proxy", type: "PROXMOX", value: payload, is_encrypted: false } value: payload,
encrypted: false
}); });
res.json(setting.value); res.json(value);
} catch (error) { } catch (error) {
next(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) => { router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try { try {
const setting = await prisma.setting.findUnique({ where: { key: "scheduler" } });
const defaults = schedulerDefaults(); const defaults = schedulerDefaults();
const persisted = const persisted = await loadSetting<Record<string, unknown>>("scheduler", {});
setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value)
? (setting.value as Record<string, unknown>)
: {};
const config = { const config = {
...defaults, ...defaults,
...persisted ...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) => { router.put("/scheduler", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try { try {
const payload = schedulerSchema.parse(req.body); const payload = schedulerSchema.parse(req.body);
const setting = await prisma.setting.upsert({ const config = await saveSetting({
where: { key: "scheduler" }, key: "scheduler",
update: { value: payload }, type: "GENERAL",
create: { key: "scheduler", type: "GENERAL", value: payload, is_encrypted: false } value: payload,
encrypted: false
}); });
const runtime = await reconfigureSchedulers(payload); const runtime = await reconfigureSchedulers(payload);
return res.json({ return res.json({
config: setting.value, config,
runtime runtime
}); });
} catch (error) { } 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) => { router.put("/operations-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try { try {
const payload = operationsPolicySchema.parse(req.body); const payload = operationsPolicySchema.parse(req.body);
await prisma.setting.upsert({ await saveSetting({
where: { key: "operations_policy" }, key: "operations_policy",
update: { value: payload }, type: "GENERAL",
create: { key: "operations_policy", type: "GENERAL", value: payload, is_encrypted: false } value: payload,
encrypted: true
}); });
const policy = await getOperationsPolicy(); 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) => { router.get("/notifications", requireAuth, authorize("settings:read"), async (_req, res, next) => {
try { try {
const setting = await prisma.setting.findUnique({ where: { key: "notifications" } });
return res.json( return res.json(
setting?.value ?? { await loadSetting("notifications", {
email_alerts: true, email_alerts: true,
backup_alerts: true, backup_alerts: true,
billing_alerts: true, billing_alerts: true,
@@ -256,7 +286,7 @@ router.get("/notifications", requireAuth, authorize("settings:read"), async (_re
email_gateway_url: "", email_gateway_url: "",
notification_email_webhook: "", notification_email_webhook: "",
ops_email: "" ops_email: ""
} })
); );
} catch (error) { } catch (error) {
return next(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) => { router.put("/notifications", requireAuth, authorize("settings:manage"), async (req, res, next) => {
try { try {
const payload = notificationsSchema.parse(req.body); const payload = notificationsSchema.parse(req.body);
const setting = await prisma.setting.upsert({ const value = await saveSetting({
where: { key: "notifications" }, key: "notifications",
update: { value: payload }, type: "EMAIL",
create: { key: "notifications", type: "EMAIL", value: payload, is_encrypted: false } value: payload,
encrypted: true
}); });
return res.json(setting.value); return res.json(value);
} catch (error) { } catch (error) {
return next(error); return next(error);
} }

View File

@@ -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<T = unknown>(key: string, fallback: T): Promise<T> {
const setting = await prisma.setting.findUnique({ where: { key } });
if (!setting) return fallback;
return (setting.value as T) ?? fallback;
}
async function upsertSetting<T = unknown>(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;

View File

@@ -8,6 +8,7 @@
import axios from "axios"; import axios from "axios";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error"; import { HttpError } from "../lib/http-error";
import { decryptJson } from "../lib/security";
import { restartVm, shutdownVm, startVm, stopVm } from "./proxmox.service"; import { restartVm, shutdownVm, startVm, stopVm } from "./proxmox.service";
type TaskCreateInput = { type TaskCreateInput = {
@@ -255,13 +256,16 @@ export async function getOperationsPolicy(): Promise<OperationsPolicy> {
}) })
]); ]);
const settingValue = decryptJson(setting?.value) as unknown;
const notificationsRaw = decryptJson(notificationsSetting?.value) as unknown;
const value = const value =
setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value) settingValue && typeof settingValue === "object" && !Array.isArray(settingValue)
? (setting.value as Record<string, unknown>) ? (settingValue as Record<string, unknown>)
: {}; : {};
const notificationsValue = const notificationsValue =
notificationsSetting?.value && typeof notificationsSetting.value === "object" && !Array.isArray(notificationsSetting.value) notificationsRaw && typeof notificationsRaw === "object" && !Array.isArray(notificationsRaw)
? (notificationsSetting.value as Record<string, unknown>) ? (notificationsRaw as Record<string, unknown>)
: {}; : {};
const maxRetryAttemptsRaw = Number(value.max_retry_attempts); const maxRetryAttemptsRaw = Number(value.max_retry_attempts);

View File

@@ -3,6 +3,7 @@ import crypto from "crypto";
import { PaymentProvider } from "@prisma/client"; import { PaymentProvider } from "@prisma/client";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { HttpError } from "../lib/http-error"; import { HttpError } from "../lib/http-error";
import { decryptJson } from "../lib/security";
import { markInvoicePaid } from "./billing.service"; import { markInvoicePaid } from "./billing.service";
type PaymentSettings = { type PaymentSettings = {
@@ -19,7 +20,7 @@ async function getPaymentSettings(): Promise<PaymentSettings> {
const setting = await prisma.setting.findUnique({ const setting = await prisma.setting.findUnique({
where: { key: "payment" } where: { key: "payment" }
}); });
return (setting?.value as PaymentSettings) ?? {}; return decryptJson<PaymentSettings>(setting?.value) ?? {};
} }
function normalizeProvider(provider: string | undefined, fallback: string): PaymentProvider { function normalizeProvider(provider: string | undefined, fallback: string): PaymentProvider {

View File

@@ -4,6 +4,7 @@ import { TemplateType, VmType } from "@prisma/client";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { env } from "../config/env"; import { env } from "../config/env";
import { HttpError } from "../lib/http-error"; import { HttpError } from "../lib/http-error";
import { decryptJson } from "../lib/security";
type ProxmoxSettings = { type ProxmoxSettings = {
host: string; host: string;
@@ -52,7 +53,7 @@ async function getProxmoxSettings(): Promise<ProxmoxSettings> {
throw new HttpError(400, "Proxmox settings have not been configured", "PROXMOX_NOT_CONFIGURED"); throw new HttpError(400, "Proxmox settings have not been configured", "PROXMOX_NOT_CONFIGURED");
} }
const value = setting.value as Partial<ProxmoxSettings>; const value = decryptJson(setting.value) as Partial<ProxmoxSettings>;
if (!value.host || !value.username || !value.token_id || !value.token_secret) { if (!value.host || !value.username || !value.token_id || !value.token_secret) {
throw new HttpError(400, "Proxmox credentials are incomplete", "PROXMOX_INCOMPLETE_CONFIG"); throw new HttpError(400, "Proxmox credentials are incomplete", "PROXMOX_INCOMPLETE_CONFIG");
} }

View File

@@ -7,6 +7,7 @@ declare global {
email: string; email: string;
role: Role; role: Role;
tenant_id?: string | null; tenant_id?: string | null;
sid?: string;
} }
interface Request { interface Request {

View File

@@ -22,6 +22,8 @@ import Provisioning from './pages/Provisioning';
import NetworkIpam from './pages/NetworkIpam'; import NetworkIpam from './pages/NetworkIpam';
import ClientArea from './pages/ClientArea'; import ClientArea from './pages/ClientArea';
import Security from './pages/Security'; import Security from './pages/Security';
import Profile from './pages/Profile';
import SystemManagement from './pages/SystemManagement';
const AuthenticatedApp = () => { const AuthenticatedApp = () => {
const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated } = useAuth(); const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated } = useAuth();
@@ -58,6 +60,8 @@ const AuthenticatedApp = () => {
<Route path="/operations" element={<Operations />} /> <Route path="/operations" element={<Operations />} />
<Route path="/audit-logs" element={<AuditLogs />} /> <Route path="/audit-logs" element={<AuditLogs />} />
<Route path="/rbac" element={<RBAC />} /> <Route path="/rbac" element={<RBAC />} />
<Route path="/profile" element={<Profile />} />
<Route path="/system" element={<SystemManagement />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Route> </Route>
<Route path="*" element={isAuthenticated ? <PageNotFound /> : <Navigate to="/login" replace />} /> <Route path="*" element={isAuthenticated ? <PageNotFound /> : <Navigate to="/login" replace />} />

View File

@@ -4,11 +4,13 @@ const STORAGE_REFRESH_TOKEN_KEY = "proxpanel_refresh_token";
const resourceMap = { const resourceMap = {
AuditLog: "audit-logs", AuditLog: "audit-logs",
AppTemplate: "app-templates", AppTemplate: "app-templates",
AuthSession: "auth-sessions",
ApplicationGroup: "application-groups", ApplicationGroup: "application-groups",
Backup: "backups", Backup: "backups",
BackupPolicy: "backup-policies", BackupPolicy: "backup-policies",
BackupRestoreTask: "backup-restore-tasks", BackupRestoreTask: "backup-restore-tasks",
BillingPlan: "billing-plans", BillingPlan: "billing-plans",
CmsPage: "cms-pages",
FirewallRule: "firewall-rules", FirewallRule: "firewall-rules",
Invoice: "invoices", Invoice: "invoices",
IpAddressPool: "ip-addresses", IpAddressPool: "ip-addresses",
@@ -27,6 +29,7 @@ const resourceMap = {
ProxmoxNode: "nodes", ProxmoxNode: "nodes",
ProvisionedService: "provisioned-services", ProvisionedService: "provisioned-services",
SecurityEvent: "security-events", SecurityEvent: "security-events",
SiteNavigationItem: "site-navigation-items",
Tenant: "tenants", Tenant: "tenants",
UsageRecord: "usage-records", UsageRecord: "usage-records",
User: "users", User: "users",
@@ -243,8 +246,13 @@ async function request(path, options = {}, hasRetried = false) {
if (!response.ok) { if (!response.ok) {
const errorBody = await response.json().catch(() => ({})); 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.status = response.status;
error.code = errorBody?.error?.code;
error.data = errorBody; error.data = errorBody;
throw error; throw error;
} }
@@ -309,10 +317,15 @@ const entities = new Proxy(
); );
const auth = { const auth = {
async login(email, password) { async login(email, password, options = {}) {
const payload = await request("/api/auth/login", { const payload = await request("/api/auth/login", {
method: "POST", 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) { if (payload?.token) {
@@ -329,9 +342,21 @@ const auth = {
return request("/api/auth/me"); return request("/api/auth/me");
}, },
logout(redirectTo) { async logout(redirectTo) {
setToken(null); const refreshToken = getRefreshToken();
setRefreshToken(null); 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") { if (redirectTo && typeof window !== "undefined") {
window.location.href = redirectTo; window.location.href = redirectTo;
@@ -351,6 +376,197 @@ const auth = {
getRefreshToken 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 = { const operations = {
async listTasks(params = {}) { async listTasks(params = {}) {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -1116,6 +1332,9 @@ const clientArea = {
export const appClient = { export const appClient = {
auth, auth,
adminUsers,
profile,
system,
entities, entities,
dashboard, dashboard,
monitoring, monitoring,

View File

@@ -17,11 +17,14 @@ import {
Server, Server,
Settings, Settings,
Shield, Shield,
Users UserCircle2,
Users,
Wrench
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { appClient } from "@/api/appClient"; import { appClient } from "@/api/appClient";
import { navigationGroups } from "./nav-config"; import { navigationGroups } from "./nav-config";
import { useAuth } from "@/lib/AuthContext";
const iconMap = { const iconMap = {
dashboard: LayoutDashboard, dashboard: LayoutDashboard,
@@ -38,13 +41,17 @@ const iconMap = {
client: Users, client: Users,
billing: CreditCard, billing: CreditCard,
rbac: Shield, rbac: Shield,
profile: UserCircle2,
system: Wrench,
settings: Settings settings: Settings
}; };
export default function Sidebar() { export default function Sidebar() {
const location = useLocation(); const location = useLocation();
const { appPublicSettings } = useAuth();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const brandName = appPublicSettings?.branding?.app_name || "ProxPanel Cloud";
const isActive = (path) => { const isActive = (path) => {
if (path === "/") return location.pathname === "/"; if (path === "/") return location.pathname === "/";
@@ -65,7 +72,7 @@ export default function Sidebar() {
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground tracking-tight">ProxPanel Cloud</p> <p className="truncate text-sm font-semibold text-foreground tracking-tight">{brandName}</p>
<p className="truncate text-[11px] text-muted-foreground">Enterprise Control Console</p> <p className="truncate text-[11px] text-muted-foreground">Enterprise Control Console</p>
</div> </div>
)} )}
@@ -120,7 +127,9 @@ export default function Sidebar() {
<button <button
type="button" type="button"
onClick={() => appClient.auth.logout("/login")} onClick={() => {
void appClient.auth.logout("/login");
}}
className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-rose-50 hover:text-rose-700" className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-rose-50 hover:text-rose-700"
> >
<LogOut className="h-[17px] w-[17px] shrink-0" /> <LogOut className="h-[17px] w-[17px] shrink-0" />

View File

@@ -35,6 +35,8 @@ export const navigationGroups = [
{ path: "/client", label: "Client Area", iconKey: "client" }, { path: "/client", label: "Client Area", iconKey: "client" },
{ path: "/billing", label: "Billing", iconKey: "billing" }, { path: "/billing", label: "Billing", iconKey: "billing" },
{ path: "/rbac", label: "RBAC", iconKey: "rbac" }, { path: "/rbac", label: "RBAC", iconKey: "rbac" },
{ path: "/profile", label: "Profile", iconKey: "profile" },
{ path: "/system", label: "System", iconKey: "system" },
{ path: "/settings", label: "Settings", iconKey: "settings" } { path: "/settings", label: "Settings", iconKey: "settings" }
] ]
} }

View File

@@ -7,9 +7,22 @@ export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoadingAuth, setIsLoadingAuth] = useState(true); const [isLoadingAuth, setIsLoadingAuth] = useState(true);
const [isLoadingPublicSettings, setIsLoadingPublicSettings] = useState(false); const [isLoadingPublicSettings, setIsLoadingPublicSettings] = useState(true);
const [appPublicSettings, setAppPublicSettings] = useState(null);
const [authError, setAuthError] = useState(null); const [authError, setAuthError] = useState(null);
const loadPublicSettings = useCallback(async () => {
try {
setIsLoadingPublicSettings(true);
const payload = await appClient.system.publicSite();
setAppPublicSettings(payload || null);
} catch {
setAppPublicSettings(null);
} finally {
setIsLoadingPublicSettings(false);
}
}, []);
const checkAppState = useCallback(async () => { const checkAppState = useCallback(async () => {
try { try {
setIsLoadingAuth(true); setIsLoadingAuth(true);
@@ -33,15 +46,19 @@ export const AuthProvider = ({ children }) => {
checkAppState(); checkAppState();
}, [checkAppState]); }, [checkAppState]);
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
const logout = useCallback((shouldRedirect = true) => { const logout = useCallback((shouldRedirect = true) => {
setUser(null); setUser(null);
setIsAuthenticated(false); setIsAuthenticated(false);
setAuthError({ type: "auth_required", message: "Logged out" }); setAuthError({ type: "auth_required", message: "Logged out" });
if (shouldRedirect) { if (shouldRedirect) {
appClient.auth.logout("/"); void appClient.auth.logout("/");
} else { } else {
appClient.auth.logout(); void appClient.auth.logout();
} }
}, []); }, []);
@@ -57,10 +74,11 @@ export const AuthProvider = ({ children }) => {
isLoadingAuth, isLoadingAuth,
isLoadingPublicSettings, isLoadingPublicSettings,
authError, authError,
appPublicSettings: null, appPublicSettings,
logout, logout,
navigateToLogin, navigateToLogin,
checkAppState checkAppState,
loadPublicSettings
}} }}
> >
{children} {children}

View File

@@ -1,12 +1,32 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import moment from "moment";
import { FileText, Filter, Search } from "lucide-react";
import { appClient } from "@/api/appClient"; import { appClient } from "@/api/appClient";
import { FileText, Search, Filter } from "lucide-react";
import PageHeader from "../components/shared/PageHeader"; import PageHeader from "../components/shared/PageHeader";
import StatusBadge from "../components/shared/StatusBadge"; import StatusBadge from "../components/shared/StatusBadge";
import EmptyState from "../components/shared/EmptyState"; import EmptyState from "../components/shared/EmptyState";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import moment from "moment";
function asSearchableText(value) {
if (value == null) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function formatDetails(value) {
if (value == null) return "-";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export default function AuditLogs() { export default function AuditLogs() {
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
@@ -15,31 +35,58 @@ export default function AuditLogs() {
const [severityFilter, setSeverityFilter] = useState("all"); const [severityFilter, setSeverityFilter] = useState("all");
useEffect(() => { useEffect(() => {
appClient.entities.AuditLog.list("-created_date", 100).then(data => { setLogs(data); setLoading(false); }); let active = true;
appClient.entities.AuditLog.list("-created_date", 200)
.then((data) => {
if (!active) return;
setLogs(data || []);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []); }, []);
const filtered = logs.filter(l => { const filtered = logs.filter((item) => {
const matchSearch = (l.action || "").toLowerCase().includes(search.toLowerCase()) || const needle = search.trim().toLowerCase();
(l.actor_email || "").toLowerCase().includes(search.toLowerCase()) || const matchSearch =
(l.resource_name || "").toLowerCase().includes(search.toLowerCase()); !needle ||
const matchSeverity = severityFilter === "all" || l.severity === severityFilter; asSearchableText(item.action).toLowerCase().includes(needle) ||
asSearchableText(item.actor_email).toLowerCase().includes(needle) ||
asSearchableText(item.resource_name).toLowerCase().includes(needle) ||
asSearchableText(item.details).toLowerCase().includes(needle);
const matchSeverity = severityFilter === "all" || item.severity === severityFilter;
return matchSearch && matchSeverity; return matchSearch && matchSeverity;
}); });
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>; if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title="Audit Logs" description={`${logs.length} events recorded`} /> <PageHeader title="Audit Logs" description={`${logs.length} immutable events recorded`} />
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search logs..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9 bg-card border-border" /> <Input
placeholder="Search action, actor, resource, details..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
/>
</div> </div>
<Select value={severityFilter} onValueChange={setSeverityFilter}> <Select value={severityFilter} onValueChange={setSeverityFilter}>
<SelectTrigger className="w-[160px] bg-card border-border"> <SelectTrigger className="w-[170px]">
<Filter className="w-3.5 h-3.5 mr-2 text-muted-foreground" /><SelectValue /> <Filter className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Severity</SelectItem> <SelectItem value="all">All Severity</SelectItem>
@@ -52,30 +99,45 @@ export default function AuditLogs() {
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<EmptyState icon={FileText} title="No Audit Logs" description="Activity will be recorded here." /> <EmptyState
icon={FileText}
title="No audit events"
description="No audit events matched the current filters."
/>
) : ( ) : (
<div className="surface-card overflow-hidden"> <div className="surface-card overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-auto">
<table className="w-full"> <table className="w-full min-w-[980px]">
<thead> <thead>
<tr className="border-b border-border bg-muted/30"> <tr className="border-b border-border bg-muted/30">
{["Time", "Severity", "Action", "Resource", "Actor", "Details"].map(h => ( {["Time", "Severity", "Action", "Resource", "Actor", "Details"].map((header) => (
<th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th> <th
key={header}
className="px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{header}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{filtered.map(log => ( {filtered.map((log) => (
<tr key={log.id} className="hover:bg-muted/30 transition-colors"> <tr key={log.id} className="hover:bg-muted/30">
<td className="px-4 py-3 text-xs text-muted-foreground font-mono whitespace-nowrap">{log.created_date ? moment(log.created_date).format("MMM D, HH:mm:ss") : "—"}</td> <td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-muted-foreground">
<td className="px-4 py-3"><StatusBadge status={log.severity} /></td> {log.created_date ? moment(log.created_date).format("MMM D, HH:mm:ss") : "-"}
<td className="px-4 py-3 text-sm font-medium text-foreground">{log.action}</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="text-sm text-foreground">{log.resource_name || "—"}</span> <StatusBadge status={log.severity} />
<p className="text-[11px] text-muted-foreground capitalize">{log.resource_type}</p> </td>
<td className="px-4 py-3 text-sm font-medium text-foreground">{log.action}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<p className="text-foreground">{log.resource_name || "-"}</p>
<p className="text-[11px] uppercase tracking-wide">{log.resource_type || "-"}</p>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{log.actor_email || "-"}</td>
<td className="max-w-[420px] px-4 py-3 text-xs text-muted-foreground">
<p className="truncate">{formatDetails(log.details)}</p>
</td> </td>
<td className="px-4 py-3 text-sm text-muted-foreground">{log.actor_email}</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{log.details || "—"}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -18,7 +18,7 @@ function safeRedirect(rawNext) {
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { checkAppState } = useAuth(); const { checkAppState, appPublicSettings } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const params = useMemo(() => new URLSearchParams(location.search), [location.search]); const params = useMemo(() => new URLSearchParams(location.search), [location.search]);
@@ -26,7 +26,12 @@ export default function Login() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [recoveryCode, setRecoveryCode] = useState("");
const [mfaRequired, setMfaRequired] = useState(false);
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const brandName = appPublicSettings?.branding?.app_name || "ProxPanel Cloud";
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
@@ -34,10 +39,21 @@ export default function Login() {
try { try {
setSubmitting(true); setSubmitting(true);
await appClient.auth.login(email.trim(), password); await appClient.auth.login(email.trim(), password, {
mfa_code: mfaRequired && !useRecoveryCode ? mfaCode.trim() : undefined,
recovery_code: mfaRequired && useRecoveryCode ? recoveryCode.trim() : undefined
});
await checkAppState(); await checkAppState();
navigate(redirectTo, { replace: true }); navigate(redirectTo, { replace: true });
} catch (error) { } catch (error) {
if (error?.code === "MFA_REQUIRED") {
setMfaRequired(true);
toast({
title: "MFA verification required",
description: "Enter your authenticator code or use a recovery code."
});
return;
}
toast({ toast({
title: "Login failed", title: "Login failed",
description: error?.message || "Invalid credentials", description: error?.message || "Invalid credentials",
@@ -53,7 +69,7 @@ export default function Login() {
<div className="mx-auto grid min-h-[calc(100vh-4rem)] w-full max-w-6xl overflow-hidden rounded-3xl border border-border bg-card shadow-[0_18px_60px_rgba(15,23,42,0.12)] md:grid-cols-[1.1fr_0.9fr]"> <div className="mx-auto grid min-h-[calc(100vh-4rem)] w-full max-w-6xl overflow-hidden rounded-3xl border border-border bg-card shadow-[0_18px_60px_rgba(15,23,42,0.12)] md:grid-cols-[1.1fr_0.9fr]">
<section className="hidden bg-gradient-to-br from-blue-700 to-sky-700 p-10 text-white md:flex md:flex-col md:justify-between"> <section className="hidden bg-gradient-to-br from-blue-700 to-sky-700 p-10 text-white md:flex md:flex-col md:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-blue-100">ProxPanel Cloud</p> <p className="text-xs font-semibold uppercase tracking-[0.16em] text-blue-100">{brandName}</p>
<h1 className="mt-4 text-4xl font-semibold leading-tight">Operate enterprise infrastructure with precision.</h1> <h1 className="mt-4 text-4xl font-semibold leading-tight">Operate enterprise infrastructure with precision.</h1>
<p className="mt-4 max-w-md text-sm text-blue-100/90"> <p className="mt-4 max-w-md text-sm text-blue-100/90">
Unified control for compute, network, billing, and tenant operations, built for high-trust production environments. Unified control for compute, network, billing, and tenant operations, built for high-trust production environments.
@@ -105,6 +121,42 @@ export default function Login() {
required required
/> />
</div> </div>
{mfaRequired ? (
<>
{!useRecoveryCode ? (
<div className="space-y-1.5">
<Label htmlFor="mfaCode">Authenticator Code</Label>
<Input
id="mfaCode"
inputMode="numeric"
autoComplete="one-time-code"
value={mfaCode}
onChange={(event) => setMfaCode(event.target.value)}
placeholder="123456"
required
/>
</div>
) : (
<div className="space-y-1.5">
<Label htmlFor="recoveryCode">Recovery Code</Label>
<Input
id="recoveryCode"
value={recoveryCode}
onChange={(event) => setRecoveryCode(event.target.value)}
placeholder="ABCD-1234-EFGH-5678"
required
/>
</div>
)}
<button
type="button"
onClick={() => setUseRecoveryCode((value) => !value)}
className="text-xs font-medium text-primary hover:underline"
>
{useRecoveryCode ? "Use authenticator code instead" : "Use a recovery code instead"}
</button>
</>
) : null}
<Button type="submit" className="mt-2 w-full" disabled={submitting}> <Button type="submit" className="mt-2 w-full" disabled={submitting}>
{submitting ? "Signing In..." : "Sign In"} {submitting ? "Signing In..." : "Sign In"}
</Button> </Button>

613
src/pages/Profile.jsx Normal file
View File

@@ -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 (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Profile & Identity"
description="Manage your account profile, security controls, and active authentication sessions."
/>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<section className="space-y-6">
<div className="surface-card p-6">
<div className="mb-4 flex items-center gap-2">
<UserCircle2 className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Profile</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label>Email</Label>
<Input value={profile?.email || ""} disabled className="mt-1" />
</div>
<div>
<Label>Role</Label>
<Input value={profile?.role || ""} disabled className="mt-1" />
</div>
<div>
<Label>Full Name</Label>
<Input
value={profileForm.full_name}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, full_name: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Avatar URL</Label>
<Input
value={profileForm.avatar_url}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, avatar_url: event.target.value }))
}
className="mt-1"
placeholder="https://example.com/avatar.png"
/>
</div>
<div className="md:col-span-2">
<Label>Profile Metadata (JSON)</Label>
<textarea
value={profileForm.profile_metadata_text}
onChange={(event) =>
setProfileForm((prev) => ({
...prev,
profile_metadata_text: event.target.value
}))
}
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>Created: {formatDate(profile?.created_at)}</span>
<span>Last login: {formatDate(profile?.last_login_at)}</span>
<span>MFA: {profile?.mfa_enabled ? "Enabled" : "Disabled"}</span>
</div>
<Button className="mt-4" onClick={handleSaveProfile} disabled={savingProfile}>
{savingProfile ? "Saving..." : "Save Profile"}
</Button>
</div>
<div className="surface-card p-6">
<div className="mb-4 flex items-center gap-2">
<Lock className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Password Management</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<Label>Current Password</Label>
<Input
type="password"
value={passwordForm.current_password}
onChange={(event) =>
setPasswordForm((prev) => ({
...prev,
current_password: event.target.value
}))
}
className="mt-1"
/>
</div>
<div>
<Label>New Password</Label>
<Input
type="password"
value={passwordForm.new_password}
onChange={(event) =>
setPasswordForm((prev) => ({ ...prev, new_password: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Confirm New Password</Label>
<Input
type="password"
value={passwordForm.confirm_password}
onChange={(event) =>
setPasswordForm((prev) => ({
...prev,
confirm_password: event.target.value
}))
}
className="mt-1"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button onClick={handleChangePassword} disabled={changingPassword}>
{changingPassword ? "Updating..." : "Change Password"}
</Button>
<Button
variant="outline"
onClick={handleRequestResetToken}
disabled={requestingResetToken}
>
{requestingResetToken ? "Generating..." : "Generate Reset Token"}
</Button>
</div>
{lastResetToken?.token ? (
<div className="mt-3 rounded-lg border border-border bg-muted/40 p-3">
<p className="text-xs font-semibold text-foreground">Latest Reset Token</p>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{lastResetToken.token}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Expires: {formatDate(lastResetToken.expires_at)}
</p>
</div>
) : null}
</div>
</section>
<section className="space-y-6">
<div className="surface-card p-6">
<div className="mb-4 flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Multi-Factor Authentication</h3>
</div>
{!profile?.mfa_enabled ? (
<div className="space-y-4">
<div>
<Label>Account Password</Label>
<Input
type="password"
value={mfaSetupPassword}
onChange={(event) => setMfaSetupPassword(event.target.value)}
className="mt-1"
/>
</div>
<Button
onClick={handleStartMfaSetup}
disabled={!mfaSetupPassword || loadingMfaSetup}
>
{loadingMfaSetup ? "Preparing..." : "Generate MFA Secret"}
</Button>
{mfaSetupPayload ? (
<div className="space-y-3 rounded-lg border border-border bg-muted/40 p-3">
<div>
<p className="text-xs font-semibold text-foreground">otpauth URI</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground">
{mfaSetupPayload.otpauth_uri}
</p>
</div>
<div>
<p className="text-xs font-semibold text-foreground">Recovery Codes</p>
<div className="mt-1 space-y-1">
{(mfaSetupPayload.recovery_codes || []).map((code) => (
<p
key={code}
className="rounded bg-background px-2 py-1 font-mono text-[11px] text-muted-foreground"
>
{code}
</p>
))}
</div>
</div>
<div>
<Label>Verification Code</Label>
<Input
value={mfaEnableCode}
onChange={(event) => setMfaEnableCode(event.target.value)}
className="mt-1"
placeholder="123456"
/>
</div>
<Button onClick={handleEnableMfa} disabled={!mfaEnableCode || enablingMfa}>
{enablingMfa ? "Enabling..." : "Enable MFA"}
</Button>
</div>
) : null}
</div>
) : (
<div className="space-y-4">
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
MFA is currently enabled for this account.
</p>
<div>
<Label>Password</Label>
<Input
type="password"
value={mfaDisableForm.password}
onChange={(event) =>
setMfaDisableForm((prev) => ({
...prev,
password: event.target.value
}))
}
className="mt-1"
/>
</div>
<div>
<Label>MFA Code (optional)</Label>
<Input
value={mfaDisableForm.code}
onChange={(event) =>
setMfaDisableForm((prev) => ({ ...prev, code: event.target.value }))
}
className="mt-1"
placeholder="123456"
/>
</div>
<Button
variant="destructive"
onClick={handleDisableMfa}
disabled={!mfaDisableForm.password || disablingMfa}
>
{disablingMfa ? "Disabling..." : "Disable MFA"}
</Button>
</div>
)}
</div>
<div className="surface-card p-6">
<div className="mb-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Sessions</h3>
</div>
<span className="text-xs text-muted-foreground">
Active: {activeSessionCount}/{sessions.length}
</span>
</div>
<div className="space-y-2">
{sessions.map((session) => (
<div
key={session.id}
className="rounded-lg border border-border bg-background/65 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-foreground">
{session.user_agent || "Unknown agent"}
</p>
<p className="text-[11px] text-muted-foreground">
IP: {session.ip_address || "-"}
</p>
<p className="text-[11px] text-muted-foreground">
Issued: {formatDate(session.issued_at)}
</p>
<p className="text-[11px] text-muted-foreground">
Last used: {formatDate(session.last_used_at)}
</p>
</div>
{session.revoked_at ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
Revoked
</span>
) : (
<Button
variant="outline"
size="sm"
onClick={() => revokeSession(session.id)}
>
Revoke
</Button>
)}
</div>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="outline" onClick={refreshSessions} disabled={sessionsLoading}>
{sessionsLoading ? "Refreshing..." : "Refresh Sessions"}
</Button>
<Button variant="destructive" onClick={revokeAllSessions}>
Revoke All
</Button>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@@ -1,170 +1,432 @@
import { useState, useEffect } from "react"; import { useEffect, useMemo, useState } from "react";
import { appClient } from "@/api/appClient";
import { Shield, Users } from "lucide-react"; import { Shield, Users } from "lucide-react";
import { appClient } from "@/api/appClient";
import PageHeader from "../components/shared/PageHeader"; import PageHeader from "../components/shared/PageHeader";
import EmptyState from "../components/shared/EmptyState"; import EmptyState from "../components/shared/EmptyState";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { motion } from "framer-motion";
const defaultRoles = [ const roleOptions = ["SUPER_ADMIN", "TENANT_ADMIN", "OPERATOR", "VIEWER"];
{
name: "Super Admin",
description: "Full system access with no restrictions",
permissions: ["vm:create", "vm:read", "vm:update", "vm:delete", "vm:start", "vm:stop", "node:manage", "tenant:manage", "billing:manage", "backup:manage", "user:manage", "rbac:manage", "settings:manage", "audit:read"],
color: "text-rose-600",
bg: "bg-rose-50",
},
{
name: "Tenant Admin",
description: "Manage VMs and billing within tenant scope",
permissions: ["vm:create", "vm:read", "vm:update", "vm:delete", "vm:start", "vm:stop", "backup:manage", "billing:read", "audit:read"],
color: "text-indigo-600",
bg: "bg-indigo-50",
},
{
name: "Operator",
description: "Start/stop VMs and run backups",
permissions: ["vm:read", "vm:start", "vm:stop", "backup:manage", "audit:read"],
color: "text-sky-600",
bg: "bg-sky-50",
},
{
name: "Viewer",
description: "Read-only access to dashboards and VMs",
permissions: ["vm:read", "billing:read", "audit:read"],
color: "text-emerald-600",
bg: "bg-emerald-50",
},
];
const allPermissions = [ function roleLabel(value) {
{ group: "Virtual Machines", items: ["vm:create", "vm:read", "vm:update", "vm:delete", "vm:start", "vm:stop"] }, return String(value || "")
{ group: "Nodes", items: ["node:manage"] }, .toLowerCase()
{ group: "Tenants", items: ["tenant:manage"] }, .replace(/_/g, " ")
{ group: "Billing", items: ["billing:manage", "billing:read"] }, .replace(/\b\w/g, (char) => char.toUpperCase());
{ group: "Backups", items: ["backup:manage"] }, }
{ group: "Users", items: ["user:manage"] },
{ group: "RBAC", items: ["rbac:manage"] },
{ group: "Settings", items: ["settings:manage"] },
{ group: "Audit", items: ["audit:read"] },
];
export default function RBAC() { export default function RBAC() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast(); const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [loadingUsers, setLoadingUsers] = useState(false);
const [savingUser, setSavingUser] = useState(false);
const [resettingUserId, setResettingUserId] = useState("");
const [roles, setRoles] = useState([]);
const [users, setUsers] = useState([]);
const [tenants, setTenants] = useState([]);
const [lastTempPassword, setLastTempPassword] = useState("");
const [search, setSearch] = useState("");
const [createForm, setCreateForm] = useState({
email: "",
full_name: "",
role: "VIEWER",
tenant_id: "",
generate_password: true,
password: "",
is_active: true
});
async function loadData() {
try {
setLoading(true);
const [rolesPayload, usersPayload, tenantsPayload] = await Promise.all([
appClient.adminUsers.listRoles(),
appClient.adminUsers.listUsers(),
appClient.entities.Tenant.list("-created_date", 500)
]);
setRoles(rolesPayload || []);
setUsers(usersPayload || []);
setTenants(tenantsPayload || []);
} catch (error) {
toast({
title: "RBAC load failed",
description: error?.message || "Unable to load role and user data.",
variant: "destructive"
});
} finally {
setLoading(false);
}
}
async function refreshUsers() {
try {
setLoadingUsers(true);
const usersPayload = await appClient.adminUsers.listUsers();
setUsers(usersPayload || []);
} catch (error) {
toast({
title: "User refresh failed",
description: error?.message || "Unable to refresh users.",
variant: "destructive"
});
} finally {
setLoadingUsers(false);
}
}
useEffect(() => { useEffect(() => {
appClient.entities.User.list().then(data => { setUsers(data); setLoading(false); }); void loadData();
}, []); }, []);
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>; const filteredUsers = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return users;
return users.filter((user) => {
return (
String(user.email || "").toLowerCase().includes(needle) ||
String(user.full_name || "").toLowerCase().includes(needle) ||
String(user.role || "").toLowerCase().includes(needle) ||
String(user.tenant?.name || "").toLowerCase().includes(needle)
);
});
}, [search, users]);
async function handleCreateUser() {
try {
setSavingUser(true);
const payload = {
email: createForm.email.trim(),
full_name: createForm.full_name.trim(),
role: createForm.role,
tenant_id: createForm.role === "TENANT_ADMIN" ? createForm.tenant_id || undefined : undefined,
generate_password: createForm.generate_password,
password: createForm.generate_password ? undefined : createForm.password,
is_active: createForm.is_active
};
const response = await appClient.adminUsers.createUser(payload);
await refreshUsers();
setCreateForm({
email: "",
full_name: "",
role: "VIEWER",
tenant_id: "",
generate_password: true,
password: "",
is_active: true
});
setLastTempPassword(response?.temporary_password || "");
toast({
title: "User created",
description: "A new user account has been provisioned."
});
} catch (error) {
toast({
title: "User create failed",
description: error?.message || "Unable to create user.",
variant: "destructive"
});
} finally {
setSavingUser(false);
}
}
async function handleUserPatch(userId, payload) {
try {
await appClient.adminUsers.updateUser(userId, payload);
await refreshUsers();
toast({
title: "User updated",
description: "User role or status has been updated."
});
} catch (error) {
toast({
title: "Update failed",
description: error?.message || "Unable to update user.",
variant: "destructive"
});
}
}
async function handleResetPassword(user) {
try {
setResettingUserId(user.id);
const payload = await appClient.adminUsers.resetPassword(user.id);
setLastTempPassword(payload?.temporary_password || "");
toast({
title: "Password reset",
description: `Temporary password issued for ${user.email}.`
});
} catch (error) {
toast({
title: "Reset failed",
description: error?.message || "Could not reset password.",
variant: "destructive"
});
} finally {
setResettingUserId("");
}
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title="Access Control" description="Role-based permissions management" /> <PageHeader
title="Role-Based Access Control"
description="Manage operators, tenant administrators, and view-only users with secure credential workflows."
/>
{/* Roles */} <div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div> <section className="surface-card p-6">
<h2 className="text-sm font-semibold text-foreground mb-3">Roles</h2> <div className="mb-4 flex items-center gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <Shield className="h-4 w-4 text-primary" />
{defaultRoles.map((role, i) => ( <h3 className="text-sm font-semibold text-foreground">Role Catalog</h3>
<motion.div key={role.name} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }} </div>
className="surface-card p-5 hover:border-primary/20 transition-all"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center gap-3 mb-3"> {roles.map((role) => (
<div className={`w-9 h-9 rounded-lg ${role.bg} flex items-center justify-center`}> <div key={role.role} className="rounded-lg border border-border bg-background/70 p-3">
<Shield className={`w-4.5 h-4.5 ${role.color}`} /> <p className="text-sm font-semibold text-foreground">{role.label}</p>
</div> <p className="text-xs text-muted-foreground">{role.scope}</p>
<div> <p className="mt-1 text-xs text-muted-foreground">{role.description}</p>
<h3 className="text-sm font-semibold text-foreground">{role.name}</h3>
<p className="text-[11px] text-muted-foreground">{role.description}</p>
</div>
</div> </div>
<div className="flex flex-wrap gap-1.5"> ))}
{role.permissions.map(p => ( </div>
<span key={p} className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground font-mono">{p}</span> </section>
))}
<section className="surface-card p-6">
<div className="mb-4 flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Create User</h3>
</div>
<div className="space-y-3">
<div>
<Label>Email</Label>
<Input
type="email"
value={createForm.email}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, email: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Full Name</Label>
<Input
value={createForm.full_name}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, full_name: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Role</Label>
<Select
value={createForm.role}
onValueChange={(value) =>
setCreateForm((prev) => ({
...prev,
role: value,
tenant_id: value === "TENANT_ADMIN" ? prev.tenant_id : ""
}))
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleOptions.map((role) => (
<SelectItem key={role} value={role}>
{roleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createForm.role === "TENANT_ADMIN" ? (
<div>
<Label>Tenant</Label>
<Select
value={createForm.tenant_id}
onValueChange={(value) =>
setCreateForm((prev) => ({ ...prev, tenant_id: value }))
}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select tenant" />
</SelectTrigger>
<SelectContent>
{tenants.map((tenant) => (
<SelectItem key={tenant.id} value={tenant.id}>
{tenant.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</motion.div> ) : null}
))} <div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
</div> <Label className="text-xs">Auto-generate secure password</Label>
<Switch
checked={createForm.generate_password}
onCheckedChange={(value) =>
setCreateForm((prev) => ({ ...prev, generate_password: value }))
}
/>
</div>
{!createForm.generate_password ? (
<div>
<Label>Password</Label>
<Input
type="password"
value={createForm.password}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, password: event.target.value }))
}
className="mt-1"
/>
</div>
) : null}
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<Label className="text-xs">Active account</Label>
<Switch
checked={createForm.is_active}
onCheckedChange={(value) =>
setCreateForm((prev) => ({ ...prev, is_active: value }))
}
/>
</div>
<Button
onClick={handleCreateUser}
disabled={
savingUser ||
!createForm.email ||
!createForm.full_name ||
(!createForm.generate_password && !createForm.password)
}
className="w-full"
>
{savingUser ? "Creating..." : "Create User"}
</Button>
{lastTempPassword ? (
<div className="rounded-lg border border-border bg-muted/40 p-3">
<p className="text-xs font-semibold text-foreground">Latest Temporary Password</p>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{lastTempPassword}
</p>
</div>
) : null}
</div>
</section>
</div> </div>
{/* Permission Matrix */} <section className="surface-card overflow-hidden">
<div> <div className="border-b border-border bg-muted/40 px-4 py-3">
<h2 className="text-sm font-semibold text-foreground mb-3">Permission Matrix</h2> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="surface-card overflow-hidden"> <h3 className="text-sm font-semibold text-foreground">Users ({users.length})</h3>
<div className="overflow-x-auto"> <Input
<table className="w-full"> value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name, email, role, tenant..."
className="h-8 w-full sm:w-[320px]"
/>
</div>
</div>
{filteredUsers.length === 0 ? (
<div className="p-6">
<EmptyState
icon={Users}
title="No users found"
description="Create the first administrative account to start managing RBAC."
/>
</div>
) : (
<div className="overflow-auto">
<table className="w-full min-w-[960px]">
<thead> <thead>
<tr className="border-b border-border bg-muted/30"> <tr className="border-b border-border bg-muted/20">
<th className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">Permission</th> {["Name", "Email", "Role", "Tenant", "MFA", "Status", "Actions"].map((header) => (
{defaultRoles.map(r => ( <th
<th key={r.name} className="text-center text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{r.name}</th> key={header}
className="px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{header}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{allPermissions.map(group => ( {filteredUsers.map((user) => (
group.items.map((perm, j) => ( <tr key={user.id} className="hover:bg-muted/30">
<tr key={perm} className="hover:bg-muted/30 transition-colors"> <td className="px-4 py-3 text-sm text-foreground">{user.full_name || "-"}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-3 text-sm text-muted-foreground">{user.email}</td>
{j === 0 && <p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">{group.group}</p>} <td className="px-4 py-3">
<span className="text-xs font-mono text-foreground">{perm}</span> <Select
</td> value={user.role}
{defaultRoles.map(r => ( onValueChange={(value) => handleUserPatch(user.id, { role: value })}
<td key={r.name} className="text-center px-4 py-2.5"> >
{r.permissions.includes(perm) ? ( <SelectTrigger className="h-8 w-[160px]">
<span className="inline-block w-5 h-5 rounded-md bg-primary/20 text-primary text-xs leading-5"></span> <SelectValue />
) : ( </SelectTrigger>
<span className="inline-block w-5 h-5 rounded-md bg-muted text-muted-foreground/30 text-xs leading-5"></span> <SelectContent>
)} {roleOptions.map((role) => (
</td> <SelectItem key={role} value={role}>
))} {roleLabel(role)}
</tr> </SelectItem>
)) ))}
</SelectContent>
</Select>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{user.tenant?.name || "-"}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{user.mfa_enabled ? "Enabled" : "Disabled"}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Switch
checked={Boolean(user.is_active)}
onCheckedChange={(value) =>
handleUserPatch(user.id, { is_active: value })
}
/>
<span className="text-xs text-muted-foreground">
{user.is_active ? "Active" : "Inactive"}
</span>
</div>
</td>
<td className="px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={() => handleResetPassword(user)}
disabled={resettingUserId === user.id || loadingUsers}
>
{resettingUserId === user.id ? "Resetting..." : "Reset Password"}
</Button>
</td>
</tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
{/* Users */}
<div>
<h2 className="text-sm font-semibold text-foreground mb-3">Users ({users.length})</h2>
{users.length === 0 ? (
<EmptyState icon={Users} title="No Users" description="Users will appear here once they register." />
) : (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
{["User", "Email", "Role", "Joined"].map(h => (
<th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map(u => (
<tr key={u.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-sm font-medium text-foreground">{u.full_name || "—"}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{u.email}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium capitalize">{u.role || "user"}</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{u.created_date ? new Date(u.created_date).toLocaleDateString() : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)} )}
</div> </section>
</div> </div>
); );
} }

View File

@@ -0,0 +1,895 @@
import { useEffect, useState } from "react";
import { Brush, ClipboardList, Globe, Rocket } 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 { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
const defaultBranding = {
app_name: "VotCloud",
logo_url: "",
primary_color: "#0ea5e9",
accent_color: "#1d4ed8",
support_email: "",
website_url: "",
legal_company_name: "VotCloud",
copyright_notice: ""
};
const defaultSubscriptionPolicy = {
default_trial_days: 14,
default_grace_days: 3,
trial_vm_limit: 2,
auto_suspend_on_expiry: true
};
const defaultPageForm = {
slug: "",
title: "",
section: "general",
content_text: "{}",
is_published: false
};
const defaultNavForm = {
label: "",
href: "",
position: "header",
sort_order: 100,
is_enabled: true,
metadata_text: "{}"
};
export default function SystemManagement() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [savingBranding, setSavingBranding] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [startingTrial, setStartingTrial] = useState(false);
const [expiringTrials, setExpiringTrials] = useState(false);
const [savingPage, setSavingPage] = useState(false);
const [savingNav, setSavingNav] = useState(false);
const [branding, setBranding] = useState(defaultBranding);
const [subscriptionPolicy, setSubscriptionPolicy] = useState(defaultSubscriptionPolicy);
const [tenants, setTenants] = useState([]);
const [selectedTenantId, setSelectedTenantId] = useState("");
const [trialForm, setTrialForm] = useState({
days: 14,
grace_days: 3,
vm_limit: 2
});
const [pages, setPages] = useState([]);
const [pageForm, setPageForm] = useState(defaultPageForm);
const [editingPageId, setEditingPageId] = useState(null);
const [navigationItems, setNavigationItems] = useState([]);
const [navForm, setNavForm] = useState(defaultNavForm);
const [editingNavId, setEditingNavId] = useState(null);
async function loadData() {
try {
setLoading(true);
const [brandingPayload, policyPayload, tenantsPayload, pagesPayload, navigationPayload] =
await Promise.all([
appClient.system.getBranding(),
appClient.system.getSubscriptionPolicy(),
appClient.entities.Tenant.list("-created_date", 500),
appClient.system.listCmsPages({ include_drafts: true }),
appClient.system.listNavigationItems()
]);
setBranding((prev) => ({ ...prev, ...(brandingPayload || {}) }));
setSubscriptionPolicy((prev) => ({ ...prev, ...(policyPayload || {}) }));
setTenants(tenantsPayload || []);
setPages(pagesPayload || []);
setNavigationItems(navigationPayload || []);
} catch (error) {
toast({
title: "System data load failed",
description: error?.message || "Unable to load system management data.",
variant: "destructive"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData();
}, []);
async function saveBranding() {
try {
setSavingBranding(true);
const payload = await appClient.system.saveBranding(branding);
setBranding((prev) => ({ ...prev, ...(payload || {}) }));
toast({
title: "Branding updated",
description: "White-label branding settings were saved."
});
} catch (error) {
toast({
title: "Branding save failed",
description: error?.message || "Could not update branding settings.",
variant: "destructive"
});
} finally {
setSavingBranding(false);
}
}
async function savePolicy() {
try {
setSavingPolicy(true);
const payload = await appClient.system.saveSubscriptionPolicy(subscriptionPolicy);
setSubscriptionPolicy((prev) => ({ ...prev, ...(payload || {}) }));
toast({
title: "Subscription policy updated",
description: "Global trial and subscription policy has been saved."
});
} catch (error) {
toast({
title: "Policy save failed",
description: error?.message || "Could not update subscription policy.",
variant: "destructive"
});
} finally {
setSavingPolicy(false);
}
}
async function startTrialForTenant() {
if (!selectedTenantId) {
toast({
title: "Select a tenant",
description: "Choose a tenant before starting a trial.",
variant: "destructive"
});
return;
}
try {
setStartingTrial(true);
await appClient.system.startTrial(selectedTenantId, trialForm);
await loadData();
toast({
title: "Trial started",
description: "Tenant trial lifecycle has been applied."
});
} catch (error) {
toast({
title: "Trial start failed",
description: error?.message || "Unable to start trial for tenant.",
variant: "destructive"
});
} finally {
setStartingTrial(false);
}
}
async function expireTrialsNow() {
try {
setExpiringTrials(true);
const payload = await appClient.system.expireTrials();
await loadData();
toast({
title: "Trial expiry completed",
description: `Expired tenants: ${payload?.expired_count ?? 0}`
});
} catch (error) {
toast({
title: "Trial expiry failed",
description: error?.message || "Could not run trial expiry.",
variant: "destructive"
});
} finally {
setExpiringTrials(false);
}
}
function resetPageForm() {
setPageForm(defaultPageForm);
setEditingPageId(null);
}
async function saveCmsPage() {
try {
setSavingPage(true);
const content = pageForm.content_text.trim() ? JSON.parse(pageForm.content_text) : {};
const payload = {
slug: pageForm.slug.trim(),
title: pageForm.title.trim(),
section: pageForm.section.trim() || "general",
content,
is_published: pageForm.is_published
};
if (editingPageId) {
await appClient.system.updateCmsPage(editingPageId, payload);
} else {
await appClient.system.createCmsPage(payload);
}
await loadData();
resetPageForm();
toast({
title: editingPageId ? "CMS page updated" : "CMS page created",
description: "Landing page content has been saved."
});
} catch (error) {
toast({
title: "CMS save failed",
description: error?.message || "Could not save CMS page.",
variant: "destructive"
});
} finally {
setSavingPage(false);
}
}
function editPage(page) {
setEditingPageId(page.id);
setPageForm({
slug: page.slug || "",
title: page.title || "",
section: page.section || "general",
content_text: JSON.stringify(page.content || {}, null, 2),
is_published: Boolean(page.is_published)
});
}
async function removePage(id) {
try {
await appClient.system.deleteCmsPage(id);
await loadData();
if (editingPageId === id) {
resetPageForm();
}
toast({
title: "CMS page deleted",
description: "The selected page has been removed."
});
} catch (error) {
toast({
title: "Page delete failed",
description: error?.message || "Could not delete CMS page.",
variant: "destructive"
});
}
}
function resetNavForm() {
setNavForm(defaultNavForm);
setEditingNavId(null);
}
async function saveNavigationItem() {
try {
setSavingNav(true);
const metadata = navForm.metadata_text.trim() ? JSON.parse(navForm.metadata_text) : {};
const payload = {
label: navForm.label.trim(),
href: navForm.href.trim(),
position: navForm.position,
sort_order: Number(navForm.sort_order || 0),
is_enabled: navForm.is_enabled,
metadata
};
if (editingNavId) {
await appClient.system.updateNavigationItem(editingNavId, payload);
} else {
await appClient.system.createNavigationItem(payload);
}
await loadData();
resetNavForm();
toast({
title: editingNavId ? "Navigation updated" : "Navigation created",
description: "Site navigation settings were saved."
});
} catch (error) {
toast({
title: "Navigation save failed",
description: error?.message || "Could not save navigation item.",
variant: "destructive"
});
} finally {
setSavingNav(false);
}
}
function editNavigationItem(item) {
setEditingNavId(item.id);
setNavForm({
label: item.label || "",
href: item.href || "",
position: item.position || "header",
sort_order: item.sort_order ?? 100,
is_enabled: Boolean(item.is_enabled),
metadata_text: JSON.stringify(item.metadata || {}, null, 2)
});
}
async function removeNavigationItem(id) {
try {
await appClient.system.deleteNavigationItem(id);
await loadData();
if (editingNavId === id) {
resetNavForm();
}
toast({
title: "Navigation deleted",
description: "The selected navigation item has been removed."
});
} catch (error) {
toast({
title: "Navigation delete failed",
description: error?.message || "Could not delete navigation item.",
variant: "destructive"
});
}
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="System Management"
description="Global controls for branding, subscription lifecycle, tenant trials, and customer-facing CMS content."
/>
<Tabs defaultValue="branding" className="space-y-4">
<TabsList className="h-auto flex-wrap gap-1 bg-muted p-1">
<TabsTrigger value="branding" className="gap-1.5 text-xs">
<Brush className="h-3.5 w-3.5" /> Branding
</TabsTrigger>
<TabsTrigger value="lifecycle" className="gap-1.5 text-xs">
<Rocket className="h-3.5 w-3.5" /> Trial Lifecycle
</TabsTrigger>
<TabsTrigger value="cms" className="gap-1.5 text-xs">
<Globe className="h-3.5 w-3.5" /> CMS Pages
</TabsTrigger>
<TabsTrigger value="navigation" className="gap-1.5 text-xs">
<ClipboardList className="h-3.5 w-3.5" /> Navigation
</TabsTrigger>
</TabsList>
<TabsContent value="branding">
<div className="surface-card p-6">
<h3 className="text-sm font-semibold text-foreground">White-label Branding Engine</h3>
<p className="mt-1 text-xs text-muted-foreground">
Manage global logos, brand colors, support identity, and legal footer metadata.
</p>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label>Application Name</Label>
<Input
value={branding.app_name || ""}
onChange={(event) =>
setBranding((prev) => ({ ...prev, app_name: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Logo URL</Label>
<Input
value={branding.logo_url || ""}
onChange={(event) =>
setBranding((prev) => ({ ...prev, logo_url: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Primary Color</Label>
<Input
value={branding.primary_color || ""}
onChange={(event) =>
setBranding((prev) => ({ ...prev, primary_color: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Accent Color</Label>
<Input
value={branding.accent_color || ""}
onChange={(event) =>
setBranding((prev) => ({ ...prev, accent_color: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Support Email</Label>
<Input
value={branding.support_email || ""}
onChange={(event) =>
setBranding((prev) => ({ ...prev, support_email: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Website URL</Label>
<Input
value={branding.website_url || ""}
onChange={(event) =>
setBranding((prev) => ({ ...prev, website_url: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Legal Company Name</Label>
<Input
value={branding.legal_company_name || ""}
onChange={(event) =>
setBranding((prev) => ({
...prev,
legal_company_name: event.target.value
}))
}
className="mt-1"
/>
</div>
<div>
<Label>Copyright Notice</Label>
<Input
value={branding.copyright_notice || ""}
onChange={(event) =>
setBranding((prev) => ({
...prev,
copyright_notice: event.target.value
}))
}
className="mt-1"
/>
</div>
</div>
<Button className="mt-4" onClick={saveBranding} disabled={savingBranding}>
{savingBranding ? "Saving..." : "Save Branding"}
</Button>
</div>
</TabsContent>
<TabsContent value="lifecycle">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="surface-card p-6">
<h3 className="text-sm font-semibold text-foreground">Subscription Trial Policy</h3>
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<Label>Default Trial Days</Label>
<Input
type="number"
value={subscriptionPolicy.default_trial_days}
onChange={(event) =>
setSubscriptionPolicy((prev) => ({
...prev,
default_trial_days: Number(event.target.value || 14)
}))
}
className="mt-1"
/>
</div>
<div>
<Label>Default Grace Days</Label>
<Input
type="number"
value={subscriptionPolicy.default_grace_days}
onChange={(event) =>
setSubscriptionPolicy((prev) => ({
...prev,
default_grace_days: Number(event.target.value || 3)
}))
}
className="mt-1"
/>
</div>
<div>
<Label>Trial VM Limit</Label>
<Input
type="number"
value={subscriptionPolicy.trial_vm_limit}
onChange={(event) =>
setSubscriptionPolicy((prev) => ({
...prev,
trial_vm_limit: Number(event.target.value || 2)
}))
}
className="mt-1"
/>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
checked={Boolean(subscriptionPolicy.auto_suspend_on_expiry)}
onCheckedChange={(value) =>
setSubscriptionPolicy((prev) => ({
...prev,
auto_suspend_on_expiry: value
}))
}
/>
<Label>Auto suspend on expiry</Label>
</div>
</div>
<Button className="mt-4" onClick={savePolicy} disabled={savingPolicy}>
{savingPolicy ? "Saving..." : "Save Subscription Policy"}
</Button>
</div>
<div className="surface-card p-6">
<h3 className="text-sm font-semibold text-foreground">Start Tenant Trial</h3>
<div className="mt-4 space-y-4">
<div>
<Label>Tenant</Label>
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select tenant" />
</SelectTrigger>
<SelectContent>
{tenants.map((tenant) => (
<SelectItem key={tenant.id} value={tenant.id}>
{tenant.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Days</Label>
<Input
type="number"
value={trialForm.days}
onChange={(event) =>
setTrialForm((prev) => ({
...prev,
days: Number(event.target.value || 14)
}))
}
className="mt-1"
/>
</div>
<div>
<Label>Grace Days</Label>
<Input
type="number"
value={trialForm.grace_days}
onChange={(event) =>
setTrialForm((prev) => ({
...prev,
grace_days: Number(event.target.value || 3)
}))
}
className="mt-1"
/>
</div>
<div>
<Label>VM Limit</Label>
<Input
type="number"
value={trialForm.vm_limit}
onChange={(event) =>
setTrialForm((prev) => ({
...prev,
vm_limit: Number(event.target.value || 2)
}))
}
className="mt-1"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={startTrialForTenant} disabled={startingTrial}>
{startingTrial ? "Starting..." : "Start Trial"}
</Button>
<Button variant="outline" onClick={expireTrialsNow} disabled={expiringTrials}>
{expiringTrials ? "Running..." : "Run Expiry Sweep"}
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="cms">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<div className="surface-card p-6">
<h3 className="text-sm font-semibold text-foreground">
{editingPageId ? "Edit CMS Page" : "Create CMS Page"}
</h3>
<div className="mt-4 space-y-3">
<div>
<Label>Slug</Label>
<Input
value={pageForm.slug}
onChange={(event) =>
setPageForm((prev) => ({ ...prev, slug: event.target.value }))
}
className="mt-1"
placeholder="pricing"
/>
</div>
<div>
<Label>Title</Label>
<Input
value={pageForm.title}
onChange={(event) =>
setPageForm((prev) => ({ ...prev, title: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Section</Label>
<Input
value={pageForm.section}
onChange={(event) =>
setPageForm((prev) => ({ ...prev, section: event.target.value }))
}
className="mt-1"
placeholder="landing"
/>
</div>
<div>
<Label>Content (JSON)</Label>
<textarea
value={pageForm.content_text}
onChange={(event) =>
setPageForm((prev) => ({ ...prev, content_text: event.target.value }))
}
className="mt-1 h-40 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={pageForm.is_published}
onCheckedChange={(value) =>
setPageForm((prev) => ({ ...prev, is_published: value }))
}
/>
<Label>Published</Label>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={saveCmsPage} disabled={savingPage}>
{savingPage ? "Saving..." : editingPageId ? "Update Page" : "Create Page"}
</Button>
{editingPageId ? (
<Button variant="outline" onClick={resetPageForm}>
Cancel Edit
</Button>
) : null}
</div>
</div>
</div>
<div className="surface-card overflow-hidden">
<div className="border-b border-border bg-muted/40 px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">CMS Pages ({pages.length})</h3>
</div>
<div className="max-h-[520px] overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/20">
{["Title", "Slug", "Section", "State", "Actions"].map((column) => (
<th
key={column}
className="px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{pages.map((page) => (
<tr key={page.id} className="hover:bg-muted/30">
<td className="px-4 py-2 text-sm text-foreground">{page.title}</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">{page.slug}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{page.section}</td>
<td className="px-4 py-2 text-xs">
<span
className={`rounded-full px-2 py-0.5 ${
page.is_published
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700"
}`}
>
{page.is_published ? "Published" : "Draft"}
</span>
</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => editPage(page)}>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => removePage(page.id)}
>
Delete
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="navigation">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<div className="surface-card p-6">
<h3 className="text-sm font-semibold text-foreground">
{editingNavId ? "Edit Navigation Item" : "Create Navigation Item"}
</h3>
<div className="mt-4 space-y-3">
<div>
<Label>Label</Label>
<Input
value={navForm.label}
onChange={(event) =>
setNavForm((prev) => ({ ...prev, label: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Href</Label>
<Input
value={navForm.href}
onChange={(event) =>
setNavForm((prev) => ({ ...prev, href: event.target.value }))
}
className="mt-1"
placeholder="/pricing"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Position</Label>
<Select
value={navForm.position}
onValueChange={(value) => setNavForm((prev) => ({ ...prev, position: value }))}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header">Header</SelectItem>
<SelectItem value="footer">Footer</SelectItem>
<SelectItem value="legal">Legal</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Sort Order</Label>
<Input
type="number"
value={navForm.sort_order}
onChange={(event) =>
setNavForm((prev) => ({
...prev,
sort_order: Number(event.target.value || 0)
}))
}
className="mt-1"
/>
</div>
</div>
<div>
<Label>Metadata (JSON)</Label>
<textarea
value={navForm.metadata_text}
onChange={(event) =>
setNavForm((prev) => ({ ...prev, metadata_text: event.target.value }))
}
className="mt-1 h-28 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={navForm.is_enabled}
onCheckedChange={(value) => setNavForm((prev) => ({ ...prev, is_enabled: value }))}
/>
<Label>Enabled</Label>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={saveNavigationItem} disabled={savingNav}>
{savingNav ? "Saving..." : editingNavId ? "Update Navigation" : "Create Navigation"}
</Button>
{editingNavId ? (
<Button variant="outline" onClick={resetNavForm}>
Cancel Edit
</Button>
) : null}
</div>
</div>
</div>
<div className="surface-card overflow-hidden">
<div className="border-b border-border bg-muted/40 px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">
Navigation Items ({navigationItems.length})
</h3>
</div>
<div className="max-h-[520px] overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/20">
{["Label", "Href", "Position", "Order", "State", "Actions"].map((column) => (
<th
key={column}
className="px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{navigationItems.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="px-4 py-2 text-sm text-foreground">{item.label}</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">{item.href}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{item.position}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{item.sort_order}</td>
<td className="px-4 py-2 text-xs">
<span
className={`rounded-full px-2 py-0.5 ${
item.is_enabled
? "bg-emerald-50 text-emerald-700"
: "bg-slate-100 text-slate-600"
}`}
>
{item.is_enabled ? "Enabled" : "Disabled"}
</span>
</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => editNavigationItem(item)}
>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => removeNavigationItem(item.id)}
>
Delete
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,121 +1,430 @@
import { useState, useEffect } from "react"; import { useEffect, useMemo, useState } from "react";
import { Building2, Plus, Search } from "lucide-react";
import { motion } from "framer-motion";
import { appClient } from "@/api/appClient"; import { appClient } from "@/api/appClient";
import { Plus, Building2, Search } from "lucide-react";
import PageHeader from "../components/shared/PageHeader"; import PageHeader from "../components/shared/PageHeader";
import StatusBadge from "../components/shared/StatusBadge"; import StatusBadge from "../components/shared/StatusBadge";
import EmptyState from "../components/shared/EmptyState"; import EmptyState from "../components/shared/EmptyState";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { motion } from "framer-motion";
const defaultForm = {
name: "",
owner_email: "",
plan: "starter",
status: "active",
currency: "NGN",
payment_provider: "paystack",
vm_limit: 5,
cpu_limit: 16,
ram_limit_mb: 16384,
disk_limit_gb: 500
};
function slugify(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-");
}
export default function Tenants() { export default function Tenants() {
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: "", owner_email: "", plan: "starter", currency: "NGN", payment_provider: "paystack", vm_limit: 5, cpu_limit: 16, ram_limit_mb: 16384, disk_limit_gb: 500 });
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => { loadData(); }, []); const [tenants, setTenants] = useState([]);
const loadData = async () => { setTenants(await appClient.entities.Tenant.list("-created_date")); setLoading(false); }; const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [search, setSearch] = useState("");
const [showDialog, setShowDialog] = useState(false);
const [editingTenant, setEditingTenant] = useState(null);
const [form, setForm] = useState(defaultForm);
const handleCreate = async () => { async function loadData() {
if (!form.name || !form.owner_email) return; try {
setCreating(true); setLoading(true);
await appClient.entities.Tenant.create({ ...form, status: "active", slug: form.name.toLowerCase().replace(/\s+/g, "-"), balance: 0, member_emails: [] }); const payload = await appClient.entities.Tenant.list("-created_date", 500);
await loadData(); setTenants(payload || []);
setShowCreate(false); } catch (error) {
setForm({ name: "", owner_email: "", plan: "starter", currency: "NGN", payment_provider: "paystack", vm_limit: 5, cpu_limit: 16, ram_limit_mb: 16384, disk_limit_gb: 500 }); toast({
setCreating(false); title: "Load failed",
toast({ title: "Tenant Created", description: form.name }); description: error?.message || "Unable to load tenants.",
}; variant: "destructive"
});
} finally {
setLoading(false);
}
}
const handleDelete = async (t) => { useEffect(() => {
await appClient.entities.Tenant.delete(t.id); void loadData();
await loadData(); }, []);
toast({ title: "Tenant Deleted", description: t.name, variant: "destructive" });
};
const filtered = tenants.filter(t => t.name.toLowerCase().includes(search.toLowerCase()) || (t.owner_email || "").toLowerCase().includes(search.toLowerCase())); function openCreateDialog() {
setEditingTenant(null);
setForm(defaultForm);
setShowDialog(true);
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>; function openEditDialog(tenant) {
setEditingTenant(tenant);
setForm({
name: tenant.name || "",
owner_email: tenant.owner_email || "",
plan: String(tenant.plan || "starter").toLowerCase(),
status: String(tenant.status || "active").toLowerCase(),
currency: String(tenant.currency || "NGN").toUpperCase(),
payment_provider: String(tenant.payment_provider || "paystack").toLowerCase(),
vm_limit: Number(tenant.vm_limit || 0),
cpu_limit: Number(tenant.cpu_limit || 0),
ram_limit_mb: Number(tenant.ram_limit_mb || 0),
disk_limit_gb: Number(tenant.disk_limit_gb || 0)
});
setShowDialog(true);
}
async function handleSave() {
if (!form.name || !form.owner_email) {
return;
}
try {
setSaving(true);
const payload = {
...form,
slug: editingTenant ? undefined : slugify(form.name),
balance: editingTenant ? undefined : 0,
member_emails: editingTenant ? undefined : []
};
if (editingTenant) {
await appClient.entities.Tenant.update(editingTenant.id, payload);
} else {
await appClient.entities.Tenant.create(payload);
}
await loadData();
setShowDialog(false);
toast({
title: editingTenant ? "Tenant updated" : "Tenant created",
description: form.name
});
} catch (error) {
toast({
title: "Save failed",
description: error?.message || "Unable to save tenant.",
variant: "destructive"
});
} finally {
setSaving(false);
}
}
async function handleDelete(tenant) {
try {
await appClient.entities.Tenant.delete(tenant.id);
await loadData();
toast({
title: "Tenant deleted",
description: tenant.name,
variant: "destructive"
});
} catch (error) {
toast({
title: "Delete failed",
description: error?.message || "Unable to delete tenant.",
variant: "destructive"
});
}
}
const filteredTenants = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return tenants;
return tenants.filter((tenant) => {
return (
String(tenant.name || "").toLowerCase().includes(needle) ||
String(tenant.owner_email || "").toLowerCase().includes(needle) ||
String(tenant.plan || "").toLowerCase().includes(needle)
);
});
}, [search, tenants]);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title="Tenants" description={`${tenants.length} organizations`}> <PageHeader title="Tenants" description={`${tenants.length} organizations`}>
<Button onClick={() => setShowCreate(true)} className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"><Plus className="w-4 h-4" /> New Tenant</Button> <Button onClick={openCreateDialog} className="gap-2">
<Plus className="h-4 w-4" /> New Tenant
</Button>
</PageHeader> </PageHeader>
<div className="relative"> <div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search tenants..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9 bg-card border-border max-w-sm" /> <Input
placeholder="Search tenants..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
/>
</div> </div>
{filtered.length === 0 ? ( {filteredTenants.length === 0 ? (
<EmptyState icon={Building2} title="No Tenants" description="Create your first tenant organization." <EmptyState
action={<Button onClick={() => setShowCreate(true)} variant="outline" className="gap-2"><Plus className="w-4 h-4" /> New Tenant</Button>} /> icon={Building2}
title="No tenants"
description="Create your first tenant organization."
action={
<Button onClick={openCreateDialog} variant="outline" className="gap-2">
<Plus className="h-4 w-4" /> New Tenant
</Button>
}
/>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((t, i) => ( {filteredTenants.map((tenant, index) => (
<motion.div key={t.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.04 }} <motion.div
className="surface-card p-5 hover:border-primary/20 transition-all group"> key={tenant.id}
<div className="flex items-start justify-between mb-3"> initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04 }}
className="surface-card group p-5 transition-all hover:border-primary/20"
>
<div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/30 flex items-center justify-center"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/30">
<Building2 className="w-5 h-5 text-accent" /> <Building2 className="h-5 w-5 text-accent" />
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-foreground">{t.name}</h3> <h3 className="text-sm font-semibold text-foreground">{tenant.name}</h3>
<p className="text-[11px] text-muted-foreground">{t.owner_email}</p> <p className="text-[11px] text-muted-foreground">{tenant.owner_email}</p>
</div> </div>
</div> </div>
<StatusBadge status={t.status} /> <StatusBadge status={tenant.status} />
</div> </div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground py-3 border-t border-border">
<div>Plan: <span className="text-foreground font-medium capitalize">{t.plan}</span></div> <div className="grid grid-cols-2 gap-2 border-t border-border py-3 text-xs text-muted-foreground">
<div>VMs: <span className="text-foreground font-medium">{t.vm_limit || 0} max</span></div> <div>
<div>Balance: <span className="text-foreground font-medium">{t.currency} {(t.balance || 0).toLocaleString()}</span></div> Plan:{" "}
<div>Payment: <span className="text-foreground font-medium capitalize">{t.payment_provider || ""}</span></div> <span className="font-medium capitalize text-foreground">{tenant.plan || "-"}</span>
</div>
<div>
VMs:{" "}
<span className="font-medium text-foreground">{tenant.vm_limit || 0} max</span>
</div>
<div>
Balance:{" "}
<span className="font-medium text-foreground">
{tenant.currency} {(tenant.balance || 0).toLocaleString()}
</span>
</div>
<div>
Payment:{" "}
<span className="font-medium capitalize text-foreground">
{tenant.payment_provider || "-"}
</span>
</div>
</div> </div>
<div className="flex justify-end pt-2">
<Button variant="ghost" size="sm" className="text-rose-600 hover:bg-rose-50 text-xs opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleDelete(t)}>Delete</Button> <div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
size="sm"
className="text-xs opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => openEditDialog(tenant)}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-rose-600 opacity-0 transition-opacity hover:bg-rose-50 group-hover:opacity-100"
onClick={() => handleDelete(tenant)}
>
Delete
</Button>
</div> </div>
</motion.div> </motion.div>
))} ))}
</div> </div>
)} )}
<Dialog open={showCreate} onOpenChange={setShowCreate}> <Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="bg-card border-border max-w-lg"> <DialogContent className="max-w-xl">
<DialogHeader><DialogTitle>Create Tenant</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>{editingTenant ? "Update Tenant" : "Create Tenant"}</DialogTitle>
</DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div><Label>Organization Name</Label><Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} className="bg-muted border-border mt-1" /></div> <div>
<div><Label>Owner Email</Label><Input type="email" value={form.owner_email} onChange={e => setForm({ ...form, owner_email: e.target.value })} className="bg-muted border-border mt-1" /></div> <Label>Organization Name</Label>
<Input
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
className="mt-1"
/>
</div>
<div>
<Label>Owner Email</Label>
<Input
type="email"
value={form.owner_email}
onChange={(event) =>
setForm((prev) => ({ ...prev, owner_email: event.target.value }))
}
className="mt-1"
/>
</div>
</div> </div>
<div className="grid grid-cols-3 gap-3">
<div><Label>Plan</Label><Select value={form.plan} onValueChange={v => setForm({ ...form, plan: v })}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="starter">Starter</SelectItem><SelectItem value="professional">Professional</SelectItem><SelectItem value="enterprise">Enterprise</SelectItem><SelectItem value="custom">Custom</SelectItem></SelectContent></Select></div> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div><Label>Currency</Label><Select value={form.currency} onValueChange={v => setForm({ ...form, currency: v })}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="NGN">NGN</SelectItem><SelectItem value="USD">USD</SelectItem><SelectItem value="GHS">GHS</SelectItem><SelectItem value="KES">KES</SelectItem><SelectItem value="ZAR">ZAR</SelectItem></SelectContent></Select></div> <div>
<div><Label>Payment</Label><Select value={form.payment_provider} onValueChange={v => setForm({ ...form, payment_provider: v })}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="paystack">Paystack</SelectItem><SelectItem value="flutterwave">Flutterwave</SelectItem><SelectItem value="manual">Manual</SelectItem></SelectContent></Select></div> <Label>Plan</Label>
<Select
value={form.plan}
onValueChange={(value) => setForm((prev) => ({ ...prev, plan: value }))}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="starter">Starter</SelectItem>
<SelectItem value="professional">Professional</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Status</Label>
<Select
value={form.status}
onValueChange={(value) => setForm((prev) => ({ ...prev, status: value }))}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="trial">Trial</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Currency</Label>
<Select
value={form.currency}
onValueChange={(value) => setForm((prev) => ({ ...prev, currency: value }))}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NGN">NGN</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GHS">GHS</SelectItem>
<SelectItem value="KES">KES</SelectItem>
<SelectItem value="ZAR">ZAR</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Payment</Label>
<Select
value={form.payment_provider}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, payment_provider: value }))
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="paystack">Paystack</SelectItem>
<SelectItem value="flutterwave">Flutterwave</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3">
<div><Label>VM Limit</Label><Input type="number" value={form.vm_limit} onChange={e => setForm({ ...form, vm_limit: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div><Label>Disk Limit (GB)</Label><Input type="number" value={form.disk_limit_gb} onChange={e => setForm({ ...form, disk_limit_gb: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div> <div>
<Label>VM Limit</Label>
<Input
type="number"
value={form.vm_limit}
onChange={(event) =>
setForm((prev) => ({ ...prev, vm_limit: Number(event.target.value || 0) }))
}
className="mt-1"
/>
</div>
<div>
<Label>CPU Limit</Label>
<Input
type="number"
value={form.cpu_limit}
onChange={(event) =>
setForm((prev) => ({ ...prev, cpu_limit: Number(event.target.value || 0) }))
}
className="mt-1"
/>
</div>
<div>
<Label>RAM Limit (MB)</Label>
<Input
type="number"
value={form.ram_limit_mb}
onChange={(event) =>
setForm((prev) => ({
...prev,
ram_limit_mb: Number(event.target.value || 0)
}))
}
className="mt-1"
/>
</div>
<div>
<Label>Disk Limit (GB)</Label>
<Input
type="number"
value={form.disk_limit_gb}
onChange={(event) =>
setForm((prev) => ({
...prev,
disk_limit_gb: Number(event.target.value || 0)
}))
}
className="mt-1"
/>
</div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button> <Button variant="outline" onClick={() => setShowDialog(false)}>
<Button onClick={handleCreate} disabled={creating || !form.name || !form.owner_email} className="bg-primary text-primary-foreground">{creating ? "Creating..." : "Create Tenant"}</Button> Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !form.name || !form.owner_email}>
{saving ? "Saving..." : editingTenant ? "Update Tenant" : "Create Tenant"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
); );
} }