import { useEffect, useState } from "react"; import { hostProtocolFlags, type HostProtocols } from "./host-protocols"; import { getUserList } from "@/main-axios"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { ArrowLeft, Download, KeyRound, Lock, Pencil, Plus, RefreshCw, SquareTerminal, Trash2, TriangleAlert, } from "lucide-react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { PasswordInput } from "@/components/password-input"; import { adminGetUserHosts, adminDeleteUserHost, adminGetUserCredentials, adminDeleteUserCredential, adminResetUserPassword, adminExportUserData, getSessions, revokeSession, revokeAllUserSessions, getApiKeys, createApiKey, deleteApiKey, deleteUser, getUserRoles, assignRoleToUser, removeRoleFromUser, } from "@/main-axios"; import { type ApiKey, type CreatedApiKey, type Role, type SSHHostWithStatus, type UserRole, } from "@/main-axios"; import type { Host, Credential } from "@/types/ui-types"; import { CredentialEditorView } from "./CredentialEditorView"; import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection"; import { HostEditor } from "./HostEditor"; import { mapCredentials, sshHostToHost } from "./HostManagerData"; import type { AdminSession, AdminUser } from "./AdminManagementSections"; import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs"; import { useActionSlot } from "@/hooks/use-action-slot"; type ApiErrorLike = { response?: { data?: { error?: string; code?: string; }; }; }; // Core tabs, or "plugin:" for a tab a plugin contributes to the // admin.userTabs slot. type ManageTabId = string; type EditorState = | { kind: "host"; host: Host | null } | { kind: "credential"; credential: Credential | null } | null; function apiErrorCode(error: unknown): string | undefined { return (error as ApiErrorLike).response?.data?.code; } function apiErrorMessage(error: unknown, fallback: string) { return (error as ApiErrorLike).response?.data?.error || fallback; } export function AdminUserManagePanel({ user, roles, onBack, onOpenHostTab, onUserDeleted, onSecondFactorsReset, }: { user: AdminUser; roles: Role[]; onBack: () => void; onOpenHostTab?: (host: Host) => void; onUserDeleted: () => void; onSecondFactorsReset: () => void; }) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("account"); // Who inherits the hosts and credentials when this account goes: // the deleting admin by default, another user, or nobody. const [successor, setSuccessor] = useState<"me" | "none" | string>("me"); const [successorOptions, setSuccessorOptions] = useState< Array<{ id: string; username: string }> >([]); useEffect(() => { getUserList() .then((r) => setSuccessorOptions( r.users .filter((u) => u.userId !== user.id) .map((u) => ({ id: u.userId, username: u.username })), ), ) .catch(() => {}); }, [user.id]); const [editor, setEditor] = useState(null); const [editorTab, setEditorTab] = useState("general"); const [editorProtocols, setEditorProtocols] = useState(() => hostProtocolFlags(null), ); const [confirmDialog, setConfirmDialog] = useState<{ message: string; onConfirm: () => void; } | null>(null); const [hosts, setHosts] = useState([]); const [credentials, setCredentials] = useState([]); const [sessions, setSessions] = useState([]); const [apiKeys, setApiKeys] = useState([]); const [userRoles, setUserRoles] = useState([]); const [loading, setLoading] = useState(true); // Account tab state const [resetPassword, setResetPassword] = useState(""); const [resetLoading, setResetLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false); const [newKeyName, setNewKeyName] = useState(""); const [newKeyLoading, setNewKeyLoading] = useState(false); const [createdKeyToken, setCreatedKeyToken] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); const dataUnlocked = user.dataUnlocked !== false; const reloadHosts = () => { if (!dataUnlocked) return; adminGetUserHosts(user.id) .then((raw) => setHosts(raw.map((h) => sshHostToHost(h as SSHHostWithStatus))), ) .catch(() => {}); }; const reloadCredentials = () => { if (!dataUnlocked) return; adminGetUserCredentials(user.id) .then((res) => setCredentials(mapCredentials(res))) .catch(() => {}); }; const reloadSessions = () => { getSessions() .then(({ sessions: s }) => setSessions(s.filter((session) => session.userId === user.id)), ) .catch(() => {}); }; useEffect(() => { setLoading(true); Promise.allSettled([ dataUnlocked ? adminGetUserHosts(user.id) : Promise.resolve([]), dataUnlocked ? adminGetUserCredentials(user.id) : Promise.resolve([]), getSessions(), getApiKeys(), getUserRoles(user.id), ]) .then(([h, c, sess, keys, ur]) => { if (h.status === "fulfilled" && dataUnlocked) { setHosts( (h.value as SSHHostWithStatus[]).map((raw) => sshHostToHost(raw)), ); } if (c.status === "fulfilled" && dataUnlocked) { setCredentials(mapCredentials(c.value)); } if (sess.status === "fulfilled") { setSessions( sess.value.sessions.filter((session) => session.userId === user.id), ); } if (keys.status === "fulfilled") { setApiKeys(keys.value.apiKeys.filter((k) => k.userId === user.id)); } if (ur.status === "fulfilled") { setUserRoles(ur.value.roles); } }) .finally(() => setLoading(false)); }, [user.id, dataUnlocked]); const pluginTabs = useActionSlot("admin.userTabs").filter( (contribution) => contribution.kind === "component" && contribution.component, ); const activePluginTab = pluginTabs.find( (contribution) => `plugin:${contribution.actionId}` === activeTab, ); const manageTabs: { id: ManageTabId; label: string }[] = [ { id: "account", label: t("admin.manageTabAccount") }, { id: "hosts", label: t("admin.manageTabHosts") }, { id: "credentials", label: t("admin.manageTabCredentials") }, ...pluginTabs.map((contribution) => ({ id: `plugin:${contribution.actionId}`, label: t(contribution.titleKey), })), { id: "sessions", label: t("admin.manageTabSessions") }, { id: "danger", label: t("admin.manageTabDanger") }, ]; async function handleResetPassword(confirmDataWipe = false) { if (resetPassword.length < 6) { toast.error(t("admin.createUserPasswordTooShort")); return; } setResetLoading(true); try { const result = await adminResetUserPassword( user.id, resetPassword, confirmDataWipe, ); setResetPassword(""); toast.success( result.dataWiped ? t("admin.resetPasswordSuccessWiped") : t("admin.resetPasswordSuccess"), ); } catch (e) { if (apiErrorCode(e) === "DATA_WIPE_REQUIRED") { setConfirmDialog({ message: t("admin.resetPasswordConfirmWipe", { username: user.username, }), onConfirm: () => handleResetPassword(true), }); } else { toast.error(apiErrorMessage(e, t("admin.resetPasswordFailed"))); } } finally { setResetLoading(false); } } async function handleExport() { setExportLoading(true); try { const data = await adminExportUserData(user.id); const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json", }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `termix-user-${user.username}-export.json`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); toast.success(t("admin.exportUserDataSuccess")); } catch (e) { toast.error(apiErrorMessage(e, t("admin.exportUserDataFailed"))); } finally { setExportLoading(false); } } async function handleCreateApiKey() { if (!newKeyName.trim()) { toast.error(t("admin.apiKeyNameRequired")); return; } setNewKeyLoading(true); try { const created: CreatedApiKey = await createApiKey( newKeyName.trim(), user.id, ); setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]); setCreatedKeyToken(created.token); setNewKeyName(""); toast.success(t("admin.apiKeyCreatedSuccess", { name: created.name })); } catch (e) { toast.error(apiErrorMessage(e, t("admin.apiKeyCreateFailed"))); } finally { setNewKeyLoading(false); } } async function handleDeleteUser() { setDeleteLoading(true); try { if (successor === "me") await deleteUser(user.username); else await deleteUser(user.username, successor); toast.success(t("admin.deleteUserSuccess", { username: user.username })); onUserDeleted(); } catch (e) { toast.error(apiErrorMessage(e, t("admin.deleteUserFailed"))); } finally { setDeleteLoading(false); } } const lockedNotice = (
{t("admin.dataLockedNotice", { username: user.username })}
); const sectionHeading = (label: string) => ( {label} ); // Full-panel host/credential editor view (mirrors HostManager's editor view) if (editor) { const isHost = editor.kind === "host"; const tabs = isHost ? makeHostTabs( t, editorProtocols as unknown as Record, ).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh) : makeCredentialTabs(t); return (
{isHost ? ( { setEditor(null); setEditorTab("general"); }} onSave={() => { setEditor(null); setEditorTab("general"); reloadHosts(); }} protocols={editorProtocols} onProtocolChange={(p) => setEditorProtocols((prev) => ({ ...prev, ...p })) } onTabChange={setEditorTab} hosts={hosts} credentials={credentials} adminTargetUserId={user.id} /> ) : ( c.folder) .filter((f): f is string => !!f), ), ).sort()} onBack={() => { setEditor(null); setEditorTab("general"); }} onSave={() => { setEditor(null); setEditorTab("general"); reloadCredentials(); }} adminTargetUserId={user.id} /> )}
); } return (
{/* Back bar */}
({ ...tab, icon: null }))} activeTab={activeTab} onTabChange={(id) => setActiveTab(id as ManageTabId)} />
{activeTab === "account" && ( <> {/* Password reset */}
{sectionHeading(t("admin.resetPasswordTitle"))} {user.isOidc && !user.passwordHash ? ( {t("admin.resetPasswordOidcOnly")} ) : (
setResetPassword(e.target.value)} />
)}
setConfirmDialog({ message, onConfirm }) } onReset={onSecondFactorsReset} /> {/* Roles */}
{sectionHeading(t("admin.userRoles"))} {userRoles.length > 0 && (
{userRoles.map((ur) => { const roleInfo = roles.find((r) => r.id === ur.roleId); const isSystem = roleInfo?.isSystem ?? false; return ( {ur.roleDisplayName} {!isSystem && ( )} ); })}
)}
{roles .filter( (r) => !r.isSystem && !userRoles.some((ur) => ur.roleId === r.id), ) .map((r) => ( ))}
{/* API keys */}
{sectionHeading(t("admin.manageApiKeys"))}
setNewKeyName(e.target.value)} />
{createdKeyToken && (
{t("admin.apiKeyCopyNotice")} {createdKeyToken}
)} {apiKeys.length === 0 ? ( {t("admin.noApiKeysForUser")} ) : ( apiKeys.map((key) => (
{key.name} {key.tokenPrefix}…
)) )}
{/* Data export */}
{sectionHeading(t("admin.exportUserData"))}
{t("admin.exportUserDataDesc")}
{!dataUnlocked && lockedNotice}
)} {activeTab === "hosts" && (!dataUnlocked ? ( lockedNotice ) : (
{t("admin.hostsCount", { count: hosts.length })}
{!loading && hosts.length === 0 && ( {t("admin.noHostsForUser")} )} {hosts.map((host) => (
{host.name || host.ip} {host.username ? `${host.username}@` : ""} {host.ip}:{host.port}
{onOpenHostTab && ( )}
))}
))} {activeTab === "credentials" && (!dataUnlocked ? ( lockedNotice ) : (
{t("admin.credentialsCount", { count: credentials.length })}
{!loading && credentials.length === 0 && ( {t("admin.noCredentialsForUser")} )} {credentials.map((cred) => (
{cred.name} {cred.username || "-"} · {cred.type}
))}
))} {activePluginTab && (!dataUnlocked ? ( lockedNotice ) : ( ))} {activeTab === "sessions" && (
{t("admin.sessionsActive", { count: sessions.length })}
{sessions.length > 0 && ( )}
{!loading && sessions.length === 0 && ( {t("admin.noSessionsForUser")} )} {sessions.map((session) => (
{session.deviceInfo} {t("admin.sessionActive", { time: session.lastActiveAt })}
))}
)} {activeTab === "danger" && (
{t("admin.deleteUserDangerDesc", { username: user.username, })} {user.isAdmin && ( {t("admin.deleteUserAdminBlocked")} )}
)}
{/* Confirm dialog overlay */} {confirmDialog && (

{confirmDialog.message}

)}
); }