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

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

View File

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

View File

@@ -4,11 +4,13 @@ const STORAGE_REFRESH_TOKEN_KEY = "proxpanel_refresh_token";
const resourceMap = {
AuditLog: "audit-logs",
AppTemplate: "app-templates",
AuthSession: "auth-sessions",
ApplicationGroup: "application-groups",
Backup: "backups",
BackupPolicy: "backup-policies",
BackupRestoreTask: "backup-restore-tasks",
BillingPlan: "billing-plans",
CmsPage: "cms-pages",
FirewallRule: "firewall-rules",
Invoice: "invoices",
IpAddressPool: "ip-addresses",
@@ -27,6 +29,7 @@ const resourceMap = {
ProxmoxNode: "nodes",
ProvisionedService: "provisioned-services",
SecurityEvent: "security-events",
SiteNavigationItem: "site-navigation-items",
Tenant: "tenants",
UsageRecord: "usage-records",
User: "users",
@@ -243,8 +246,13 @@ async function request(path, options = {}, hasRetried = false) {
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
const error = new Error(errorBody?.message ?? `Request failed: ${response.status}`);
const error = new Error(
errorBody?.error?.message ??
errorBody?.message ??
`Request failed: ${response.status}`
);
error.status = response.status;
error.code = errorBody?.error?.code;
error.data = errorBody;
throw error;
}
@@ -309,10 +317,15 @@ const entities = new Proxy(
);
const auth = {
async login(email, password) {
async login(email, password, options = {}) {
const payload = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password })
body: JSON.stringify({
email,
password,
mfa_code: options.mfa_code,
recovery_code: options.recovery_code
})
});
if (payload?.token) {
@@ -329,9 +342,21 @@ const auth = {
return request("/api/auth/me");
},
logout(redirectTo) {
setToken(null);
setRefreshToken(null);
async logout(redirectTo) {
const refreshToken = getRefreshToken();
try {
if (getToken()) {
await request("/api/auth/logout", {
method: "POST",
body: JSON.stringify(refreshToken ? { refresh_token: refreshToken } : {})
});
}
} catch {
// Intentionally ignore and continue with local token cleanup.
} finally {
setToken(null);
setRefreshToken(null);
}
if (redirectTo && typeof window !== "undefined") {
window.location.href = redirectTo;
@@ -351,6 +376,197 @@ const auth = {
getRefreshToken
};
const adminUsers = {
async listRoles() {
return request("/api/admin/roles");
},
async listUsers(params = {}) {
const query = new URLSearchParams();
if (params.tenant_id) query.set("tenant_id", params.tenant_id);
if (params.role) query.set("role", params.role);
const qs = query.toString();
return request(`/api/admin/users${qs ? `?${qs}` : ""}`);
},
async createUser(payload) {
return request("/api/admin/users", {
method: "POST",
body: JSON.stringify(payload ?? {})
});
},
async updateUser(id, payload) {
return request(`/api/admin/users/${id}`, {
method: "PATCH",
body: JSON.stringify(payload ?? {})
});
},
async resetPassword(id) {
return request(`/api/admin/users/${id}/reset-password`, {
method: "POST"
});
}
};
const profile = {
async get() {
return request("/api/profile");
},
async update(payload) {
return request("/api/profile", {
method: "PATCH",
body: JSON.stringify(payload ?? {})
});
},
async changePassword(payload) {
return request("/api/profile/change-password", {
method: "POST",
body: JSON.stringify(payload ?? {})
});
},
async mfaSetup(password) {
return request("/api/profile/mfa/setup", {
method: "POST",
body: JSON.stringify({ password })
});
},
async mfaEnable(code) {
return request("/api/profile/mfa/enable", {
method: "POST",
body: JSON.stringify({ code })
});
},
async mfaDisable(payload) {
return request("/api/profile/mfa/disable", {
method: "POST",
body: JSON.stringify(payload ?? {})
});
},
async listSessions() {
return request("/api/profile/sessions");
},
async revokeSession(id) {
return request(`/api/profile/sessions/${id}/revoke`, {
method: "POST"
});
},
async revokeAllSessions() {
return request("/api/profile/sessions/revoke-all", {
method: "POST"
});
},
async requestPasswordResetToken() {
return request("/api/profile/password-reset/request", {
method: "POST"
});
}
};
const system = {
async publicSite() {
return request("/api/system/public/site");
},
async getBranding() {
return request("/api/system/branding");
},
async saveBranding(payload) {
return request("/api/system/branding", {
method: "PUT",
body: JSON.stringify(payload ?? {})
});
},
async getSubscriptionPolicy() {
return request("/api/system/subscription-policy");
},
async saveSubscriptionPolicy(payload) {
return request("/api/system/subscription-policy", {
method: "PUT",
body: JSON.stringify(payload ?? {})
});
},
async startTrial(tenantId, payload) {
return request(`/api/system/trials/${tenantId}/start`, {
method: "POST",
body: JSON.stringify(payload ?? {})
});
},
async expireTrials() {
return request("/api/system/trials/expire", {
method: "POST"
});
},
async listCmsPages(params = {}) {
const query = new URLSearchParams();
if (typeof params.include_drafts === "boolean") {
query.set("include_drafts", String(params.include_drafts));
}
const qs = query.toString();
return request(`/api/system/cms/pages${qs ? `?${qs}` : ""}`);
},
async createCmsPage(payload) {
return request("/api/system/cms/pages", {
method: "POST",
body: JSON.stringify(payload ?? {})
});
},
async updateCmsPage(id, payload) {
return request(`/api/system/cms/pages/${id}`, {
method: "PATCH",
body: JSON.stringify(payload ?? {})
});
},
async deleteCmsPage(id) {
return request(`/api/system/cms/pages/${id}`, {
method: "DELETE"
});
},
async listNavigationItems() {
return request("/api/system/cms/navigation");
},
async createNavigationItem(payload) {
return request("/api/system/cms/navigation", {
method: "POST",
body: JSON.stringify(payload ?? {})
});
},
async updateNavigationItem(id, payload) {
return request(`/api/system/cms/navigation/${id}`, {
method: "PATCH",
body: JSON.stringify(payload ?? {})
});
},
async deleteNavigationItem(id) {
return request(`/api/system/cms/navigation/${id}`, {
method: "DELETE"
});
}
};
const operations = {
async listTasks(params = {}) {
const query = new URLSearchParams();
@@ -1116,6 +1332,9 @@ const clientArea = {
export const appClient = {
auth,
adminUsers,
profile,
system,
entities,
dashboard,
monitoring,

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,613 @@
import { useEffect, useMemo, useState } from "react";
import { KeyRound, Lock, ShieldCheck, UserCircle2 } from "lucide-react";
import { appClient } from "@/api/appClient";
import PageHeader from "../components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString();
}
function prettyJson(value) {
try {
return JSON.stringify(value ?? {}, null, 2);
} catch {
return "{}";
}
}
export default function Profile() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [savingProfile, setSavingProfile] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [loadingMfaSetup, setLoadingMfaSetup] = useState(false);
const [enablingMfa, setEnablingMfa] = useState(false);
const [disablingMfa, setDisablingMfa] = useState(false);
const [requestingResetToken, setRequestingResetToken] = useState(false);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [profile, setProfile] = useState(null);
const [sessions, setSessions] = useState([]);
const [lastResetToken, setLastResetToken] = useState(null);
const [profileForm, setProfileForm] = useState({
full_name: "",
avatar_url: "",
profile_metadata_text: "{}"
});
const [passwordForm, setPasswordForm] = useState({
current_password: "",
new_password: "",
confirm_password: ""
});
const [mfaSetupPassword, setMfaSetupPassword] = useState("");
const [mfaSetupPayload, setMfaSetupPayload] = useState(null);
const [mfaEnableCode, setMfaEnableCode] = useState("");
const [mfaDisableForm, setMfaDisableForm] = useState({
password: "",
code: ""
});
async function loadProfileAndSessions() {
try {
setLoading(true);
const [profilePayload, sessionsPayload] = await Promise.all([
appClient.profile.get(),
appClient.profile.listSessions()
]);
setProfile(profilePayload);
setSessions(sessionsPayload || []);
setProfileForm({
full_name: profilePayload?.full_name || "",
avatar_url: profilePayload?.avatar_url || "",
profile_metadata_text: prettyJson(profilePayload?.profile_metadata)
});
} catch (error) {
toast({
title: "Profile load failed",
description: error?.message || "Unable to load profile information.",
variant: "destructive"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadProfileAndSessions();
}, []);
const activeSessionCount = useMemo(
() => sessions.filter((session) => !session.revoked_at).length,
[sessions]
);
async function refreshSessions() {
try {
setSessionsLoading(true);
const payload = await appClient.profile.listSessions();
setSessions(payload || []);
} catch (error) {
toast({
title: "Session refresh failed",
description: error?.message || "Unable to refresh active sessions.",
variant: "destructive"
});
} finally {
setSessionsLoading(false);
}
}
async function handleSaveProfile() {
try {
setSavingProfile(true);
let parsedMetadata = {};
if (profileForm.profile_metadata_text.trim()) {
parsedMetadata = JSON.parse(profileForm.profile_metadata_text);
}
const payload = await appClient.profile.update({
full_name: profileForm.full_name,
avatar_url: profileForm.avatar_url || undefined,
profile_metadata: parsedMetadata
});
setProfile(payload);
setProfileForm((prev) => ({
...prev,
profile_metadata_text: prettyJson(payload?.profile_metadata)
}));
toast({
title: "Profile updated",
description: "Your profile details were saved successfully."
});
} catch (error) {
toast({
title: "Profile update failed",
description: error?.message || "Could not save profile data.",
variant: "destructive"
});
} finally {
setSavingProfile(false);
}
}
async function handleChangePassword() {
if (passwordForm.new_password !== passwordForm.confirm_password) {
toast({
title: "Password mismatch",
description: "New password and confirmation do not match.",
variant: "destructive"
});
return;
}
try {
setChangingPassword(true);
await appClient.profile.changePassword({
current_password: passwordForm.current_password,
new_password: passwordForm.new_password
});
setPasswordForm({
current_password: "",
new_password: "",
confirm_password: ""
});
toast({
title: "Password changed",
description: "All previous sessions were revoked by policy."
});
await refreshSessions();
} catch (error) {
toast({
title: "Password update failed",
description: error?.message || "Could not change password.",
variant: "destructive"
});
} finally {
setChangingPassword(false);
}
}
async function handleStartMfaSetup() {
try {
setLoadingMfaSetup(true);
const payload = await appClient.profile.mfaSetup(mfaSetupPassword);
setMfaSetupPayload(payload);
setMfaEnableCode("");
toast({
title: "MFA secret generated",
description: "Use your authenticator app, then confirm with a code."
});
} catch (error) {
toast({
title: "MFA setup failed",
description: error?.message || "Could not generate MFA configuration.",
variant: "destructive"
});
} finally {
setLoadingMfaSetup(false);
}
}
async function handleEnableMfa() {
try {
setEnablingMfa(true);
await appClient.profile.mfaEnable(mfaEnableCode);
setMfaSetupPassword("");
setMfaSetupPayload(null);
await loadProfileAndSessions();
toast({
title: "MFA enabled",
description: "Authenticator-based MFA is now active."
});
} catch (error) {
toast({
title: "MFA enable failed",
description: error?.message || "The verification code is invalid.",
variant: "destructive"
});
} finally {
setEnablingMfa(false);
}
}
async function handleDisableMfa() {
try {
setDisablingMfa(true);
await appClient.profile.mfaDisable({
password: mfaDisableForm.password,
code: mfaDisableForm.code || undefined
});
setMfaDisableForm({ password: "", code: "" });
await loadProfileAndSessions();
toast({
title: "MFA disabled",
description: "Multi-factor authentication has been turned off."
});
} catch (error) {
toast({
title: "MFA disable failed",
description: error?.message || "Could not disable MFA for this account.",
variant: "destructive"
});
} finally {
setDisablingMfa(false);
}
}
async function handleRequestResetToken() {
try {
setRequestingResetToken(true);
const payload = await appClient.profile.requestPasswordResetToken();
setLastResetToken(payload);
toast({
title: "Reset token issued",
description: "A short-lived reset token has been generated."
});
} catch (error) {
toast({
title: "Token request failed",
description: error?.message || "Could not generate reset token.",
variant: "destructive"
});
} finally {
setRequestingResetToken(false);
}
}
async function revokeSession(sessionId) {
try {
await appClient.profile.revokeSession(sessionId);
await refreshSessions();
toast({
title: "Session revoked",
description: "The selected session has been revoked."
});
} catch (error) {
toast({
title: "Revoke failed",
description: error?.message || "Could not revoke the session.",
variant: "destructive"
});
}
}
async function revokeAllSessions() {
try {
await appClient.profile.revokeAllSessions();
await refreshSessions();
toast({
title: "Sessions revoked",
description: "All active sessions were revoked."
});
} catch (error) {
toast({
title: "Revoke all failed",
description: error?.message || "Could not revoke all sessions.",
variant: "destructive"
});
}
}
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Profile & Identity"
description="Manage your account profile, security controls, and active authentication sessions."
/>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<section className="space-y-6">
<div className="surface-card p-6">
<div className="mb-4 flex items-center gap-2">
<UserCircle2 className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Profile</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label>Email</Label>
<Input value={profile?.email || ""} disabled className="mt-1" />
</div>
<div>
<Label>Role</Label>
<Input value={profile?.role || ""} disabled className="mt-1" />
</div>
<div>
<Label>Full Name</Label>
<Input
value={profileForm.full_name}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, full_name: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Avatar URL</Label>
<Input
value={profileForm.avatar_url}
onChange={(event) =>
setProfileForm((prev) => ({ ...prev, avatar_url: event.target.value }))
}
className="mt-1"
placeholder="https://example.com/avatar.png"
/>
</div>
<div className="md:col-span-2">
<Label>Profile Metadata (JSON)</Label>
<textarea
value={profileForm.profile_metadata_text}
onChange={(event) =>
setProfileForm((prev) => ({
...prev,
profile_metadata_text: event.target.value
}))
}
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>Created: {formatDate(profile?.created_at)}</span>
<span>Last login: {formatDate(profile?.last_login_at)}</span>
<span>MFA: {profile?.mfa_enabled ? "Enabled" : "Disabled"}</span>
</div>
<Button className="mt-4" onClick={handleSaveProfile} disabled={savingProfile}>
{savingProfile ? "Saving..." : "Save Profile"}
</Button>
</div>
<div className="surface-card p-6">
<div className="mb-4 flex items-center gap-2">
<Lock className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Password Management</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<Label>Current Password</Label>
<Input
type="password"
value={passwordForm.current_password}
onChange={(event) =>
setPasswordForm((prev) => ({
...prev,
current_password: event.target.value
}))
}
className="mt-1"
/>
</div>
<div>
<Label>New Password</Label>
<Input
type="password"
value={passwordForm.new_password}
onChange={(event) =>
setPasswordForm((prev) => ({ ...prev, new_password: event.target.value }))
}
className="mt-1"
/>
</div>
<div>
<Label>Confirm New Password</Label>
<Input
type="password"
value={passwordForm.confirm_password}
onChange={(event) =>
setPasswordForm((prev) => ({
...prev,
confirm_password: event.target.value
}))
}
className="mt-1"
/>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button onClick={handleChangePassword} disabled={changingPassword}>
{changingPassword ? "Updating..." : "Change Password"}
</Button>
<Button
variant="outline"
onClick={handleRequestResetToken}
disabled={requestingResetToken}
>
{requestingResetToken ? "Generating..." : "Generate Reset Token"}
</Button>
</div>
{lastResetToken?.token ? (
<div className="mt-3 rounded-lg border border-border bg-muted/40 p-3">
<p className="text-xs font-semibold text-foreground">Latest Reset Token</p>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{lastResetToken.token}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Expires: {formatDate(lastResetToken.expires_at)}
</p>
</div>
) : null}
</div>
</section>
<section className="space-y-6">
<div className="surface-card p-6">
<div className="mb-4 flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Multi-Factor Authentication</h3>
</div>
{!profile?.mfa_enabled ? (
<div className="space-y-4">
<div>
<Label>Account Password</Label>
<Input
type="password"
value={mfaSetupPassword}
onChange={(event) => setMfaSetupPassword(event.target.value)}
className="mt-1"
/>
</div>
<Button
onClick={handleStartMfaSetup}
disabled={!mfaSetupPassword || loadingMfaSetup}
>
{loadingMfaSetup ? "Preparing..." : "Generate MFA Secret"}
</Button>
{mfaSetupPayload ? (
<div className="space-y-3 rounded-lg border border-border bg-muted/40 p-3">
<div>
<p className="text-xs font-semibold text-foreground">otpauth URI</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground">
{mfaSetupPayload.otpauth_uri}
</p>
</div>
<div>
<p className="text-xs font-semibold text-foreground">Recovery Codes</p>
<div className="mt-1 space-y-1">
{(mfaSetupPayload.recovery_codes || []).map((code) => (
<p
key={code}
className="rounded bg-background px-2 py-1 font-mono text-[11px] text-muted-foreground"
>
{code}
</p>
))}
</div>
</div>
<div>
<Label>Verification Code</Label>
<Input
value={mfaEnableCode}
onChange={(event) => setMfaEnableCode(event.target.value)}
className="mt-1"
placeholder="123456"
/>
</div>
<Button onClick={handleEnableMfa} disabled={!mfaEnableCode || enablingMfa}>
{enablingMfa ? "Enabling..." : "Enable MFA"}
</Button>
</div>
) : null}
</div>
) : (
<div className="space-y-4">
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700">
MFA is currently enabled for this account.
</p>
<div>
<Label>Password</Label>
<Input
type="password"
value={mfaDisableForm.password}
onChange={(event) =>
setMfaDisableForm((prev) => ({
...prev,
password: event.target.value
}))
}
className="mt-1"
/>
</div>
<div>
<Label>MFA Code (optional)</Label>
<Input
value={mfaDisableForm.code}
onChange={(event) =>
setMfaDisableForm((prev) => ({ ...prev, code: event.target.value }))
}
className="mt-1"
placeholder="123456"
/>
</div>
<Button
variant="destructive"
onClick={handleDisableMfa}
disabled={!mfaDisableForm.password || disablingMfa}
>
{disablingMfa ? "Disabling..." : "Disable MFA"}
</Button>
</div>
)}
</div>
<div className="surface-card p-6">
<div className="mb-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Sessions</h3>
</div>
<span className="text-xs text-muted-foreground">
Active: {activeSessionCount}/{sessions.length}
</span>
</div>
<div className="space-y-2">
{sessions.map((session) => (
<div
key={session.id}
className="rounded-lg border border-border bg-background/65 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-foreground">
{session.user_agent || "Unknown agent"}
</p>
<p className="text-[11px] text-muted-foreground">
IP: {session.ip_address || "-"}
</p>
<p className="text-[11px] text-muted-foreground">
Issued: {formatDate(session.issued_at)}
</p>
<p className="text-[11px] text-muted-foreground">
Last used: {formatDate(session.last_used_at)}
</p>
</div>
{session.revoked_at ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
Revoked
</span>
) : (
<Button
variant="outline"
size="sm"
onClick={() => revokeSession(session.id)}
>
Revoke
</Button>
)}
</div>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="outline" onClick={refreshSessions} disabled={sessionsLoading}>
{sessionsLoading ? "Refreshing..." : "Refresh Sessions"}
</Button>
<Button variant="destructive" onClick={revokeAllSessions}>
Revoke All
</Button>
</div>
</div>
</section>
</div>
</div>
);
}

View File

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

View File

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

View File

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