mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ChevronRight,
|
|
Copy,
|
|
CopyPlus,
|
|
GripVertical,
|
|
Pencil,
|
|
Pin,
|
|
Trash2,
|
|
Share2,
|
|
Upload,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import { ComponentSlot } from "@/shell/ActionSlot";
|
|
import type { Credential } from "@/types/ui-types";
|
|
import type {
|
|
CredentialDensity,
|
|
CredentialTrayTrigger,
|
|
} from "@/types/credential-sidebar-preferences";
|
|
|
|
export function credentialMatchesQuery(cred: Credential, query: string) {
|
|
return (
|
|
cred.name.toLowerCase().includes(query) ||
|
|
cred.username.toLowerCase().includes(query) ||
|
|
cred.tags?.some((t) => t.toLowerCase().includes(query))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Per-density layout knobs, mirroring HOST_ITEM_DENSITY_TOKENS in
|
|
* HostItem.tsx -- one implementation with a token lookup instead of two
|
|
* parallel JSX trees, so a future tweak only has to be made once.
|
|
*/
|
|
const CREDENTIAL_ITEM_DENSITY_TOKENS = {
|
|
comfortable: {
|
|
rowPadding: "pl-2.5 pr-2 py-2",
|
|
nameTextSize: "text-[13px]",
|
|
showUsernameRow: true,
|
|
showTagsRow: true,
|
|
},
|
|
compact: {
|
|
rowPadding: "pl-2 pr-1.5 py-[5px]",
|
|
nameTextSize: "text-[12px]",
|
|
showUsernameRow: false,
|
|
showTagsRow: false,
|
|
},
|
|
} as const;
|
|
|
|
export function CredentialItem({
|
|
cred,
|
|
usedByCount = 0,
|
|
query = "",
|
|
stripeIndex = 0,
|
|
isMenuOpen = false,
|
|
onMenuOpenChange,
|
|
isTrayOpen = false,
|
|
onTrayOpenChange,
|
|
onDragStart,
|
|
onDragEnd,
|
|
depth = 0,
|
|
density = "comfortable",
|
|
trayTrigger = "hover",
|
|
showTags = true,
|
|
arrangeMode = false,
|
|
isDragging = false,
|
|
onReorderDrop,
|
|
isReorderHovered = false,
|
|
reorderHoverEdge = null,
|
|
onReorderHoverChange,
|
|
onDeploy,
|
|
onEdit,
|
|
onClone,
|
|
onDelete,
|
|
onShare,
|
|
}: {
|
|
cred: Credential;
|
|
usedByCount?: number;
|
|
query?: string;
|
|
stripeIndex?: number;
|
|
isMenuOpen?: boolean;
|
|
onMenuOpenChange?: (open: boolean) => void;
|
|
isTrayOpen?: boolean;
|
|
onTrayOpenChange?: (open: boolean) => void;
|
|
onDragStart?: () => void;
|
|
onDragEnd?: () => void;
|
|
/** Nesting level when rendered in a flattened virtual list. */
|
|
depth?: number;
|
|
density?: CredentialDensity;
|
|
trayTrigger?: CredentialTrayTrigger;
|
|
showTags?: boolean;
|
|
/** When true (rearranging unlocked), the row can be dragged to reorder or
|
|
* onto a folder header to move. */
|
|
arrangeMode?: boolean;
|
|
/** True while this row is the one being dragged. */
|
|
isDragging?: boolean;
|
|
onReorderDrop?: (position: "before" | "after") => void;
|
|
/** Whether THIS row is the current reorder drop target -- lifted to the
|
|
* parent tree so only one row can ever show the drop-indicator bar at a
|
|
* time. See HostItem's identical prop for the full rationale. */
|
|
isReorderHovered?: boolean;
|
|
reorderHoverEdge?: "before" | "after" | null;
|
|
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
|
|
onDeploy: () => void;
|
|
onEdit: () => void;
|
|
onClone: () => void;
|
|
onDelete: () => void;
|
|
onShare?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
|
const isKey = cred.type === "key";
|
|
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
|
|
const isCompact = density === "compact";
|
|
const isTouchOnly =
|
|
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
|
|
|
const alwaysShowTray = trayTrigger === "always";
|
|
const actionsOnly = trayTrigger === "actionsOnly";
|
|
const shouldUseClickTray =
|
|
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
|
|
const canDrag = arrangeMode && !isTouchOnly;
|
|
|
|
const depthStyle =
|
|
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
|
|
|
|
const trayButtonClass =
|
|
"flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
|
|
|
|
const connectionButtons = isKey ? (
|
|
<>
|
|
<button
|
|
title={t("credentials.deploySSHKeyTitle")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDeploy();
|
|
}}
|
|
className={trayButtonClass}
|
|
>
|
|
<Upload className="size-3.5" />
|
|
</button>
|
|
<button
|
|
title={t("credentials.copyDeployCommand")}
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
const pubKey = cred.publicKey;
|
|
if (!pubKey) {
|
|
toast.error(t("credentials.noPublicKeyAvailable"));
|
|
return;
|
|
}
|
|
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
|
|
const ok = await copyToClipboard(cmd);
|
|
if (ok) toast.success(t("credentials.deployCommandCopied"));
|
|
else toast.error(t("common.copyFailed"));
|
|
}}
|
|
className={trayButtonClass}
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</button>
|
|
</>
|
|
) : null;
|
|
|
|
// A recipient sees only what their grant allows: "manage" may edit and
|
|
// re-share, "use" may only use. Cloning and deleting stay with the owner.
|
|
const canEdit = !cred.isShared || cred.permissionLevel === "manage";
|
|
const canShare =
|
|
!!onShare && (!cred.isShared || cred.permissionLevel === "manage");
|
|
const managementButtons = (
|
|
<>
|
|
{canEdit && (
|
|
<button
|
|
title={t("credentials.editCredentialAction")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onEdit();
|
|
}}
|
|
className={trayButtonClass}
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</button>
|
|
)}
|
|
{canShare && (
|
|
<button
|
|
title={t("credentials.shareCredentialAction")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onShare?.();
|
|
}}
|
|
className={trayButtonClass}
|
|
>
|
|
<Share2 className="size-3.5" />
|
|
</button>
|
|
)}
|
|
{!cred.isShared && (
|
|
<>
|
|
<button
|
|
title={t("credentials.cloneCredentialAction")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClone();
|
|
}}
|
|
className={trayButtonClass}
|
|
>
|
|
<CopyPlus className="size-3.5" />
|
|
</button>
|
|
<button
|
|
title={t("credentials.deleteCredentialAction")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete();
|
|
}}
|
|
className={`${trayButtonClass} hover:text-destructive`}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const trayOpenState = isTrayOpen || isMenuOpen;
|
|
// A collapsed tray clipped to max-h-0 is still a flex item, so it earns the
|
|
// text column's gap-1 and leaves a gap under every closed row. The negative
|
|
// margin cancels it while keeping the element in flow to animate open.
|
|
const trayCollapsed = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-1"}`;
|
|
const trayVisibilityClass =
|
|
alwaysShowTray || actionsOnly
|
|
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}`
|
|
: shouldUseClickTray
|
|
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}`
|
|
: `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`;
|
|
|
|
return (
|
|
<div
|
|
draggable={canDrag}
|
|
onDragStart={(e) => {
|
|
if (!canDrag) return;
|
|
e.dataTransfer.effectAllowed = "move";
|
|
// Drag the name row, not the whole row with its open tray, so the
|
|
// ghost stays a compact label instead of a tall overlapping block.
|
|
const label =
|
|
e.currentTarget.querySelector<HTMLElement>("[data-drag-label]");
|
|
if (label) {
|
|
const rect = label.getBoundingClientRect();
|
|
e.dataTransfer.setDragImage(
|
|
label,
|
|
Math.min(e.clientX - rect.left, rect.width),
|
|
rect.height / 2,
|
|
);
|
|
}
|
|
onDragStart?.();
|
|
}}
|
|
onDragEnd={() => {
|
|
onReorderHoverChange?.(null);
|
|
onDragEnd?.();
|
|
}}
|
|
onDragOver={(e) => {
|
|
if (!arrangeMode || !onReorderDrop) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
onReorderHoverChange?.(
|
|
e.clientY - rect.top < rect.height / 2 ? "before" : "after",
|
|
);
|
|
}}
|
|
onDrop={(e) => {
|
|
if (!arrangeMode || !onReorderDrop || !reorderEdge) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onReorderDrop(reorderEdge);
|
|
onReorderHoverChange?.(null);
|
|
}}
|
|
style={depthStyle}
|
|
className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${
|
|
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-default"
|
|
} ${stripeIndex % 2 === 1 ? "bg-muted/15" : ""} ${isMenuOpen ? "bg-muted/50" : ""} ${isDragging ? "opacity-40" : ""}`}
|
|
>
|
|
{canDrag && (
|
|
<div className="flex items-center justify-center w-4 shrink-0 text-muted-foreground/35 group-hover:text-muted-foreground/70 transition-colors">
|
|
<GripVertical className="size-3" />
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={`flex flex-col flex-1 min-w-0 ${tokens.rowPadding} ${isCompact ? "" : "gap-1"}`}
|
|
>
|
|
{/* Name row */}
|
|
<div data-drag-label className="flex items-center gap-1.5 min-w-0">
|
|
<span
|
|
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
|
|
>
|
|
{cred.name}
|
|
{cred.isShared && (
|
|
<span
|
|
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
|
|
title={t("credentials.sharedBy", {
|
|
owner: cred.ownerUsername ?? "",
|
|
})}
|
|
>
|
|
<Share2 className="size-2.5" />
|
|
{t("credentials.sharedBadge")}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span
|
|
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
|
|
>
|
|
{isKey ? t("credentials.keyBadge") : t("credentials.passwordBadge")}
|
|
</span>
|
|
<ComponentSlot
|
|
slotId="credentials.badges"
|
|
props={{ credentialId: Number(cred.id) }}
|
|
/>
|
|
{cred.pin && (
|
|
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
|
)}
|
|
{!shouldUseClickTray && !actionsOnly && (
|
|
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
|
{usedByCount > 0 ? `${usedByCount}h` : ""}
|
|
</span>
|
|
)}
|
|
{(shouldUseClickTray || actionsOnly) && (
|
|
<button
|
|
title={
|
|
isTrayOpen
|
|
? t("credentials.collapseActions")
|
|
: t("credentials.expandActions")
|
|
}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onTrayOpenChange?.(!isTrayOpen);
|
|
}}
|
|
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
|
|
>
|
|
<ChevronRight
|
|
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
|
|
/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Username row */}
|
|
{tokens.showUsernameRow && (cred.username || usedByCount > 0) && (
|
|
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
|
|
{cred.username}
|
|
{usedByCount > 0 && (
|
|
<span className="text-muted-foreground/40">
|
|
{cred.username ? " · " : ""}
|
|
{usedByCount}h
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
|
|
{/* Tag pills */}
|
|
{showTags && cred.tags && cred.tags.length > 0 && (
|
|
<div
|
|
className={`flex items-center gap-1 min-w-0 overflow-hidden ${tokens.showTagsRow ? "" : "-mt-0.5"}`}
|
|
>
|
|
{cred.tags.slice(0, isCompact ? 2 : 4).map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="text-[9px] px-1.5 py-[1px] bg-muted/60 text-muted-foreground/70 lowercase shrink-0 leading-[1.4]"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{cred.tags.length > (isCompact ? 2 : 4) && (
|
|
<span className="text-[9px] text-muted-foreground/40 shrink-0">
|
|
+{cred.tags.length - (isCompact ? 2 : 4)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Connection buttons: permanent in "always"/"actionsOnly" modes, or shown once the chevron opens the tray in click mode */}
|
|
{connectionButtons &&
|
|
(alwaysShowTray ||
|
|
actionsOnly ||
|
|
(shouldUseClickTray && isTrayOpen)) && (
|
|
<div className="flex items-center flex-wrap gap-1">
|
|
{connectionButtons}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action tray */}
|
|
<div className={trayVisibilityClass}>
|
|
<div
|
|
className={`flex flex-col gap-0.5 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "" : "pt-1.5"}`}
|
|
>
|
|
{connectionButtons &&
|
|
!alwaysShowTray &&
|
|
!actionsOnly &&
|
|
!shouldUseClickTray && (
|
|
<div className="flex items-center flex-wrap gap-0.5">
|
|
{connectionButtons}
|
|
</div>
|
|
)}
|
|
<div
|
|
className={`flex items-center gap-0.5 ${connectionButtons ? "border-t border-border/30" : ""} ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-1.5" : "pt-1 mt-0.5"}`}
|
|
>
|
|
{managementButtons}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|