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