feat: implement enterprise RBAC, profile identity, system management, and audit stabilization
This commit is contained in:
@@ -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;
|
||||
@@ -302,14 +302,23 @@ model User {
|
||||
email String @unique
|
||||
password_hash String
|
||||
full_name String?
|
||||
avatar_url String?
|
||||
profile_metadata Json @default("{}")
|
||||
role Role @default(VIEWER)
|
||||
tenant_id String?
|
||||
is_active Boolean @default(true)
|
||||
must_change_password Boolean @default(false)
|
||||
mfa_enabled Boolean @default(false)
|
||||
mfa_secret String?
|
||||
mfa_recovery_codes Json @default("[]")
|
||||
password_changed_at DateTime?
|
||||
last_login_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
tenant Tenant? @relation(fields: [tenant_id], references: [id], onDelete: SetNull)
|
||||
auth_sessions AuthSession[]
|
||||
password_reset_tokens PasswordResetToken[]
|
||||
|
||||
@@index([tenant_id])
|
||||
}
|
||||
@@ -319,6 +328,11 @@ model Tenant {
|
||||
name String
|
||||
slug String @unique
|
||||
status TenantStatus @default(ACTIVE)
|
||||
trial_starts_at DateTime?
|
||||
trial_ends_at DateTime?
|
||||
trial_grace_ends_at DateTime?
|
||||
trial_days Int?
|
||||
trial_locked Boolean @default(false)
|
||||
plan String @default("starter")
|
||||
owner_email String
|
||||
member_emails Json @default("[]")
|
||||
@@ -351,9 +365,42 @@ model Tenant {
|
||||
monitoring_alert_events MonitoringAlertEvent[]
|
||||
|
||||
@@index([status])
|
||||
@@index([trial_ends_at])
|
||||
@@index([owner_email])
|
||||
}
|
||||
|
||||
model AuthSession {
|
||||
id String @id @default(cuid())
|
||||
user_id String
|
||||
refresh_token_hash String @unique
|
||||
ip_address String?
|
||||
user_agent String?
|
||||
issued_at DateTime @default(now())
|
||||
expires_at DateTime
|
||||
last_used_at DateTime?
|
||||
revoked_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([user_id, revoked_at])
|
||||
@@index([expires_at])
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(cuid())
|
||||
user_id String
|
||||
token_hash String @unique
|
||||
expires_at DateTime
|
||||
used_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([user_id, expires_at])
|
||||
}
|
||||
|
||||
model ProxmoxNode {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -1203,3 +1250,32 @@ model Setting {
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
}
|
||||
|
||||
model CmsPage {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
section String @default("general")
|
||||
content Json @default("{}")
|
||||
is_published Boolean @default(false)
|
||||
created_by String?
|
||||
updated_by String?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@index([section, is_published])
|
||||
}
|
||||
|
||||
model SiteNavigationItem {
|
||||
id String @id @default(cuid())
|
||||
label String
|
||||
href String
|
||||
position String @default("header")
|
||||
sort_order Int @default(100)
|
||||
is_enabled Boolean @default(true)
|
||||
metadata Json @default("{}")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@index([position, sort_order])
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import backupRoutes from "./routes/backup.routes";
|
||||
import networkRoutes from "./routes/network.routes";
|
||||
import monitoringRoutes from "./routes/monitoring.routes";
|
||||
import clientRoutes from "./routes/client.routes";
|
||||
import profileRoutes from "./routes/profile.routes";
|
||||
import adminUsersRoutes from "./routes/admin-users.routes";
|
||||
import systemRoutes from "./routes/system.routes";
|
||||
import { errorHandler, notFoundHandler } from "./middleware/error-handler";
|
||||
import { createRateLimit } from "./middleware/rate-limit";
|
||||
|
||||
@@ -82,6 +85,9 @@ export function createApp() {
|
||||
app.use("/api/network", networkRoutes);
|
||||
app.use("/api/monitoring", monitoringRoutes);
|
||||
app.use("/api/client", clientRoutes);
|
||||
app.use("/api/profile", profileRoutes);
|
||||
app.use("/api/admin", adminUsersRoutes);
|
||||
app.use("/api/system", systemRoutes);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -8,6 +8,7 @@ const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(8080),
|
||||
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
|
||||
JWT_SECRET: z.string().min(16, "JWT_SECRET must be at least 16 characters"),
|
||||
SETTINGS_ENCRYPTION_KEY: z.string().min(16).optional(),
|
||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||
JWT_REFRESH_SECRET: z.string().min(16, "JWT_REFRESH_SECRET must be at least 16 characters").optional(),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default("30d"),
|
||||
|
||||
105
backend/src/lib/security.ts
Normal file
105
backend/src/lib/security.ts
Normal 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
100
backend/src/lib/totp.ts
Normal 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`;
|
||||
}
|
||||
@@ -26,7 +26,10 @@ type Permission =
|
||||
| "security:manage"
|
||||
| "security:read"
|
||||
| "user:manage"
|
||||
| "user:read";
|
||||
| "user:read"
|
||||
| "profile:read"
|
||||
| "profile:manage"
|
||||
| "session:manage";
|
||||
|
||||
const rolePermissions: Record<Role, Set<Permission>> = {
|
||||
SUPER_ADMIN: new Set<Permission>([
|
||||
@@ -51,7 +54,10 @@ const rolePermissions: Record<Role, Set<Permission>> = {
|
||||
"security:manage",
|
||||
"security:read",
|
||||
"user:manage",
|
||||
"user:read"
|
||||
"user:read",
|
||||
"profile:read",
|
||||
"profile:manage",
|
||||
"session:manage"
|
||||
]),
|
||||
TENANT_ADMIN: new Set<Permission>([
|
||||
"vm:create",
|
||||
@@ -68,7 +74,10 @@ const rolePermissions: Record<Role, Set<Permission>> = {
|
||||
"settings:read",
|
||||
"audit:read",
|
||||
"security:read",
|
||||
"user:read"
|
||||
"user:read",
|
||||
"profile:read",
|
||||
"profile:manage",
|
||||
"session:manage"
|
||||
]),
|
||||
OPERATOR: new Set<Permission>([
|
||||
"vm:read",
|
||||
@@ -81,7 +90,9 @@ const rolePermissions: Record<Role, Set<Permission>> = {
|
||||
"backup:read",
|
||||
"audit:read",
|
||||
"security:manage",
|
||||
"security:read"
|
||||
"security:read",
|
||||
"profile:read",
|
||||
"profile:manage"
|
||||
]),
|
||||
VIEWER: new Set<Permission>([
|
||||
"vm:read",
|
||||
@@ -92,7 +103,9 @@ const rolePermissions: Record<Role, Set<Permission>> = {
|
||||
"audit:read",
|
||||
"security:read",
|
||||
"settings:read",
|
||||
"user:read"
|
||||
"user:read",
|
||||
"profile:read",
|
||||
"profile:manage"
|
||||
])
|
||||
};
|
||||
|
||||
@@ -120,7 +133,8 @@ export function verifyRefreshToken(token: string): Express.UserToken | null {
|
||||
id: decoded.id,
|
||||
email: decoded.email,
|
||||
role: decoded.role,
|
||||
tenant_id: decoded.tenant_id
|
||||
tenant_id: decoded.tenant_id,
|
||||
sid: decoded.sid
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
272
backend/src/routes/admin-users.routes.ts
Normal file
272
backend/src/routes/admin-users.routes.ts
Normal 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;
|
||||
@@ -1,25 +1,80 @@
|
||||
import { Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { HttpError } from "../lib/http-error";
|
||||
import { createJwtToken, createRefreshToken, requireAuth, verifyRefreshToken } from "../middleware/auth";
|
||||
import { consumeRecoveryCode, hashToken } from "../lib/security";
|
||||
import { verifyTotpCode } from "../lib/totp";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1)
|
||||
password: z.string().min(1),
|
||||
mfa_code: z.string().optional(),
|
||||
recovery_code: z.string().optional()
|
||||
});
|
||||
|
||||
const refreshSchema = z.object({
|
||||
refresh_token: z.string().min(1)
|
||||
});
|
||||
|
||||
const logoutSchema = z.object({
|
||||
refresh_token: z.string().min(1).optional()
|
||||
});
|
||||
|
||||
function tokenExpiryDate(refreshToken: string) {
|
||||
const decoded = jwt.decode(refreshToken) as { exp?: number } | null;
|
||||
const exp = decoded?.exp ? new Date(decoded.exp * 1000) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
return exp;
|
||||
}
|
||||
|
||||
async function createAuthSession(input: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Express.UserToken["role"];
|
||||
tenant_id?: string | null;
|
||||
};
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}) {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const basePayload = {
|
||||
id: input.user.id,
|
||||
email: input.user.email,
|
||||
role: input.user.role,
|
||||
tenant_id: input.user.tenant_id,
|
||||
sid: sessionId
|
||||
};
|
||||
const accessToken = createJwtToken(basePayload);
|
||||
const refreshToken = createRefreshToken(basePayload);
|
||||
|
||||
await prisma.authSession.create({
|
||||
data: {
|
||||
id: sessionId,
|
||||
user_id: input.user.id,
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
ip_address: input.ipAddress,
|
||||
user_agent: input.userAgent,
|
||||
expires_at: tokenExpiryDate(refreshToken),
|
||||
last_used_at: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
token: accessToken,
|
||||
refresh_token: refreshToken
|
||||
};
|
||||
}
|
||||
|
||||
router.post("/login", async (req, res, next) => {
|
||||
try {
|
||||
const payload = loginSchema.parse(req.body);
|
||||
const user = await prisma.user.findUnique({ where: { email: payload.email } });
|
||||
const user = await prisma.user.findUnique({ where: { email: payload.email.toLowerCase().trim() } });
|
||||
if (!user || !user.is_active) {
|
||||
throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS");
|
||||
}
|
||||
@@ -28,6 +83,35 @@ router.post("/login", async (req, res, next) => {
|
||||
throw new HttpError(401, "Invalid email or password", "INVALID_CREDENTIALS");
|
||||
}
|
||||
|
||||
if (user.mfa_enabled) {
|
||||
const mfaCode = payload.mfa_code?.trim();
|
||||
const recoveryCode = payload.recovery_code?.trim();
|
||||
if (!mfaCode && !recoveryCode) {
|
||||
throw new HttpError(401, "MFA code is required", "MFA_REQUIRED");
|
||||
}
|
||||
|
||||
let mfaPassed = false;
|
||||
if (mfaCode && user.mfa_secret) {
|
||||
mfaPassed = verifyTotpCode(mfaCode, user.mfa_secret, { window: 1 });
|
||||
}
|
||||
|
||||
if (!mfaPassed && recoveryCode) {
|
||||
const existingHashes = Array.isArray(user.mfa_recovery_codes) ? (user.mfa_recovery_codes as string[]) : [];
|
||||
const result = consumeRecoveryCode(recoveryCode, existingHashes);
|
||||
if (result.matched) {
|
||||
mfaPassed = true;
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { mfa_recovery_codes: result.remainingHashes }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!mfaPassed) {
|
||||
throw new HttpError(401, "Invalid MFA code", "MFA_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { last_login_at: new Date() }
|
||||
@@ -39,18 +123,24 @@ router.post("/login", async (req, res, next) => {
|
||||
role: user.role,
|
||||
tenant_id: user.tenant_id
|
||||
};
|
||||
const token = createJwtToken(userPayload);
|
||||
const refreshToken = createRefreshToken(userPayload);
|
||||
const tokens = await createAuthSession({
|
||||
user: userPayload,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get("user-agent") ?? undefined
|
||||
});
|
||||
|
||||
res.json({
|
||||
token,
|
||||
refresh_token: refreshToken,
|
||||
token: tokens.token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
must_change_password: user.must_change_password,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
tenant_id: user.tenant_id
|
||||
tenant_id: user.tenant_id,
|
||||
avatar_url: user.avatar_url,
|
||||
mfa_enabled: user.mfa_enabled
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -62,10 +152,27 @@ router.post("/refresh", async (req, res, next) => {
|
||||
try {
|
||||
const payload = refreshSchema.parse(req.body ?? {});
|
||||
const decoded = verifyRefreshToken(payload.refresh_token);
|
||||
if (!decoded) {
|
||||
if (!decoded || !decoded.sid) {
|
||||
throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN");
|
||||
}
|
||||
|
||||
const session = await prisma.authSession.findFirst({
|
||||
where: {
|
||||
id: decoded.sid,
|
||||
user_id: decoded.id,
|
||||
revoked_at: null
|
||||
}
|
||||
});
|
||||
if (!session) {
|
||||
throw new HttpError(401, "Refresh session not found", "INVALID_REFRESH_TOKEN");
|
||||
}
|
||||
if (session.expires_at.getTime() < Date.now()) {
|
||||
throw new HttpError(401, "Refresh session expired", "INVALID_REFRESH_TOKEN");
|
||||
}
|
||||
if (session.refresh_token_hash !== hashToken(payload.refresh_token)) {
|
||||
throw new HttpError(401, "Refresh token mismatch", "INVALID_REFRESH_TOKEN");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: {
|
||||
@@ -84,11 +191,23 @@ router.post("/refresh", async (req, res, next) => {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenant_id: user.tenant_id
|
||||
tenant_id: user.tenant_id,
|
||||
sid: decoded.sid
|
||||
};
|
||||
const token = createJwtToken(userPayload);
|
||||
const refreshToken = createRefreshToken(userPayload);
|
||||
|
||||
await prisma.authSession.update({
|
||||
where: { id: decoded.sid },
|
||||
data: {
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
expires_at: tokenExpiryDate(refreshToken),
|
||||
last_used_at: new Date(),
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get("user-agent") ?? session.user_agent
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
token,
|
||||
refresh_token: refreshToken
|
||||
@@ -98,6 +217,46 @@ router.post("/refresh", async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/logout", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const payload = logoutSchema.parse(req.body ?? {});
|
||||
const refreshToken = payload.refresh_token;
|
||||
if (!refreshToken) {
|
||||
await prisma.authSession.updateMany({
|
||||
where: {
|
||||
user_id: req.user!.id,
|
||||
revoked_at: null
|
||||
},
|
||||
data: {
|
||||
revoked_at: new Date()
|
||||
}
|
||||
});
|
||||
return res.json({ success: true, revoked: "all" });
|
||||
}
|
||||
|
||||
const decoded = verifyRefreshToken(refreshToken);
|
||||
if (!decoded?.sid || decoded.id !== req.user!.id) {
|
||||
throw new HttpError(401, "Invalid refresh token", "INVALID_REFRESH_TOKEN");
|
||||
}
|
||||
|
||||
await prisma.authSession.updateMany({
|
||||
where: {
|
||||
id: decoded.sid,
|
||||
user_id: req.user!.id,
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
revoked_at: null
|
||||
},
|
||||
data: {
|
||||
revoked_at: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ success: true, revoked: decoded.sid });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/me", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -109,7 +268,12 @@ router.get("/me", requireAuth, async (req, res, next) => {
|
||||
role: true,
|
||||
tenant_id: true,
|
||||
is_active: true,
|
||||
created_at: true
|
||||
created_at: true,
|
||||
avatar_url: true,
|
||||
profile_metadata: true,
|
||||
mfa_enabled: true,
|
||||
must_change_password: true,
|
||||
last_login_at: true
|
||||
}
|
||||
});
|
||||
if (!user) throw new HttpError(404, "User not found", "USER_NOT_FOUND");
|
||||
|
||||
381
backend/src/routes/profile.routes.ts
Normal file
381
backend/src/routes/profile.routes.ts
Normal 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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth";
|
||||
import { HttpError } from "../lib/http-error";
|
||||
import { toPrismaJsonValue } from "../lib/prisma-json";
|
||||
@@ -274,6 +275,31 @@ const resourceMap: Record<string, ResourceMeta> = {
|
||||
readPermission: "security:read",
|
||||
tenantScoped: true,
|
||||
searchFields: ["destination", "provider_message"]
|
||||
},
|
||||
"cms-pages": {
|
||||
model: "cmsPage",
|
||||
readPermission: "settings:read",
|
||||
createPermission: "settings:manage",
|
||||
updatePermission: "settings:manage",
|
||||
deletePermission: "settings:manage",
|
||||
tenantScoped: false,
|
||||
searchFields: ["slug", "title", "section"]
|
||||
},
|
||||
"site-navigation-items": {
|
||||
model: "siteNavigationItem",
|
||||
readPermission: "settings:read",
|
||||
createPermission: "settings:manage",
|
||||
updatePermission: "settings:manage",
|
||||
deletePermission: "settings:manage",
|
||||
tenantScoped: false,
|
||||
searchFields: ["label", "href", "position"]
|
||||
},
|
||||
"auth-sessions": {
|
||||
model: "authSession",
|
||||
readPermission: "user:manage",
|
||||
updatePermission: "user:manage",
|
||||
deletePermission: "user:manage",
|
||||
tenantScoped: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -624,6 +650,25 @@ router.post("/:resource", requireAuth, async (req, res, next) => {
|
||||
const model = getModel(meta);
|
||||
const payload = normalizePayload(resource, req.body ?? {});
|
||||
|
||||
if (resource === "users") {
|
||||
const email = typeof payload.email === "string" ? payload.email.toLowerCase().trim() : "";
|
||||
if (!email) {
|
||||
throw new HttpError(400, "email is required for users.create", "VALIDATION_ERROR");
|
||||
}
|
||||
payload.email = email;
|
||||
|
||||
const plainPassword = typeof payload.password === "string" ? payload.password : undefined;
|
||||
if (!plainPassword || plainPassword.length < 10) {
|
||||
throw new HttpError(400, "password (min 10 chars) is required for users.create", "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
payload.password_hash = await bcrypt.hash(plainPassword, 12);
|
||||
payload.must_change_password = true;
|
||||
payload.password_changed_at = new Date();
|
||||
delete payload.password;
|
||||
delete payload.password_hash_raw;
|
||||
}
|
||||
|
||||
if (meta.tenantScoped && isTenantScopedUser(req) && req.user?.tenant_id) {
|
||||
if (
|
||||
meta.model !== "backupRestoreTask" &&
|
||||
@@ -666,6 +711,23 @@ router.patch("/:resource/:id", requireAuth, async (req, res, next) => {
|
||||
await ensureItemTenantScope(req, meta, existing);
|
||||
|
||||
const payload = normalizePayload(resource, req.body ?? {});
|
||||
if (resource === "users") {
|
||||
if (typeof payload.email === "string") {
|
||||
payload.email = payload.email.toLowerCase().trim();
|
||||
}
|
||||
if ("password_hash" in payload) {
|
||||
delete payload.password_hash;
|
||||
}
|
||||
if (typeof payload.password === "string") {
|
||||
if (payload.password.length < 10) {
|
||||
throw new HttpError(400, "password must be at least 10 characters", "VALIDATION_ERROR");
|
||||
}
|
||||
payload.password_hash = await bcrypt.hash(payload.password, 12);
|
||||
payload.must_change_password = false;
|
||||
payload.password_changed_at = new Date();
|
||||
}
|
||||
delete payload.password;
|
||||
}
|
||||
const updated = await model.update({
|
||||
where: { id: req.params.id },
|
||||
data: payload
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { authorize, requireAuth } from "../middleware/auth";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { toPrismaJsonValue } from "../lib/prisma-json";
|
||||
import { decryptJson, encryptJson } from "../lib/security";
|
||||
import { getOperationsPolicy } from "../services/operations.service";
|
||||
import { getSchedulerRuntimeSnapshot, reconfigureSchedulers, schedulerDefaults } from "../services/scheduler.service";
|
||||
|
||||
@@ -79,10 +81,38 @@ const notificationsSchema = z.object({
|
||||
ops_email: z.string().email().optional()
|
||||
});
|
||||
|
||||
function decodeSettingValue<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) => {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: "proxmox" } });
|
||||
res.json(setting?.value ?? {});
|
||||
const value = await loadSetting("proxmox", {});
|
||||
res.json(value);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -91,12 +121,13 @@ router.get("/proxmox", requireAuth, authorize("settings:read"), async (_req, res
|
||||
router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = proxmoxSchema.parse(req.body);
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key: "proxmox" },
|
||||
update: { value: payload },
|
||||
create: { key: "proxmox", type: "PROXMOX", value: payload, is_encrypted: true }
|
||||
const value = await saveSetting({
|
||||
key: "proxmox",
|
||||
type: "PROXMOX",
|
||||
value: payload,
|
||||
encrypted: true
|
||||
});
|
||||
res.json(setting.value);
|
||||
res.json(value);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -104,8 +135,8 @@ router.put("/proxmox", requireAuth, authorize("settings:manage"), async (req, re
|
||||
|
||||
router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res, next) => {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: "payment" } });
|
||||
res.json(setting?.value ?? {});
|
||||
const value = await loadSetting("payment", {});
|
||||
res.json(value);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -114,12 +145,13 @@ router.get("/payment", requireAuth, authorize("settings:read"), async (_req, res
|
||||
router.put("/payment", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = paymentSchema.parse(req.body);
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key: "payment" },
|
||||
update: { value: payload },
|
||||
create: { key: "payment", type: "PAYMENT", value: payload, is_encrypted: true }
|
||||
const value = await saveSetting({
|
||||
key: "payment",
|
||||
type: "PAYMENT",
|
||||
value: payload,
|
||||
encrypted: true
|
||||
});
|
||||
res.json(setting.value);
|
||||
res.json(value);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -127,8 +159,8 @@ router.put("/payment", requireAuth, authorize("settings:manage"), async (req, re
|
||||
|
||||
router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res, next) => {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: "backup" } });
|
||||
res.json(setting?.value ?? {});
|
||||
const value = await loadSetting("backup", {});
|
||||
res.json(value);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -137,12 +169,13 @@ router.get("/backup", requireAuth, authorize("settings:read"), async (_req, res,
|
||||
router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = backupSchema.parse(req.body);
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key: "backup" },
|
||||
update: { value: payload },
|
||||
create: { key: "backup", type: "GENERAL", value: payload, is_encrypted: false }
|
||||
const value = await saveSetting({
|
||||
key: "backup",
|
||||
type: "GENERAL",
|
||||
value: payload,
|
||||
encrypted: false
|
||||
});
|
||||
res.json(setting.value);
|
||||
res.json(value);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -150,13 +183,12 @@ router.put("/backup", requireAuth, authorize("settings:manage"), async (req, res
|
||||
|
||||
router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_req, res, next) => {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: "console_proxy" } });
|
||||
res.json(
|
||||
setting?.value ?? {
|
||||
(await loadSetting("console_proxy", {
|
||||
mode: "cluster",
|
||||
cluster: {},
|
||||
nodes: {}
|
||||
}
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -166,12 +198,13 @@ router.get("/console-proxy", requireAuth, authorize("settings:read"), async (_re
|
||||
router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = consoleProxySchema.parse(req.body);
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key: "console_proxy" },
|
||||
update: { value: payload },
|
||||
create: { key: "console_proxy", type: "PROXMOX", value: payload, is_encrypted: false }
|
||||
const value = await saveSetting({
|
||||
key: "console_proxy",
|
||||
type: "PROXMOX",
|
||||
value: payload,
|
||||
encrypted: false
|
||||
});
|
||||
res.json(setting.value);
|
||||
res.json(value);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -179,12 +212,8 @@ router.put("/console-proxy", requireAuth, authorize("settings:manage"), async (r
|
||||
|
||||
router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, res, next) => {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: "scheduler" } });
|
||||
const defaults = schedulerDefaults();
|
||||
const persisted =
|
||||
setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value)
|
||||
? (setting.value as Record<string, unknown>)
|
||||
: {};
|
||||
const persisted = await loadSetting<Record<string, unknown>>("scheduler", {});
|
||||
const config = {
|
||||
...defaults,
|
||||
...persisted
|
||||
@@ -201,15 +230,16 @@ router.get("/scheduler", requireAuth, authorize("settings:read"), async (_req, r
|
||||
router.put("/scheduler", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = schedulerSchema.parse(req.body);
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key: "scheduler" },
|
||||
update: { value: payload },
|
||||
create: { key: "scheduler", type: "GENERAL", value: payload, is_encrypted: false }
|
||||
const config = await saveSetting({
|
||||
key: "scheduler",
|
||||
type: "GENERAL",
|
||||
value: payload,
|
||||
encrypted: false
|
||||
});
|
||||
|
||||
const runtime = await reconfigureSchedulers(payload);
|
||||
return res.json({
|
||||
config: setting.value,
|
||||
config,
|
||||
runtime
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -229,10 +259,11 @@ router.get("/operations-policy", requireAuth, authorize("settings:read"), async
|
||||
router.put("/operations-policy", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = operationsPolicySchema.parse(req.body);
|
||||
await prisma.setting.upsert({
|
||||
where: { key: "operations_policy" },
|
||||
update: { value: payload },
|
||||
create: { key: "operations_policy", type: "GENERAL", value: payload, is_encrypted: false }
|
||||
await saveSetting({
|
||||
key: "operations_policy",
|
||||
type: "GENERAL",
|
||||
value: payload,
|
||||
encrypted: true
|
||||
});
|
||||
|
||||
const policy = await getOperationsPolicy();
|
||||
@@ -244,9 +275,8 @@ router.put("/operations-policy", requireAuth, authorize("settings:manage"), asyn
|
||||
|
||||
router.get("/notifications", requireAuth, authorize("settings:read"), async (_req, res, next) => {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: "notifications" } });
|
||||
return res.json(
|
||||
setting?.value ?? {
|
||||
await loadSetting("notifications", {
|
||||
email_alerts: true,
|
||||
backup_alerts: true,
|
||||
billing_alerts: true,
|
||||
@@ -256,7 +286,7 @@ router.get("/notifications", requireAuth, authorize("settings:read"), async (_re
|
||||
email_gateway_url: "",
|
||||
notification_email_webhook: "",
|
||||
ops_email: ""
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
@@ -266,12 +296,13 @@ router.get("/notifications", requireAuth, authorize("settings:read"), async (_re
|
||||
router.put("/notifications", requireAuth, authorize("settings:manage"), async (req, res, next) => {
|
||||
try {
|
||||
const payload = notificationsSchema.parse(req.body);
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key: "notifications" },
|
||||
update: { value: payload },
|
||||
create: { key: "notifications", type: "EMAIL", value: payload, is_encrypted: false }
|
||||
const value = await saveSetting({
|
||||
key: "notifications",
|
||||
type: "EMAIL",
|
||||
value: payload,
|
||||
encrypted: true
|
||||
});
|
||||
return res.json(setting.value);
|
||||
return res.json(value);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
|
||||
421
backend/src/routes/system.routes.ts
Normal file
421
backend/src/routes/system.routes.ts
Normal 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;
|
||||
@@ -8,6 +8,7 @@
|
||||
import axios from "axios";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { HttpError } from "../lib/http-error";
|
||||
import { decryptJson } from "../lib/security";
|
||||
import { restartVm, shutdownVm, startVm, stopVm } from "./proxmox.service";
|
||||
|
||||
type TaskCreateInput = {
|
||||
@@ -255,13 +256,16 @@ export async function getOperationsPolicy(): Promise<OperationsPolicy> {
|
||||
})
|
||||
]);
|
||||
|
||||
const settingValue = decryptJson(setting?.value) as unknown;
|
||||
const notificationsRaw = decryptJson(notificationsSetting?.value) as unknown;
|
||||
|
||||
const value =
|
||||
setting?.value && typeof setting.value === "object" && !Array.isArray(setting.value)
|
||||
? (setting.value as Record<string, unknown>)
|
||||
settingValue && typeof settingValue === "object" && !Array.isArray(settingValue)
|
||||
? (settingValue as Record<string, unknown>)
|
||||
: {};
|
||||
const notificationsValue =
|
||||
notificationsSetting?.value && typeof notificationsSetting.value === "object" && !Array.isArray(notificationsSetting.value)
|
||||
? (notificationsSetting.value as Record<string, unknown>)
|
||||
notificationsRaw && typeof notificationsRaw === "object" && !Array.isArray(notificationsRaw)
|
||||
? (notificationsRaw as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const maxRetryAttemptsRaw = Number(value.max_retry_attempts);
|
||||
|
||||
@@ -3,6 +3,7 @@ import crypto from "crypto";
|
||||
import { PaymentProvider } from "@prisma/client";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { HttpError } from "../lib/http-error";
|
||||
import { decryptJson } from "../lib/security";
|
||||
import { markInvoicePaid } from "./billing.service";
|
||||
|
||||
type PaymentSettings = {
|
||||
@@ -19,7 +20,7 @@ async function getPaymentSettings(): Promise<PaymentSettings> {
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: "payment" }
|
||||
});
|
||||
return (setting?.value as PaymentSettings) ?? {};
|
||||
return decryptJson<PaymentSettings>(setting?.value) ?? {};
|
||||
}
|
||||
|
||||
function normalizeProvider(provider: string | undefined, fallback: string): PaymentProvider {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TemplateType, VmType } from "@prisma/client";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { env } from "../config/env";
|
||||
import { HttpError } from "../lib/http-error";
|
||||
import { decryptJson } from "../lib/security";
|
||||
|
||||
type ProxmoxSettings = {
|
||||
host: string;
|
||||
@@ -52,7 +53,7 @@ async function getProxmoxSettings(): Promise<ProxmoxSettings> {
|
||||
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) {
|
||||
throw new HttpError(400, "Proxmox credentials are incomplete", "PROXMOX_INCOMPLETE_CONFIG");
|
||||
}
|
||||
|
||||
1
backend/src/types/express.d.ts
vendored
1
backend/src/types/express.d.ts
vendored
@@ -7,6 +7,7 @@ declare global {
|
||||
email: string;
|
||||
role: Role;
|
||||
tenant_id?: string | null;
|
||||
sid?: string;
|
||||
}
|
||||
|
||||
interface Request {
|
||||
|
||||
@@ -22,6 +22,8 @@ import Provisioning from './pages/Provisioning';
|
||||
import NetworkIpam from './pages/NetworkIpam';
|
||||
import ClientArea from './pages/ClientArea';
|
||||
import Security from './pages/Security';
|
||||
import Profile from './pages/Profile';
|
||||
import SystemManagement from './pages/SystemManagement';
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated } = useAuth();
|
||||
@@ -58,6 +60,8 @@ const AuthenticatedApp = () => {
|
||||
<Route path="/operations" element={<Operations />} />
|
||||
<Route path="/audit-logs" element={<AuditLogs />} />
|
||||
<Route path="/rbac" element={<RBAC />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/system" element={<SystemManagement />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={isAuthenticated ? <PageNotFound /> : <Navigate to="/login" replace />} />
|
||||
|
||||
@@ -4,11 +4,13 @@ const STORAGE_REFRESH_TOKEN_KEY = "proxpanel_refresh_token";
|
||||
const resourceMap = {
|
||||
AuditLog: "audit-logs",
|
||||
AppTemplate: "app-templates",
|
||||
AuthSession: "auth-sessions",
|
||||
ApplicationGroup: "application-groups",
|
||||
Backup: "backups",
|
||||
BackupPolicy: "backup-policies",
|
||||
BackupRestoreTask: "backup-restore-tasks",
|
||||
BillingPlan: "billing-plans",
|
||||
CmsPage: "cms-pages",
|
||||
FirewallRule: "firewall-rules",
|
||||
Invoice: "invoices",
|
||||
IpAddressPool: "ip-addresses",
|
||||
@@ -27,6 +29,7 @@ const resourceMap = {
|
||||
ProxmoxNode: "nodes",
|
||||
ProvisionedService: "provisioned-services",
|
||||
SecurityEvent: "security-events",
|
||||
SiteNavigationItem: "site-navigation-items",
|
||||
Tenant: "tenants",
|
||||
UsageRecord: "usage-records",
|
||||
User: "users",
|
||||
@@ -243,8 +246,13 @@ async function request(path, options = {}, hasRetried = false) {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
const error = new Error(errorBody?.message ?? `Request failed: ${response.status}`);
|
||||
const error = new Error(
|
||||
errorBody?.error?.message ??
|
||||
errorBody?.message ??
|
||||
`Request failed: ${response.status}`
|
||||
);
|
||||
error.status = response.status;
|
||||
error.code = errorBody?.error?.code;
|
||||
error.data = errorBody;
|
||||
throw error;
|
||||
}
|
||||
@@ -309,10 +317,15 @@ const entities = new Proxy(
|
||||
);
|
||||
|
||||
const auth = {
|
||||
async login(email, password) {
|
||||
async login(email, password, options = {}) {
|
||||
const payload = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
mfa_code: options.mfa_code,
|
||||
recovery_code: options.recovery_code
|
||||
})
|
||||
});
|
||||
|
||||
if (payload?.token) {
|
||||
@@ -329,9 +342,21 @@ const auth = {
|
||||
return request("/api/auth/me");
|
||||
},
|
||||
|
||||
logout(redirectTo) {
|
||||
setToken(null);
|
||||
setRefreshToken(null);
|
||||
async logout(redirectTo) {
|
||||
const refreshToken = getRefreshToken();
|
||||
try {
|
||||
if (getToken()) {
|
||||
await request("/api/auth/logout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(refreshToken ? { refresh_token: refreshToken } : {})
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Intentionally ignore and continue with local token cleanup.
|
||||
} finally {
|
||||
setToken(null);
|
||||
setRefreshToken(null);
|
||||
}
|
||||
|
||||
if (redirectTo && typeof window !== "undefined") {
|
||||
window.location.href = redirectTo;
|
||||
@@ -351,6 +376,197 @@ const auth = {
|
||||
getRefreshToken
|
||||
};
|
||||
|
||||
const adminUsers = {
|
||||
async listRoles() {
|
||||
return request("/api/admin/roles");
|
||||
},
|
||||
|
||||
async listUsers(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.tenant_id) query.set("tenant_id", params.tenant_id);
|
||||
if (params.role) query.set("role", params.role);
|
||||
const qs = query.toString();
|
||||
return request(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
async createUser(payload) {
|
||||
return request("/api/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async updateUser(id, payload) {
|
||||
return request(`/api/admin/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async resetPassword(id) {
|
||||
return request(`/api/admin/users/${id}/reset-password`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const profile = {
|
||||
async get() {
|
||||
return request("/api/profile");
|
||||
},
|
||||
|
||||
async update(payload) {
|
||||
return request("/api/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async changePassword(payload) {
|
||||
return request("/api/profile/change-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async mfaSetup(password) {
|
||||
return request("/api/profile/mfa/setup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
},
|
||||
|
||||
async mfaEnable(code) {
|
||||
return request("/api/profile/mfa/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
},
|
||||
|
||||
async mfaDisable(payload) {
|
||||
return request("/api/profile/mfa/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
return request("/api/profile/sessions");
|
||||
},
|
||||
|
||||
async revokeSession(id) {
|
||||
return request(`/api/profile/sessions/${id}/revoke`, {
|
||||
method: "POST"
|
||||
});
|
||||
},
|
||||
|
||||
async revokeAllSessions() {
|
||||
return request("/api/profile/sessions/revoke-all", {
|
||||
method: "POST"
|
||||
});
|
||||
},
|
||||
|
||||
async requestPasswordResetToken() {
|
||||
return request("/api/profile/password-reset/request", {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const system = {
|
||||
async publicSite() {
|
||||
return request("/api/system/public/site");
|
||||
},
|
||||
|
||||
async getBranding() {
|
||||
return request("/api/system/branding");
|
||||
},
|
||||
|
||||
async saveBranding(payload) {
|
||||
return request("/api/system/branding", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async getSubscriptionPolicy() {
|
||||
return request("/api/system/subscription-policy");
|
||||
},
|
||||
|
||||
async saveSubscriptionPolicy(payload) {
|
||||
return request("/api/system/subscription-policy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async startTrial(tenantId, payload) {
|
||||
return request(`/api/system/trials/${tenantId}/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async expireTrials() {
|
||||
return request("/api/system/trials/expire", {
|
||||
method: "POST"
|
||||
});
|
||||
},
|
||||
|
||||
async listCmsPages(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (typeof params.include_drafts === "boolean") {
|
||||
query.set("include_drafts", String(params.include_drafts));
|
||||
}
|
||||
const qs = query.toString();
|
||||
return request(`/api/system/cms/pages${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
async createCmsPage(payload) {
|
||||
return request("/api/system/cms/pages", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async updateCmsPage(id, payload) {
|
||||
return request(`/api/system/cms/pages/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async deleteCmsPage(id) {
|
||||
return request(`/api/system/cms/pages/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
},
|
||||
|
||||
async listNavigationItems() {
|
||||
return request("/api/system/cms/navigation");
|
||||
},
|
||||
|
||||
async createNavigationItem(payload) {
|
||||
return request("/api/system/cms/navigation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async updateNavigationItem(id, payload) {
|
||||
return request(`/api/system/cms/navigation/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload ?? {})
|
||||
});
|
||||
},
|
||||
|
||||
async deleteNavigationItem(id) {
|
||||
return request(`/api/system/cms/navigation/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const operations = {
|
||||
async listTasks(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
@@ -1116,6 +1332,9 @@ const clientArea = {
|
||||
|
||||
export const appClient = {
|
||||
auth,
|
||||
adminUsers,
|
||||
profile,
|
||||
system,
|
||||
entities,
|
||||
dashboard,
|
||||
monitoring,
|
||||
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Users
|
||||
UserCircle2,
|
||||
Users,
|
||||
Wrench
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appClient } from "@/api/appClient";
|
||||
import { navigationGroups } from "./nav-config";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
|
||||
const iconMap = {
|
||||
dashboard: LayoutDashboard,
|
||||
@@ -38,13 +41,17 @@ const iconMap = {
|
||||
client: Users,
|
||||
billing: CreditCard,
|
||||
rbac: Shield,
|
||||
profile: UserCircle2,
|
||||
system: Wrench,
|
||||
settings: Settings
|
||||
};
|
||||
|
||||
export default function Sidebar() {
|
||||
const location = useLocation();
|
||||
const { appPublicSettings } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const brandName = appPublicSettings?.branding?.app_name || "ProxPanel Cloud";
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === "/") return location.pathname === "/";
|
||||
@@ -65,7 +72,7 @@ export default function Sidebar() {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -120,7 +127,9 @@ export default function Sidebar() {
|
||||
|
||||
<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"
|
||||
>
|
||||
<LogOut className="h-[17px] w-[17px] shrink-0" />
|
||||
|
||||
@@ -35,6 +35,8 @@ export const navigationGroups = [
|
||||
{ path: "/client", label: "Client Area", iconKey: "client" },
|
||||
{ path: "/billing", label: "Billing", iconKey: "billing" },
|
||||
{ path: "/rbac", label: "RBAC", iconKey: "rbac" },
|
||||
{ path: "/profile", label: "Profile", iconKey: "profile" },
|
||||
{ path: "/system", label: "System", iconKey: "system" },
|
||||
{ path: "/settings", label: "Settings", iconKey: "settings" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,22 @@ export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
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 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 () => {
|
||||
try {
|
||||
setIsLoadingAuth(true);
|
||||
@@ -33,15 +46,19 @@ export const AuthProvider = ({ children }) => {
|
||||
checkAppState();
|
||||
}, [checkAppState]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPublicSettings();
|
||||
}, [loadPublicSettings]);
|
||||
|
||||
const logout = useCallback((shouldRedirect = true) => {
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setAuthError({ type: "auth_required", message: "Logged out" });
|
||||
|
||||
if (shouldRedirect) {
|
||||
appClient.auth.logout("/");
|
||||
void appClient.auth.logout("/");
|
||||
} else {
|
||||
appClient.auth.logout();
|
||||
void appClient.auth.logout();
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -57,10 +74,11 @@ export const AuthProvider = ({ children }) => {
|
||||
isLoadingAuth,
|
||||
isLoadingPublicSettings,
|
||||
authError,
|
||||
appPublicSettings: null,
|
||||
appPublicSettings,
|
||||
logout,
|
||||
navigateToLogin,
|
||||
checkAppState
|
||||
checkAppState,
|
||||
loadPublicSettings
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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 { FileText, Search, Filter } from "lucide-react";
|
||||
import PageHeader from "../components/shared/PageHeader";
|
||||
import StatusBadge from "../components/shared/StatusBadge";
|
||||
import EmptyState from "../components/shared/EmptyState";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
@@ -15,31 +35,58 @@ export default function AuditLogs() {
|
||||
const [severityFilter, setSeverityFilter] = useState("all");
|
||||
|
||||
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 matchSearch = (l.action || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||
(l.actor_email || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||
(l.resource_name || "").toLowerCase().includes(search.toLowerCase());
|
||||
const matchSeverity = severityFilter === "all" || l.severity === severityFilter;
|
||||
const filtered = logs.filter((item) => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
const matchSearch =
|
||||
!needle ||
|
||||
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;
|
||||
});
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input placeholder="Search logs..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9 bg-card border-border" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search action, actor, resource, details..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={severityFilter} onValueChange={setSeverityFilter}>
|
||||
<SelectTrigger className="w-[160px] bg-card border-border">
|
||||
<Filter className="w-3.5 h-3.5 mr-2 text-muted-foreground" /><SelectValue />
|
||||
<SelectTrigger className="w-[170px]">
|
||||
<Filter className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Severity</SelectItem>
|
||||
@@ -52,30 +99,45 @@ export default function AuditLogs() {
|
||||
</div>
|
||||
|
||||
{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="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[980px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
{["Time", "Severity", "Action", "Resource", "Actor", "Details"].map(h => (
|
||||
<th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th>
|
||||
{["Time", "Severity", "Action", "Resource", "Actor", "Details"].map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map(log => (
|
||||
<tr key={log.id} className="hover:bg-muted/30 transition-colors">
|
||||
<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="px-4 py-3"><StatusBadge status={log.severity} /></td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-foreground">{log.action}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-foreground">{log.resource_name || "—"}</span>
|
||||
<p className="text-[11px] text-muted-foreground capitalize">{log.resource_type}</p>
|
||||
{filtered.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-muted/30">
|
||||
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{log.created_date ? moment(log.created_date).format("MMM D, HH:mm:ss") : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={log.severity} />
|
||||
</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 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>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -18,7 +18,7 @@ function safeRedirect(rawNext) {
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { checkAppState } = useAuth();
|
||||
const { checkAppState, appPublicSettings } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const params = useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
@@ -26,7 +26,12 @@ export default function Login() {
|
||||
|
||||
const [email, setEmail] = 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 brandName = appPublicSettings?.branding?.app_name || "ProxPanel Cloud";
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -34,10 +39,21 @@ export default function Login() {
|
||||
|
||||
try {
|
||||
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();
|
||||
navigate(redirectTo, { replace: true });
|
||||
} 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({
|
||||
title: "Login failed",
|
||||
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]">
|
||||
<section className="hidden bg-gradient-to-br from-blue-700 to-sky-700 p-10 text-white md:flex md:flex-col md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-blue-100">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>
|
||||
<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.
|
||||
@@ -105,6 +121,42 @@ export default function Login() {
|
||||
required
|
||||
/>
|
||||
</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}>
|
||||
{submitting ? "Signing In..." : "Sign In"}
|
||||
</Button>
|
||||
|
||||
613
src/pages/Profile.jsx
Normal file
613
src/pages/Profile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +1,432 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { appClient } from "@/api/appClient";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Shield, Users } from "lucide-react";
|
||||
import { appClient } from "@/api/appClient";
|
||||
import PageHeader from "../components/shared/PageHeader";
|
||||
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 { motion } from "framer-motion";
|
||||
|
||||
const defaultRoles = [
|
||||
{
|
||||
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 roleOptions = ["SUPER_ADMIN", "TENANT_ADMIN", "OPERATOR", "VIEWER"];
|
||||
|
||||
const allPermissions = [
|
||||
{ group: "Virtual Machines", items: ["vm:create", "vm:read", "vm:update", "vm:delete", "vm:start", "vm:stop"] },
|
||||
{ group: "Nodes", items: ["node:manage"] },
|
||||
{ group: "Tenants", items: ["tenant:manage"] },
|
||||
{ group: "Billing", items: ["billing:manage", "billing:read"] },
|
||||
{ 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"] },
|
||||
];
|
||||
function roleLabel(value) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
export default function RBAC() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
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 (
|
||||
<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>
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">Roles</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{defaultRoles.map((role, i) => (
|
||||
<motion.div key={role.name} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
|
||||
className="surface-card p-5 hover:border-primary/20 transition-all">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-9 h-9 rounded-lg ${role.bg} flex items-center justify-center`}>
|
||||
<Shield className={`w-4.5 h-4.5 ${role.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">{role.name}</h3>
|
||||
<p className="text-[11px] text-muted-foreground">{role.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="surface-card p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Role Catalog</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{roles.map((role) => (
|
||||
<div key={role.role} className="rounded-lg border border-border bg-background/70 p-3">
|
||||
<p className="text-sm font-semibold text-foreground">{role.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{role.scope}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{role.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{role.permissions.map(p => (
|
||||
<span key={p} className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground font-mono">{p}</span>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||
<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>
|
||||
|
||||
{/* Permission Matrix */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground mb-3">Permission Matrix</h2>
|
||||
<div className="surface-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<section className="surface-card overflow-hidden">
|
||||
<div className="border-b border-border bg-muted/40 px-4 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Users ({users.length})</h3>
|
||||
<Input
|
||||
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>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">Permission</th>
|
||||
{defaultRoles.map(r => (
|
||||
<th key={r.name} className="text-center text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{r.name}</th>
|
||||
<tr className="border-b border-border bg-muted/20">
|
||||
{["Name", "Email", "Role", "Tenant", "MFA", "Status", "Actions"].map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{allPermissions.map(group => (
|
||||
group.items.map((perm, j) => (
|
||||
<tr key={perm} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
{j === 0 && <p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">{group.group}</p>}
|
||||
<span className="text-xs font-mono text-foreground">{perm}</span>
|
||||
</td>
|
||||
{defaultRoles.map(r => (
|
||||
<td key={r.name} className="text-center px-4 py-2.5">
|
||||
{r.permissions.includes(perm) ? (
|
||||
<span className="inline-block w-5 h-5 rounded-md bg-primary/20 text-primary text-xs leading-5">✓</span>
|
||||
) : (
|
||||
<span className="inline-block w-5 h-5 rounded-md bg-muted text-muted-foreground/30 text-xs leading-5">—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3 text-sm text-foreground">{user.full_name || "-"}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Select
|
||||
value={user.role}
|
||||
onValueChange={(value) => handleUserPatch(user.id, { role: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{roleLabel(role)}
|
||||
</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>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
895
src/pages/SystemManagement.jsx
Normal file
895
src/pages/SystemManagement.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { Plus, Building2, Search } from "lucide-react";
|
||||
import PageHeader from "../components/shared/PageHeader";
|
||||
import StatusBadge from "../components/shared/StatusBadge";
|
||||
import EmptyState from "../components/shared/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
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() {
|
||||
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();
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
const loadData = async () => { setTenants(await appClient.entities.Tenant.list("-created_date")); setLoading(false); };
|
||||
const [tenants, setTenants] = useState([]);
|
||||
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 () => {
|
||||
if (!form.name || !form.owner_email) return;
|
||||
setCreating(true);
|
||||
await appClient.entities.Tenant.create({ ...form, status: "active", slug: form.name.toLowerCase().replace(/\s+/g, "-"), balance: 0, member_emails: [] });
|
||||
await loadData();
|
||||
setShowCreate(false);
|
||||
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 });
|
||||
setCreating(false);
|
||||
toast({ title: "Tenant Created", description: form.name });
|
||||
};
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const payload = await appClient.entities.Tenant.list("-created_date", 500);
|
||||
setTenants(payload || []);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Load failed",
|
||||
description: error?.message || "Unable to load tenants.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (t) => {
|
||||
await appClient.entities.Tenant.delete(t.id);
|
||||
await loadData();
|
||||
toast({ title: "Tenant Deleted", description: t.name, variant: "destructive" });
|
||||
};
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 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" />
|
||||
<div className="relative max-w-sm">
|
||||
<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={(event) => setSearch(event.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState icon={Building2} title="No Tenants" description="Create your first tenant organization."
|
||||
action={<Button onClick={() => setShowCreate(true)} variant="outline" className="gap-2"><Plus className="w-4 h-4" /> New Tenant</Button>} />
|
||||
{filteredTenants.length === 0 ? (
|
||||
<EmptyState
|
||||
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">
|
||||
{filtered.map((t, i) => (
|
||||
<motion.div key={t.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.04 }}
|
||||
className="surface-card p-5 hover:border-primary/20 transition-all group">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredTenants.map((tenant, index) => (
|
||||
<motion.div
|
||||
key={tenant.id}
|
||||
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="w-10 h-10 rounded-lg bg-accent/30 flex items-center justify-center">
|
||||
<Building2 className="w-5 h-5 text-accent" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/30">
|
||||
<Building2 className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">{t.name}</h3>
|
||||
<p className="text-[11px] text-muted-foreground">{t.owner_email}</p>
|
||||
<h3 className="text-sm font-semibold text-foreground">{tenant.name}</h3>
|
||||
<p className="text-[11px] text-muted-foreground">{tenant.owner_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={t.status} />
|
||||
<StatusBadge status={tenant.status} />
|
||||
</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>VMs: <span className="text-foreground font-medium">{t.vm_limit || 0} max</span></div>
|
||||
<div>Balance: <span className="text-foreground font-medium">{t.currency} {(t.balance || 0).toLocaleString()}</span></div>
|
||||
<div>Payment: <span className="text-foreground font-medium capitalize">{t.payment_provider || "—"}</span></div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border py-3 text-xs text-muted-foreground">
|
||||
<div>
|
||||
Plan:{" "}
|
||||
<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 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>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent className="bg-card border-border max-w-lg">
|
||||
<DialogHeader><DialogTitle>Create Tenant</DialogTitle></DialogHeader>
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTenant ? "Update Tenant" : "Create Tenant"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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><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>
|
||||
<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 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><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><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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<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 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><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 className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !form.name || !form.owner_email} className="bg-primary text-primary-foreground">{creating ? "Creating..." : "Create Tenant"}</Button>
|
||||
<Button variant="outline" onClick={() => setShowDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form.name || !form.owner_email}>
|
||||
{saving ? "Saving..." : editingTenant ? "Update Tenant" : "Create Tenant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user