mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { Upload } from "lucide-react";
|
|
import { Button } from "@/components/button";
|
|
import { Select2 } from "@/components/select2";
|
|
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
|
import {
|
|
deleteCredential,
|
|
deployCredentialToHost,
|
|
duplicateCredential,
|
|
getCredentials,
|
|
getSSHHosts,
|
|
renameCredentialFolder,
|
|
updateCredential,
|
|
} from "@/main-axios";
|
|
import { requestManage } from "@/manage/manage-requests";
|
|
import type { Credential, Host } from "@/types/ui-types";
|
|
import type {
|
|
CredentialRowFields,
|
|
CredentialSidebarFilterState,
|
|
CredentialSortKey,
|
|
} from "@/types/credential-sidebar-preferences";
|
|
import { CredentialShareModal } from "./CredentialShareModal";
|
|
import { CredentialSidebarTree } from "./credential-tree";
|
|
import { credentialPassesFilters, sortCredentials } from "./credential-sort";
|
|
import { enabledHostProtocols } from "./host-protocols";
|
|
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
|
|
|
const UNCATEGORIZED = "Uncategorized";
|
|
|
|
/**
|
|
* The credentials sidebar list. Editing happens in the Manage tab; this keeps
|
|
* the tree, its folder moves and the quick actions on each row.
|
|
*/
|
|
export function CredentialList({
|
|
search = "",
|
|
sort = "default",
|
|
arrangeLocked = true,
|
|
filter,
|
|
density = "comfortable",
|
|
trayTrigger = "hover",
|
|
showTags = true,
|
|
rowFields,
|
|
onTagsChange,
|
|
}: {
|
|
search?: string;
|
|
sort?: CredentialSortKey;
|
|
arrangeLocked?: boolean;
|
|
filter?: CredentialSidebarFilterState;
|
|
density?: "comfortable" | "compact";
|
|
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
|
|
showTags?: boolean;
|
|
rowFields?: CredentialRowFields;
|
|
onTagsChange?: (tags: string[]) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const confirm = useConfirm();
|
|
const [hosts, setHosts] = useState<Host[]>([]);
|
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [shareCredential, setShareCredential] = useState<Credential | null>(
|
|
null,
|
|
);
|
|
const [deployTarget, setDeployTarget] = useState<Credential | null>(null);
|
|
const [deployHostId, setDeployHostId] = useState("");
|
|
const [deploying, setDeploying] = useState(false);
|
|
const [editingFolderName, setEditingFolderName] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [editingFolderValue, setEditingFolderValue] = useState("");
|
|
|
|
useEffect(() => {
|
|
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [credentials]);
|
|
|
|
useEffect(() => {
|
|
const reloadHosts = () => {
|
|
getSSHHosts()
|
|
.then((raw) => setHosts(raw.map(sshHostToHost)))
|
|
.catch(() => {});
|
|
};
|
|
const reloadCredentials = () => {
|
|
getCredentials()
|
|
.then((res) => setCredentials(mapCredentials(res)))
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
};
|
|
reloadHosts();
|
|
reloadCredentials();
|
|
window.addEventListener("termix:hosts-changed", reloadHosts);
|
|
window.addEventListener("ssh-hosts:changed", reloadHosts);
|
|
window.addEventListener("termix:credentials-changed", reloadCredentials);
|
|
return () => {
|
|
window.removeEventListener("termix:hosts-changed", reloadHosts);
|
|
window.removeEventListener("ssh-hosts:changed", reloadHosts);
|
|
window.removeEventListener(
|
|
"termix:credentials-changed",
|
|
reloadCredentials,
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
const needle = search.toLowerCase();
|
|
const searched = credentials.filter(
|
|
(c) =>
|
|
c.name.toLowerCase().includes(needle) ||
|
|
c.username.toLowerCase().includes(needle),
|
|
);
|
|
const filtered = sortCredentials(
|
|
filter
|
|
? searched.filter((c) => credentialPassesFilters(c, filter))
|
|
: searched,
|
|
sort,
|
|
);
|
|
const folderNames = Array.from(
|
|
new Set(filtered.map((c) => c.folder || UNCATEGORIZED)),
|
|
).sort();
|
|
const folderTree = folderNames.map((name) => ({
|
|
name,
|
|
children: filtered.filter((c) => (c.folder || UNCATEGORIZED) === name),
|
|
}));
|
|
const usedByCounts = new Map<string, number>();
|
|
for (const cred of filtered) {
|
|
usedByCounts.set(
|
|
cred.id,
|
|
hosts.filter((h) => h.credentialId === cred.id).length,
|
|
);
|
|
}
|
|
|
|
const reload = async () => {
|
|
const res = await getCredentials();
|
|
setCredentials(mapCredentials(res));
|
|
};
|
|
|
|
const renameFolder = async (folder: string, newName: string) => {
|
|
try {
|
|
await renameCredentialFolder(folder, newName);
|
|
await reload();
|
|
toast.success(t("credentials.folderRenamedTo", { name: newName }));
|
|
} catch {
|
|
toast.error(t("credentials.failedToRenameFolder"));
|
|
}
|
|
};
|
|
|
|
const moveToFolder = async (credentialId: string, targetFolder: string) => {
|
|
const cred = credentials.find((c) => c.id === credentialId);
|
|
if (!cred || (cred.folder || UNCATEGORIZED) === targetFolder) return;
|
|
const folderValue = targetFolder === UNCATEGORIZED ? "" : targetFolder;
|
|
try {
|
|
await updateCredential(Number(credentialId), { folder: folderValue });
|
|
setCredentials((prev) =>
|
|
prev.map((c) =>
|
|
c.id === credentialId ? { ...c, folder: folderValue } : c,
|
|
),
|
|
);
|
|
} catch {
|
|
toast.error(t("credentials.failedToMoveCredential"));
|
|
}
|
|
};
|
|
|
|
const clone = async (cred: Credential) => {
|
|
try {
|
|
await duplicateCredential(Number(cred.id), {
|
|
name: t("credentials.clonedCredentialName", { name: cred.name }),
|
|
});
|
|
await reload();
|
|
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
|
toast.success(t("credentials.clonedCredential", { name: cred.name }));
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : null;
|
|
toast.error(msg || t("credentials.failedToCloneCredential"));
|
|
}
|
|
};
|
|
|
|
const remove = async (cred: Credential) => {
|
|
const ok = await confirm({
|
|
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
|
|
description: t("manage.cannotBeUndone"),
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await deleteCredential(Number(cred.id));
|
|
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
|
|
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
|
toast.success(t("credentials.deletedCredential", { name: cred.name }));
|
|
} catch {
|
|
toast.error(t("credentials.failedToDeleteCredential"));
|
|
}
|
|
};
|
|
|
|
const deploy = async () => {
|
|
if (!deployTarget || !deployHostId) return;
|
|
setDeploying(true);
|
|
try {
|
|
await deployCredentialToHost(
|
|
Number(deployTarget.id),
|
|
Number(deployHostId),
|
|
);
|
|
toast.success(t("credentials.keyDeployedSuccess"));
|
|
setDeployTarget(null);
|
|
} catch {
|
|
toast.error(t("credentials.failedToDeployKey"));
|
|
} finally {
|
|
setDeploying(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
{!loading && filtered.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
|
<span className="text-sm font-semibold text-muted-foreground/60">
|
|
{t("credentials.noCredentialsFound")}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-3 h-7 border-accent-brand/40 text-xs text-accent-brand hover:bg-accent-brand/10"
|
|
onClick={() =>
|
|
requestManage({ kind: "credential", credentialId: null })
|
|
}
|
|
>
|
|
{t("credentials.addCredentialBtn")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<CredentialSidebarTree
|
|
folders={folderTree}
|
|
usedByCounts={usedByCounts}
|
|
query=""
|
|
loading={loading}
|
|
arrangeLocked={arrangeLocked}
|
|
density={density}
|
|
trayTrigger={trayTrigger}
|
|
showTags={showTags}
|
|
rowFields={rowFields}
|
|
editingFolderName={editingFolderName}
|
|
editingFolderValue={editingFolderValue}
|
|
onEditingFolderNameChange={setEditingFolderName}
|
|
onEditingFolderValueChange={setEditingFolderValue}
|
|
onRenameFolder={renameFolder}
|
|
onMoveCredentialToFolder={moveToFolder}
|
|
onDeployCredential={(cred) => {
|
|
setDeployHostId("");
|
|
setDeployTarget(cred);
|
|
}}
|
|
onEditCredential={(cred) =>
|
|
requestManage({ kind: "credential", credentialId: cred.id })
|
|
}
|
|
onCloneCredential={clone}
|
|
onDeleteCredential={remove}
|
|
onShareCredential={setShareCredential}
|
|
/>
|
|
)}
|
|
|
|
<CredentialShareModal
|
|
credential={shareCredential}
|
|
onClose={() => setShareCredential(null)}
|
|
/>
|
|
|
|
<InlineView
|
|
open={!!deployTarget}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeployTarget(null);
|
|
}}
|
|
icon={<Upload className="size-4" />}
|
|
title={t("credentials.deployDialogTitle")}
|
|
footer={
|
|
<div className="ml-auto flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeployTarget(null)}
|
|
disabled={deploying}
|
|
>
|
|
{t("hosts.cancelBtn")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
|
disabled={!deployHostId || deploying}
|
|
onClick={() => void deploy()}
|
|
>
|
|
{deploying
|
|
? t("credentials.deployingBtn")
|
|
: t("credentials.deployBtn")}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("credentials.deployDialogDesc", { name: deployTarget?.name })}
|
|
</p>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("credentials.targetHostLabel")}
|
|
</label>
|
|
<Select2
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={deployHostId}
|
|
onChange={(e) => setDeployHostId(e.target.value)}
|
|
>
|
|
<option value="">{t("credentials.selectHostOption")}</option>
|
|
{hosts
|
|
.filter(
|
|
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
|
|
)
|
|
.map((h) => (
|
|
<option key={h.id} value={h.id}>
|
|
{h.name || h.ip}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
</InlineView>
|
|
</div>
|
|
);
|
|
}
|