mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
1044 lines
39 KiB
TypeScript
1044 lines
39 KiB
TypeScript
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:<actionId>" 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<ManageTabId>("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<EditorState>(null);
|
||
const [editorTab, setEditorTab] = useState("general");
|
||
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
|
||
hostProtocolFlags(null),
|
||
);
|
||
const [confirmDialog, setConfirmDialog] = useState<{
|
||
message: string;
|
||
onConfirm: () => void;
|
||
} | null>(null);
|
||
|
||
const [hosts, setHosts] = useState<Host[]>([]);
|
||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||
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<string | null>(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 = (
|
||
<div className="flex items-start gap-2.5 p-3 mt-2 border border-border bg-muted/20 text-xs text-muted-foreground">
|
||
<Lock className="size-3.5 shrink-0 mt-0.5" />
|
||
<span>{t("admin.dataLockedNotice", { username: user.username })}</span>
|
||
</div>
|
||
);
|
||
|
||
const sectionHeading = (label: string) => (
|
||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||
{label}
|
||
</span>
|
||
);
|
||
|
||
// 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<string, boolean>,
|
||
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
|
||
: makeCredentialTabs(t);
|
||
|
||
return (
|
||
<div className="flex flex-col flex-1 min-h-0">
|
||
<div className="flex flex-col shrink-0 border-b border-border">
|
||
<button
|
||
onClick={() => {
|
||
setEditor(null);
|
||
setEditorTab("general");
|
||
}}
|
||
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
|
||
>
|
||
<ArrowLeft className="size-3.5 shrink-0" />
|
||
<span>
|
||
{t("admin.manageEditorBack", { username: user.username })}
|
||
</span>
|
||
</button>
|
||
<TabStrip
|
||
tabs={tabs}
|
||
activeTab={editorTab}
|
||
onTabChange={setEditorTab}
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||
{isHost ? (
|
||
<HostEditor
|
||
key={editor.host ? editor.host.id : "new-host"}
|
||
host={editor.host}
|
||
activeTab={editorTab}
|
||
onBack={() => {
|
||
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}
|
||
/>
|
||
) : (
|
||
<CredentialEditorView
|
||
key={editor.credential ? editor.credential.id : "new-cred"}
|
||
credential={editor.credential}
|
||
activeTab={editorTab}
|
||
existingFolders={Array.from(
|
||
new Set(
|
||
credentials
|
||
.map((c) => c.folder)
|
||
.filter((f): f is string => !!f),
|
||
),
|
||
).sort()}
|
||
onBack={() => {
|
||
setEditor(null);
|
||
setEditorTab("general");
|
||
}}
|
||
onSave={() => {
|
||
setEditor(null);
|
||
setEditorTab("general");
|
||
reloadCredentials();
|
||
}}
|
||
adminTargetUserId={user.id}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||
{/* Back bar */}
|
||
<div className="flex flex-col shrink-0 border-b border-border">
|
||
<button
|
||
onClick={onBack}
|
||
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
|
||
>
|
||
<ArrowLeft className="size-3.5 shrink-0" />
|
||
<span>{t("admin.backToUsers")}</span>
|
||
<span className="ml-auto flex items-center gap-1.5 min-w-0">
|
||
<span
|
||
className="font-semibold text-foreground truncate max-w-[160px]"
|
||
title={user.username}
|
||
>
|
||
{user.username}
|
||
</span>
|
||
{user.isAdmin && (
|
||
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
||
{t("admin.adminBadge")}
|
||
</span>
|
||
)}
|
||
{!dataUnlocked && (
|
||
<span className="flex items-center gap-0.5 text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
|
||
<Lock className="size-2.5" />
|
||
{t("admin.dataLockedBadge")}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<TabStrip
|
||
tabs={manageTabs.map((tab) => ({ ...tab, icon: null }))}
|
||
activeTab={activeTab}
|
||
onTabChange={(id) => setActiveTab(id as ManageTabId)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||
{activeTab === "account" && (
|
||
<>
|
||
{/* Password reset */}
|
||
<div className="flex flex-col gap-2">
|
||
{sectionHeading(t("admin.resetPasswordTitle"))}
|
||
{user.isOidc && !user.passwordHash ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("admin.resetPasswordOidcOnly")}
|
||
</span>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<PasswordInput
|
||
className="h-8 text-xs pr-8 flex-1"
|
||
placeholder={t("admin.resetPasswordPlaceholder")}
|
||
value={resetPassword}
|
||
onChange={(e) => setResetPassword(e.target.value)}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand shrink-0"
|
||
disabled={resetLoading || !resetPassword}
|
||
onClick={() => handleResetPassword()}
|
||
>
|
||
{resetLoading
|
||
? t("admin.resetPasswordWorking")
|
||
: t("admin.resetPasswordBtn")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<AdminSecondFactorsSection
|
||
userId={user.id}
|
||
username={user.username}
|
||
heading={sectionHeading(t("admin.secondFactorsSectionTitle"))}
|
||
requestConfirm={(message, onConfirm) =>
|
||
setConfirmDialog({ message, onConfirm })
|
||
}
|
||
onReset={onSecondFactorsReset}
|
||
/>
|
||
|
||
{/* Roles */}
|
||
<div className="flex flex-col gap-2">
|
||
{sectionHeading(t("admin.userRoles"))}
|
||
{userRoles.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{userRoles.map((ur) => {
|
||
const roleInfo = roles.find((r) => r.id === ur.roleId);
|
||
const isSystem = roleInfo?.isSystem ?? false;
|
||
return (
|
||
<span
|
||
key={ur.roleId}
|
||
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||
>
|
||
{ur.roleDisplayName}
|
||
{!isSystem && (
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
await removeRoleFromUser(user.id, ur.roleId);
|
||
setUserRoles((prev) =>
|
||
prev.filter((r) => r.roleId !== ur.roleId),
|
||
);
|
||
} catch {
|
||
toast.error(t("admin.removeRoleFailed"));
|
||
}
|
||
}}
|
||
className="hover:text-destructive ml-0.5"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{roles
|
||
.filter(
|
||
(r) =>
|
||
!r.isSystem &&
|
||
!userRoles.some((ur) => ur.roleId === r.id),
|
||
)
|
||
.map((r) => (
|
||
<button
|
||
key={r.id}
|
||
onClick={async () => {
|
||
try {
|
||
await assignRoleToUser(user.id, r.id);
|
||
setUserRoles((prev) => [
|
||
...prev,
|
||
{
|
||
userId: user.id,
|
||
roleId: r.id,
|
||
roleName: r.name,
|
||
roleDisplayName: r.displayName,
|
||
grantedBy: "",
|
||
grantedByUsername: "",
|
||
grantedAt: new Date().toISOString(),
|
||
},
|
||
]);
|
||
} catch {
|
||
toast.error(t("admin.assignRoleFailed"));
|
||
}
|
||
}}
|
||
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
|
||
>
|
||
+ {r.displayName}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* API keys */}
|
||
<div className="flex flex-col gap-2">
|
||
{sectionHeading(t("admin.manageApiKeys"))}
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
className="h-8 text-xs flex-1"
|
||
placeholder={t("admin.apiKeyNamePlaceholder")}
|
||
value={newKeyName}
|
||
onChange={(e) => setNewKeyName(e.target.value)}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand shrink-0"
|
||
disabled={newKeyLoading || !newKeyName.trim()}
|
||
onClick={handleCreateApiKey}
|
||
>
|
||
<Plus className="size-3" />
|
||
{t("admin.createKey")}
|
||
</Button>
|
||
</div>
|
||
{createdKeyToken && (
|
||
<div className="flex flex-col gap-1 p-2 border border-accent-brand/30 bg-accent-brand/5">
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{t("admin.apiKeyCopyNotice")}
|
||
</span>
|
||
<code className="text-[10px] font-mono break-all text-accent-brand">
|
||
{createdKeyToken}
|
||
</code>
|
||
</div>
|
||
)}
|
||
{apiKeys.length === 0 ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("admin.noApiKeysForUser")}
|
||
</span>
|
||
) : (
|
||
apiKeys.map((key) => (
|
||
<div
|
||
key={key.id}
|
||
className="flex items-center justify-between py-1.5 border-b border-border last:border-0"
|
||
>
|
||
<div className="flex flex-col gap-0.5 min-w-0">
|
||
<span className="text-xs font-semibold truncate">
|
||
{key.name}
|
||
</span>
|
||
<span className="text-[10px] font-mono text-muted-foreground">
|
||
{key.tokenPrefix}…
|
||
</span>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
|
||
onClick={async () => {
|
||
try {
|
||
await deleteApiKey(key.id);
|
||
setApiKeys((prev) =>
|
||
prev.filter((k) => k.id !== key.id),
|
||
);
|
||
} catch {
|
||
toast.error(t("admin.apiKeyDeleteFailed"));
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Data export */}
|
||
<div className="flex flex-col gap-2">
|
||
{sectionHeading(t("admin.exportUserData"))}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("admin.exportUserDataDesc")}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-[10px] shrink-0 ml-3"
|
||
disabled={exportLoading || !dataUnlocked}
|
||
onClick={handleExport}
|
||
>
|
||
<Download className="size-3" />
|
||
{exportLoading ? t("admin.exporting") : t("admin.export")}
|
||
</Button>
|
||
</div>
|
||
{!dataUnlocked && lockedNotice}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "hosts" &&
|
||
(!dataUnlocked ? (
|
||
lockedNotice
|
||
) : (
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{t("admin.hostsCount", { count: hosts.length })}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-foreground"
|
||
onClick={reloadHosts}
|
||
>
|
||
<RefreshCw className="size-3" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||
onClick={() => {
|
||
setEditorProtocols(hostProtocolFlags(null));
|
||
setEditorTab("general");
|
||
setEditor({ kind: "host", host: null });
|
||
}}
|
||
>
|
||
<Plus className="size-3" />
|
||
{t("admin.addHostForUser")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{!loading && hosts.length === 0 && (
|
||
<span className="text-xs text-muted-foreground py-3">
|
||
{t("admin.noHostsForUser")}
|
||
</span>
|
||
)}
|
||
{hosts.map((host) => (
|
||
<div
|
||
key={host.id}
|
||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
||
>
|
||
<div className="flex flex-col gap-0.5 min-w-0">
|
||
<span className="text-xs font-semibold truncate max-w-[180px]">
|
||
{host.name || host.ip}
|
||
</span>
|
||
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||
{host.username ? `${host.username}@` : ""}
|
||
{host.ip}:{host.port}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-0.5 shrink-0">
|
||
{onOpenHostTab && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-accent-brand"
|
||
title={t("admin.connectToHost")}
|
||
onClick={() => onOpenHostTab(host)}
|
||
>
|
||
<SquareTerminal className="size-3" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-foreground"
|
||
onClick={() => {
|
||
setEditorProtocols(hostProtocolFlags(host));
|
||
setEditorTab("general");
|
||
setEditor({ kind: "host", host });
|
||
}}
|
||
>
|
||
<Pencil className="size-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-destructive"
|
||
onClick={() =>
|
||
setConfirmDialog({
|
||
message: t("admin.deleteHostConfirm", {
|
||
name: host.name || host.ip,
|
||
username: user.username,
|
||
}),
|
||
onConfirm: async () => {
|
||
try {
|
||
await adminDeleteUserHost(
|
||
user.id,
|
||
Number(host.id),
|
||
);
|
||
setHosts((prev) =>
|
||
prev.filter((h) => h.id !== host.id),
|
||
);
|
||
toast.success(t("admin.hostDeletedSuccess"));
|
||
} catch (e) {
|
||
toast.error(
|
||
apiErrorMessage(e, t("admin.hostDeleteFailed")),
|
||
);
|
||
}
|
||
},
|
||
})
|
||
}
|
||
>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
|
||
{activeTab === "credentials" &&
|
||
(!dataUnlocked ? (
|
||
lockedNotice
|
||
) : (
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{t("admin.credentialsCount", { count: credentials.length })}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-foreground"
|
||
onClick={reloadCredentials}
|
||
>
|
||
<RefreshCw className="size-3" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||
onClick={() => {
|
||
setEditorTab("general");
|
||
setEditor({ kind: "credential", credential: null });
|
||
}}
|
||
>
|
||
<Plus className="size-3" />
|
||
{t("admin.addCredentialForUser")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{!loading && credentials.length === 0 && (
|
||
<span className="text-xs text-muted-foreground py-3">
|
||
{t("admin.noCredentialsForUser")}
|
||
</span>
|
||
)}
|
||
{credentials.map((cred) => (
|
||
<div
|
||
key={cred.id}
|
||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
||
>
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
|
||
<div className="flex flex-col gap-0.5 min-w-0">
|
||
<span className="text-xs font-semibold truncate max-w-[180px]">
|
||
{cred.name}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground truncate">
|
||
{cred.username || "-"} · {cred.type}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-0.5 shrink-0">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-foreground"
|
||
onClick={() => {
|
||
setEditorTab("general");
|
||
setEditor({ kind: "credential", credential: cred });
|
||
}}
|
||
>
|
||
<Pencil className="size-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-destructive"
|
||
onClick={() =>
|
||
setConfirmDialog({
|
||
message: t("admin.deleteCredentialConfirm", {
|
||
name: cred.name,
|
||
username: user.username,
|
||
}),
|
||
onConfirm: async () => {
|
||
try {
|
||
await adminDeleteUserCredential(
|
||
user.id,
|
||
Number(cred.id),
|
||
);
|
||
setCredentials((prev) =>
|
||
prev.filter((c) => c.id !== cred.id),
|
||
);
|
||
toast.success(
|
||
t("admin.credentialDeletedSuccess"),
|
||
);
|
||
} catch (e) {
|
||
toast.error(
|
||
apiErrorMessage(
|
||
e,
|
||
t("admin.credentialDeleteFailed"),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
})
|
||
}
|
||
>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
|
||
{activePluginTab &&
|
||
(!dataUnlocked ? (
|
||
lockedNotice
|
||
) : (
|
||
<activePluginTab.component user={user} />
|
||
))}
|
||
|
||
{activeTab === "sessions" && (
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{t("admin.sessionsActive", { count: sessions.length })}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-foreground"
|
||
onClick={reloadSessions}
|
||
>
|
||
<RefreshCw className="size-3" />
|
||
</Button>
|
||
{sessions.length > 0 && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-6 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||
onClick={async () => {
|
||
try {
|
||
await revokeAllUserSessions(user.id);
|
||
setSessions([]);
|
||
toast.success(t("admin.allSessionsRevoked"));
|
||
} catch {
|
||
toast.error(t("admin.revokeSessionsFailed"));
|
||
}
|
||
}}
|
||
>
|
||
{t("admin.revokeAll")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{!loading && sessions.length === 0 && (
|
||
<span className="text-xs text-muted-foreground py-3">
|
||
{t("admin.noSessionsForUser")}
|
||
</span>
|
||
)}
|
||
{sessions.map((session) => (
|
||
<div
|
||
key={session.id}
|
||
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
|
||
>
|
||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||
<span className="text-[10px] text-muted-foreground truncate">
|
||
{session.deviceInfo}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{t("admin.sessionActive", { time: session.lastActiveAt })}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
|
||
onClick={async () => {
|
||
try {
|
||
await revokeSession(session.id);
|
||
setSessions((prev) =>
|
||
prev.filter((s) => s.id !== session.id),
|
||
);
|
||
} catch {
|
||
toast.error(t("admin.revokeSessionFailed"));
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "danger" && (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||
<TriangleAlert className="size-4 text-destructive shrink-0 mt-0.5" />
|
||
<div className="flex flex-col gap-2 flex-1">
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("admin.deleteUserDangerDesc", {
|
||
username: user.username,
|
||
})}
|
||
</span>
|
||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||
{t("admin.deleteSuccessorLabel")}
|
||
<select
|
||
value={successor}
|
||
onChange={(e) => setSuccessor(e.target.value)}
|
||
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
|
||
>
|
||
<option value="me">{t("admin.deleteSuccessorMe")}</option>
|
||
{successorOptions.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.username}
|
||
</option>
|
||
))}
|
||
<option value="none">
|
||
{t("admin.deleteSuccessorNone")}
|
||
</option>
|
||
</select>
|
||
<span>{t("admin.deleteSuccessorDesc")}</span>
|
||
</label>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive self-start"
|
||
disabled={deleteLoading || user.isAdmin}
|
||
onClick={() =>
|
||
setConfirmDialog({
|
||
message: t("admin.deleteUserConfirm", {
|
||
username: user.username,
|
||
}),
|
||
onConfirm: handleDeleteUser,
|
||
})
|
||
}
|
||
>
|
||
<Trash2 className="size-3" />
|
||
{t("admin.deleteUser", { username: user.username })}
|
||
</Button>
|
||
{user.isAdmin && (
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{t("admin.deleteUserAdminBlocked")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Confirm dialog overlay */}
|
||
{confirmDialog && (
|
||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
||
<p className="text-sm text-foreground">{confirmDialog.message}</p>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
onClick={() => setConfirmDialog(null)}
|
||
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
||
>
|
||
{t("common.cancel")}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
confirmDialog.onConfirm();
|
||
setConfirmDialog(null);
|
||
}}
|
||
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
||
>
|
||
{t("common.confirm")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|