import React from "react"; import { useSidebar } from "@/components/ui/sidebar"; import { Separator } from "@/components/ui/separator.tsx"; import { Button } from "@/components/ui/button.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; import { Checkbox } from "@/components/ui/checkbox.tsx"; import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { Label } from "@/components/ui/label.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/ui/tabs.tsx"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog.tsx"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table.tsx"; import { Shield, Trash2, Users, Database, Link2, Unlink, Download, Upload, Monitor, Smartphone, Globe, Clock, UserCog, UserPlus, Edit, } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { getAdminOIDCConfig, getRegistrationAllowed, getPasswordLoginAllowed, getUserList, updateRegistrationAllowed, updatePasswordLoginAllowed, updateOIDCConfig, disableOIDCConfig, makeUserAdmin, removeAdminStatus, deleteUser, getUserInfo, getCookie, isElectron, getSessions, revokeSession, revokeAllUserSessions, linkOIDCToPasswordAccount, unlinkOIDCFromPasswordAccount, getUserRoles, assignRoleToUser, removeRoleFromUser, getRoles, type UserRole, type Role, } from "@/ui/main-axios.ts"; import { RoleManagement } from "./RoleManagement.tsx"; import { CreateUserDialog } from "./CreateUserDialog.tsx"; import { UserEditDialog } from "./UserEditDialog.tsx"; interface AdminSettingsProps { isTopbarOpen?: boolean; rightSidebarOpen?: boolean; rightSidebarWidth?: number; } export function AdminSettings({ isTopbarOpen = true, rightSidebarOpen = false, rightSidebarWidth = 400, }: AdminSettingsProps): React.ReactElement { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); const { state: sidebarState } = useSidebar(); const [allowRegistration, setAllowRegistration] = React.useState(true); const [regLoading, setRegLoading] = React.useState(false); const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true); const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false); const [oidcConfig, setOidcConfig] = React.useState({ client_id: "", client_secret: "", issuer_url: "", authorization_url: "", token_url: "", identifier_path: "sub", name_path: "name", scopes: "openid email profile", userinfo_url: "", }); const [oidcLoading, setOidcLoading] = React.useState(false); const [oidcError, setOidcError] = React.useState(null); const [users, setUsers] = React.useState< Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean; password_hash?: string; }> >([]); const [usersLoading, setUsersLoading] = React.useState(false); // New dialog states const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false); const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false); const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{ id: string; username: string; is_admin: boolean; is_oidc: boolean; password_hash?: string; } | null>(null); const [securityInitialized, setSecurityInitialized] = React.useState(true); const [currentUser, setCurrentUser] = React.useState<{ id: string; username: string; is_admin: boolean; is_oidc: boolean; } | null>(null); const [exportLoading, setExportLoading] = React.useState(false); const [importLoading, setImportLoading] = React.useState(false); const [importFile, setImportFile] = React.useState(null); const [exportPassword, setExportPassword] = React.useState(""); const [showPasswordInput, setShowPasswordInput] = React.useState(false); const [importPassword, setImportPassword] = React.useState(""); const [sessions, setSessions] = React.useState< Array<{ id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; jwtToken: string; isRevoked?: boolean; }> >([]); const [sessionsLoading, setSessionsLoading] = React.useState(false); const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false); const [linkOidcUser, setLinkOidcUser] = React.useState<{ id: string; username: string; } | null>(null); const [linkTargetUsername, setLinkTargetUsername] = React.useState(""); const [linkLoading, setLinkLoading] = React.useState(false); const requiresImportPassword = React.useMemo( () => !currentUser?.is_oidc, [currentUser?.is_oidc], ); React.useEffect(() => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) .configuredServerUrl; if (!serverUrl) { return; } } getAdminOIDCConfig() .then((res) => { if (res) setOidcConfig(res); }) .catch((err) => { if (!err.message?.includes("No server configured")) { toast.error(t("admin.failedToFetchOidcConfig")); } }); getUserInfo() .then((info) => { if (info) { setCurrentUser({ id: info.userId, username: info.username, is_admin: info.is_admin, is_oidc: info.is_oidc, }); } }) .catch((err) => { if (!err?.message?.includes("No server configured")) { console.warn("Failed to fetch current user info", err); } }); fetchUsers(); fetchSessions(); }, []); React.useEffect(() => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) .configuredServerUrl; if (!serverUrl) { return; } } getRegistrationAllowed() .then((res) => { if (typeof res?.allowed === "boolean") { setAllowRegistration(res.allowed); } }) .catch((err) => { if (!err.message?.includes("No server configured")) { toast.error(t("admin.failedToFetchRegistrationStatus")); } }); }, []); React.useEffect(() => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) .configuredServerUrl; if (!serverUrl) { return; } } getPasswordLoginAllowed() .then((res) => { if (typeof res?.allowed === "boolean") { setAllowPasswordLogin(res.allowed); } }) .catch((err) => { if (err.code !== "NO_SERVER_CONFIGURED") { toast.error(t("admin.failedToFetchPasswordLoginStatus")); } }); }, []); const fetchUsers = async () => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) .configuredServerUrl; if (!serverUrl) { return; } } setUsersLoading(true); try { const response = await getUserList(); setUsers(response.users); } catch (err) { if (!err.message?.includes("No server configured")) { toast.error(t("admin.failedToFetchUsers")); } } finally { setUsersLoading(false); } }; // New dialog handlers const handleEditUser = (user: (typeof users)[0]) => { setSelectedUserForEdit(user); setUserEditDialogOpen(true); }; const handleCreateUserSuccess = () => { fetchUsers(); setCreateUserDialogOpen(false); }; const handleEditUserSuccess = () => { fetchUsers(); setUserEditDialogOpen(false); setSelectedUserForEdit(null); }; const getAuthTypeDisplay = (user: (typeof users)[0]): string => { if (user.is_oidc && user.password_hash) { return t("admin.dualAuth"); } else if (user.is_oidc) { return t("admin.externalOIDC"); } else { return t("admin.localPassword"); } }; const handleToggleRegistration = async (checked: boolean) => { setRegLoading(true); try { await updateRegistrationAllowed(checked); setAllowRegistration(checked); } finally { setRegLoading(false); } }; const handleTogglePasswordLogin = async (checked: boolean) => { if (!checked) { const hasOIDCConfigured = oidcConfig.client_id && oidcConfig.client_secret && oidcConfig.issuer_url && oidcConfig.authorization_url && oidcConfig.token_url; if (!hasOIDCConfigured) { toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), { duration: 5000, }); return; } confirmWithToast( t("admin.confirmDisablePasswordLogin"), async () => { setPasswordLoginLoading(true); try { await updatePasswordLoginAllowed(checked); setAllowPasswordLogin(checked); if (allowRegistration) { await updateRegistrationAllowed(false); setAllowRegistration(false); toast.success(t("admin.passwordLoginAndRegistrationDisabled")); } else { toast.success(t("admin.passwordLoginDisabled")); } } catch { toast.error(t("admin.failedToUpdatePasswordLoginStatus")); } finally { setPasswordLoginLoading(false); } }, "destructive", ); return; } setPasswordLoginLoading(true); try { await updatePasswordLoginAllowed(checked); setAllowPasswordLogin(checked); } finally { setPasswordLoginLoading(false); } }; const handleOIDCConfigSubmit = async (e: React.FormEvent) => { e.preventDefault(); setOidcLoading(true); setOidcError(null); const required = [ "client_id", "client_secret", "issuer_url", "authorization_url", "token_url", ]; const missing = required.filter( (f) => !oidcConfig[f as keyof typeof oidcConfig], ); if (missing.length > 0) { setOidcError( t("admin.missingRequiredFields", { fields: missing.join(", ") }), ); setOidcLoading(false); return; } try { await updateOIDCConfig(oidcConfig); toast.success(t("admin.oidcConfigurationUpdated")); } catch (err: unknown) { setOidcError( (err as { response?: { data?: { error?: string } } })?.response?.data ?.error || t("admin.failedToUpdateOidcConfig"), ); } finally { setOidcLoading(false); } }; const handleOIDCConfigChange = (field: string, value: string) => { setOidcConfig((prev) => ({ ...prev, [field]: value })); }; const handleDeleteUserQuick = async (username: string) => { confirmWithToast( t("admin.deleteUser", { username }), async () => { try { await deleteUser(username); toast.success(t("admin.userDeletedSuccessfully", { username })); fetchUsers(); } catch { toast.error(t("admin.failedToDeleteUser")); } }, "destructive", ); }; const handleExportDatabase = async () => { if (!showPasswordInput) { setShowPasswordInput(true); return; } if (!exportPassword.trim()) { toast.error(t("admin.passwordRequired")); return; } setExportLoading(true); try { const isDev = !isElectron() && process.env.NODE_ENV === "development" && (window.location.port === "3000" || window.location.port === "5173" || window.location.port === "" || window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); const apiUrl = isElectron() ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export` : isDev ? `http://localhost:30001/database/export` : `${window.location.protocol}//${window.location.host}/database/export`; const response = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ password: exportPassword }), }); if (response.ok) { const blob = await response.blob(); const contentDisposition = response.headers.get("content-disposition"); const filename = contentDisposition?.match(/filename="([^"]+)"/)?.[1] || "termix-export.sqlite"; const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); toast.success(t("admin.databaseExportedSuccessfully")); setExportPassword(""); setShowPasswordInput(false); } else { const error = await response.json(); if (error.code === "PASSWORD_REQUIRED") { toast.error(t("admin.passwordRequired")); } else { toast.error(error.error || t("admin.databaseExportFailed")); } } } catch { toast.error(t("admin.databaseExportFailed")); } finally { setExportLoading(false); } }; const handleImportDatabase = async () => { if (!importFile) { toast.error(t("admin.pleaseSelectImportFile")); return; } if (requiresImportPassword && !importPassword.trim()) { toast.error(t("admin.passwordRequired")); return; } setImportLoading(true); try { const isDev = !isElectron() && process.env.NODE_ENV === "development" && (window.location.port === "3000" || window.location.port === "5173" || window.location.port === "" || window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); const apiUrl = isElectron() ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import` : isDev ? `http://localhost:30001/database/import` : `${window.location.protocol}//${window.location.host}/database/import`; const formData = new FormData(); formData.append("file", importFile); if (requiresImportPassword) { formData.append("password", importPassword); } const response = await fetch(apiUrl, { method: "POST", credentials: "include", body: formData, }); if (response.ok) { const result = await response.json(); if (result.success) { const summary = result.summary; const imported = summary.sshHostsImported + summary.sshCredentialsImported + summary.fileManagerItemsImported + summary.dismissedAlertsImported + (summary.settingsImported || 0); const skipped = summary.skippedItems; const details = []; if (summary.sshHostsImported > 0) details.push(`${summary.sshHostsImported} SSH hosts`); if (summary.sshCredentialsImported > 0) details.push(`${summary.sshCredentialsImported} credentials`); if (summary.fileManagerItemsImported > 0) details.push( `${summary.fileManagerItemsImported} file manager items`, ); if (summary.dismissedAlertsImported > 0) details.push(`${summary.dismissedAlertsImported} alerts`); if (summary.settingsImported > 0) details.push(`${summary.settingsImported} settings`); toast.success( `Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`, ); setImportFile(null); setImportPassword(""); setTimeout(() => { window.location.reload(); }, 1500); } else { toast.error( `${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`, ); } } else { const error = await response.json(); if (error.code === "PASSWORD_REQUIRED") { toast.error(t("admin.passwordRequired")); } else { toast.error(error.error || t("admin.databaseImportFailed")); } } } catch { toast.error(t("admin.databaseImportFailed")); } finally { setImportLoading(false); } }; const fetchSessions = async () => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) .configuredServerUrl; if (!serverUrl) { return; } } setSessionsLoading(true); try { const data = await getSessions(); setSessions(data.sessions || []); } catch (err) { if (!err?.message?.includes("No server configured")) { toast.error(t("admin.failedToFetchSessions")); } } finally { setSessionsLoading(false); } }; const handleRevokeSession = async (sessionId: string) => { const currentJWT = getCookie("jwt"); const currentSession = sessions.find((s) => s.jwtToken === currentJWT); const isCurrentSession = currentSession?.id === sessionId; confirmWithToast( t("admin.confirmRevokeSession"), async () => { try { await revokeSession(sessionId); toast.success(t("admin.sessionRevokedSuccessfully")); if (isCurrentSession) { setTimeout(() => { window.location.reload(); }, 1000); } else { fetchSessions(); } } catch { toast.error(t("admin.failedToRevokeSession")); } }, "destructive", ); }; const handleRevokeAllUserSessions = async (userId: string) => { const isCurrentUser = currentUser?.id === userId; confirmWithToast( t("admin.confirmRevokeAllSessions"), async () => { try { const data = await revokeAllUserSessions(userId); toast.success(data.message || t("admin.sessionsRevokedSuccessfully")); if (isCurrentUser) { setTimeout(() => { window.location.reload(); }, 1000); } else { fetchSessions(); } } catch { toast.error(t("admin.failedToRevokeSessions")); } }, "destructive", ); }; const handleLinkOIDCUser = (user: { id: string; username: string }) => { setLinkOidcUser(user); setLinkTargetUsername(""); setLinkAccountAlertOpen(true); }; const handleLinkSubmit = async () => { if (!linkOidcUser || !linkTargetUsername.trim()) { toast.error("Target username is required"); return; } setLinkLoading(true); try { const result = await linkOIDCToPasswordAccount( linkOidcUser.id, linkTargetUsername.trim(), ); toast.success( result.message || `OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`, ); setLinkAccountAlertOpen(false); setLinkTargetUsername(""); setLinkOidcUser(null); fetchUsers(); fetchSessions(); } catch (error: unknown) { const err = error as { response?: { data?: { error?: string; code?: string } }; }; toast.error(err.response?.data?.error || "Failed to link accounts"); } finally { setLinkLoading(false); } }; const handleUnlinkOIDC = async (userId: string, username: string) => { confirmWithToast( t("admin.unlinkOIDCDescription", { username }), async () => { try { const result = await unlinkOIDCFromPasswordAccount(userId); toast.success( result.message || t("admin.unlinkOIDCSuccess", { username }), ); fetchUsers(); fetchSessions(); } catch (error: unknown) { const err = error as { response?: { data?: { error?: string; code?: string } }; }; toast.error( err.response?.data?.error || t("admin.failedToUnlinkOIDC"), ); } }, "destructive", ); }; const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const bottomMarginPx = 8; const wrapperStyle: React.CSSProperties = { marginLeft: leftMarginPx, marginRight: rightSidebarOpen ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)` : 17, marginTop: topMarginPx, marginBottom: bottomMarginPx, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, transition: "margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear", }; return (

{t("admin.title")}

{t("admin.general")} OIDC {t("admin.users")} Sessions {t("rbac.roles.label")} {t("admin.databaseSecurity")}

{t("admin.userRegistration")}

{t("admin.externalAuthentication")}

{t("admin.configureExternalProvider")}

{!allowPasswordLogin && ( {t("admin.criticalWarning")} {t("admin.oidcRequiredWarning")} )} {oidcError && ( {t("common.error")} {oidcError} )}
handleOIDCConfigChange("client_id", e.target.value) } placeholder={t("placeholders.clientId")} required />
handleOIDCConfigChange("client_secret", e.target.value) } placeholder={t("placeholders.clientSecret")} required />
handleOIDCConfigChange( "authorization_url", e.target.value, ) } placeholder={t("placeholders.authUrl")} required />
handleOIDCConfigChange("issuer_url", e.target.value) } placeholder={t("placeholders.redirectUrl")} required />
handleOIDCConfigChange("token_url", e.target.value) } placeholder={t("placeholders.tokenUrl")} required />
handleOIDCConfigChange( "identifier_path", e.target.value, ) } placeholder={t("placeholders.userIdField")} required />
handleOIDCConfigChange("name_path", e.target.value) } placeholder={t("placeholders.usernameField")} required />
handleOIDCConfigChange("scopes", e.target.value) } placeholder={t("placeholders.scopes")} required />
handleOIDCConfigChange("userinfo_url", e.target.value) } placeholder="https://your-provider.com/application/o/userinfo/" />

{t("admin.userManagement")}

{allowPasswordLogin && ( )}
{usersLoading ? (
{t("admin.loadingUsers")}
) : ( {t("admin.username")} {t("admin.authType")} {t("admin.actions")} {users.map((user) => ( {user.username} {user.is_admin && ( {t("admin.adminBadge")} )} {getAuthTypeDisplay(user)}
{user.is_oidc && !user.password_hash && ( )} {user.is_oidc && user.password_hash && ( )}
))}
)}

{t("admin.sessionManagement")}

{sessionsLoading ? (
{t("admin.loadingSessions")}
) : sessions.length === 0 ? (
{t("admin.noActiveSessions")}
) : ( {t("admin.device")} {t("admin.user")} {t("admin.created")} {t("admin.lastActive")} {t("admin.expires")} {t("admin.actions")} {sessions.map((session) => { const DeviceIcon = session.deviceType === "desktop" ? Monitor : session.deviceType === "mobile" ? Smartphone : Globe; const createdDate = new Date(session.createdAt); const lastActiveDate = new Date(session.lastActiveAt); const expiresDate = new Date(session.expiresAt); const formatDate = (date: Date) => date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); return (
{session.deviceInfo} {session.isRevoked && ( {t("admin.revoked")} )}
{session.username || session.userId} {formatDate(createdDate)} {formatDate(lastActiveDate)} {formatDate(expiresDate)}
{session.username && ( )}
); })}
)}

{t("admin.databaseSecurity")}

{t("admin.export")}

{t("admin.exportDescription")}

{showPasswordInput && (
setExportPassword(e.target.value)} placeholder="Enter your password" onKeyDown={(e) => { if (e.key === "Enter") { handleExportDatabase(); } }} />
)} {showPasswordInput && ( )}

{t("admin.import")}

{t("admin.importDescription")}

setImportFile(e.target.files?.[0] || null) } className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
{importFile && requiresImportPassword && (
setImportPassword(e.target.value)} placeholder="Enter your password" onKeyDown={(e) => { if (e.key === "Enter") { handleImportDatabase(); } }} />
)}
{linkAccountAlertOpen && ( {t("admin.linkOidcToPasswordAccount")} {t("admin.linkOidcToPasswordAccountDescription", { username: linkOidcUser?.username, })}
{t("admin.linkOidcWarningTitle")} {t("admin.linkOidcWarningDescription")}
  • {t("admin.linkOidcActionDeleteUser")}
  • {t("admin.linkOidcActionAddCapability")}
  • {t("admin.linkOidcActionDualAuth")}
setLinkTargetUsername(e.target.value)} placeholder={t("admin.linkTargetUsernamePlaceholder")} disabled={linkLoading} onKeyDown={(e) => { if (e.key === "Enter" && linkTargetUsername.trim()) { handleLinkSubmit(); } }} />
)} {/* New User Management Dialogs */}
); } export default AdminSettings;