* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

-259
View File
@@ -1,259 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { deleteApiKey, type ApiKey } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
type AdminApiKeysSectionProps = {
open: boolean;
onToggle: () => void;
apiKeys: ApiKey[];
setApiKeys: Dispatch<SetStateAction<ApiKey[]>>;
loadApiKeys: () => void;
showCreateKey: boolean;
setShowCreateKey: Dispatch<SetStateAction<boolean>>;
createdKeyToken: string | null;
setCreatedKeyToken: Dispatch<SetStateAction<string | null>>;
newKeyName: string;
setNewKeyName: Dispatch<SetStateAction<string>>;
newKeyUserId: string;
setNewKeyUserId: Dispatch<SetStateAction<string>>;
newKeyExpiry: string;
setNewKeyExpiry: Dispatch<SetStateAction<string>>;
users: AdminUser[];
handleCreateApiKey: () => void;
newKeyLoading: boolean;
};
export function AdminApiKeysSection({
open,
onToggle,
apiKeys,
setApiKeys,
loadApiKeys,
showCreateKey,
setShowCreateKey,
createdKeyToken,
setCreatedKeyToken,
newKeyName,
setNewKeyName,
newKeyUserId,
setNewKeyUserId,
newKeyExpiry,
setNewKeyExpiry,
users,
handleCreateApiKey,
newKeyLoading,
}: AdminApiKeysSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
label={t("admin.sectionApiKeys")}
icon={<Network className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
<DocsLink core="apiKeys">{t("admin.apiKeysDocsLink")}</DocsLink>
<br />
{t("admin.apiKeysCliHint")}{" "}
<DocsLink core="cli">{t("admin.apiKeysCliLink")}</DocsLink>
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadApiKeys}
>
<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={() => {
setShowCreateKey((o) => !o);
setCreatedKeyToken(null);
}}
>
<Plus className="size-3" />
{t("admin.createRole")}
</Button>
</div>
</div>
{showCreateKey && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.newApiKey")}
</span>
{createdKeyToken ? (
<div className="flex flex-col gap-2">
<span className="text-[10px] text-accent-brand font-semibold">
{t("admin.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
<span className="text-[10px] font-mono flex-1 truncate">
{createdKeyToken}
</span>
<button
onClick={() => {
copyToClipboard(createdKeyToken);
toast.info(t("admin.copiedToClipboard"));
}}
className="text-muted-foreground hover:text-accent-brand shrink-0"
>
<Copy className="size-3.5" />
</button>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs self-end"
onClick={() => {
setShowCreateKey(false);
setCreatedKeyToken(null);
}}
>
{t("admin.done")}
</Button>
</div>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("placeholders.apiKeyName")}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyUser")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Select2
className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
value={newKeyUserId}
onChange={(e) => setNewKeyUserId(e.target.value)}
>
<option value="">{t("admin.apiKeySelectUser")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyExpiresAt")}
</label>
<Input
type="date"
value={newKeyExpiry}
onChange={(e) => setNewKeyExpiry(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setShowCreateKey(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateApiKey}
disabled={newKeyLoading}
>
{newKeyLoading ? t("admin.creating") : t("admin.createKey")}
</Button>
</div>
</>
)}
</div>
)}
{apiKeys.map((key) => (
<div
key={key.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">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{key.name}
</span>
{!key.isActive && (
<span className="text-[9px] font-semibold px-1 py-px border border-destructive/40 bg-destructive/10 text-destructive">
{t("admin.revokedBadge")}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeyUser")}: {key.username}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate">
{key.tokenPrefix}…
</span>
<span className="flex gap-2.5 text-[10px] text-muted-foreground">
<span>{key.createdAt.split("T")[0]}</span>
<span>
{key.expiresAt
? key.expiresAt.split("T")[0]
: t("admin.apiKeyNoExpiry")}
</span>
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeKeyConfirm", { name: key.name }),
confirmLabel: t("admin.revokeKey"),
});
if (!ok) return;
try {
await deleteApiKey(key.id);
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
toast.success(
t("admin.revokeKeySuccess", { name: key.name }),
);
} catch {
toast.error(t("admin.revokeKeyFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
</AccordionSection>
);
}
-491
View File
@@ -1,491 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
ChevronLeft,
ChevronRight,
ClipboardList,
RefreshCw,
X,
} from "lucide-react";
import { AccordionSection } from "./AdminSettingsShared";
import {
getAuditLogs,
getAuditLogActions,
getAuditForwarding,
updateAuditForwarding,
type AuditLog,
type AuditLogFilters,
} from "@/api/audit-log-api";
import { toast } from "sonner";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { parseServerTime } from "@/lib/server-time";
const RESOURCE_TYPES = [
"user",
"host",
"credential",
"plugin",
"session",
"api_key",
"setting",
];
type AdminAuditLogSectionProps = {
open: boolean;
onToggle: () => void;
users: AdminUser[];
};
export function AdminAuditLogSection({
open,
onToggle,
users,
}: AdminAuditLogSectionProps) {
const { t } = useTranslation();
const [logs, setLogs] = useState<AuditLog[]>([]);
const [forwardUrl, setForwardUrl] = useState("");
const [forwardToken, setForwardToken] = useState("");
const [forwardHasToken, setForwardHasToken] = useState(false);
const [forwardEnvConfigured, setForwardEnvConfigured] = useState(false);
const [forwardSaving, setForwardSaving] = useState(false);
useEffect(() => {
if (!open) return;
getAuditForwarding()
.then((settings) => {
setForwardUrl(settings.url);
setForwardHasToken(settings.hasToken);
setForwardEnvConfigured(settings.envConfigured);
})
.catch(() => {});
}, [open]);
async function handleSaveForwarding() {
setForwardSaving(true);
try {
const result = await updateAuditForwarding(
forwardUrl,
forwardToken || undefined,
);
setForwardHasToken(result.hasToken || (!!result.url && forwardHasToken));
setForwardToken("");
toast.success(t("admin.auditForwardingSaved"));
} catch {
toast.error(t("admin.auditForwardingSaveError"));
} finally {
setForwardSaving(false);
}
}
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [actions, setActions] = useState<string[]>([]);
const [filterUserId, setFilterUserId] = useState("");
const [filterAction, setFilterAction] = useState("");
const [filterResourceType, setFilterResourceType] = useState("");
const [filterSuccess, setFilterSuccess] = useState<"" | "true" | "false">("");
const [filterStartDate, setFilterStartDate] = useState("");
const [filterEndDate, setFilterEndDate] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchLogs = useCallback(
async (filters: AuditLogFilters, pg: number) => {
setLoading(true);
try {
const result = await getAuditLogs({ ...filters, page: pg, limit: 50 });
setLogs(result.logs);
setTotal(result.total);
setTotalPages(result.totalPages);
} catch {
// non-fatal
} finally {
setLoading(false);
}
},
[],
);
const buildFilters = useCallback((): AuditLogFilters => {
const f: AuditLogFilters = {};
if (filterUserId) f.userId = filterUserId;
if (filterAction) f.action = filterAction;
if (filterResourceType) f.resourceType = filterResourceType;
if (filterSuccess !== "") f.success = filterSuccess === "true";
if (filterStartDate) f.startDate = filterStartDate;
if (filterEndDate) f.endDate = filterEndDate;
return f;
}, [
filterUserId,
filterAction,
filterResourceType,
filterSuccess,
filterStartDate,
filterEndDate,
]);
useEffect(() => {
if (!open) return;
getAuditLogActions()
.then((r) => setActions(r.actions))
.catch(() => {});
fetchLogs(buildFilters(), 1);
setPage(1);
}, [open, buildFilters, fetchLogs]);
useEffect(() => {
if (!open) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
fetchLogs(buildFilters(), 1);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [
open,
buildFilters,
fetchLogs,
filterUserId,
filterAction,
filterResourceType,
filterSuccess,
filterStartDate,
filterEndDate,
]);
function clearFilters() {
setFilterUserId("");
setFilterAction("");
setFilterResourceType("");
setFilterSuccess("");
setFilterStartDate("");
setFilterEndDate("");
}
function handlePageChange(newPage: number) {
setPage(newPage);
fetchLogs(buildFilters(), newPage);
}
function formatTimestamp(ts: string) {
try {
return parseServerTime(ts).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return ts;
}
}
const hasFilters =
filterUserId ||
filterAction ||
filterResourceType ||
filterSuccess !== "" ||
filterStartDate ||
filterEndDate;
return (
<AccordionSection
label={t("admin.sectionAuditLog")}
icon={<ClipboardList className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2 gap-2">
{/* SIEM forwarding */}
<div className="flex flex-col gap-1.5 pb-2 border-b border-border">
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditForwardingTitle")}
</span>
<p className="text-[10px] text-muted-foreground">
{t("admin.auditForwardingDesc")}
{forwardEnvConfigured && (
<> {t("admin.auditForwardingEnvNotice")}</>
)}
</p>
<Input
placeholder="https://siem.example.com/ingest"
value={forwardUrl}
onChange={(e) => setForwardUrl(e.target.value)}
className="text-[10px] h-7"
/>
<Input
type="password"
placeholder={
forwardHasToken
? t("admin.auditForwardingTokenStored")
: t("admin.auditForwardingToken")
}
value={forwardToken}
onChange={(e) => setForwardToken(e.target.value)}
className="text-[10px] h-7"
/>
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] self-start"
onClick={handleSaveForwarding}
disabled={forwardSaving}
>
{t("common.save")}
</Button>
</div>
{/* Filters */}
<div className="grid grid-cols-2 gap-1.5">
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterUser")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{(Array.isArray(users) ? users : []).map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterAction")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{actions.map((a) => (
<option key={a} value={a}>
{a.replace(/_/g, " ")}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterResourceType")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterResourceType}
onChange={(e) => setFilterResourceType(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{RESOURCE_TYPES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterStatus")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterSuccess}
onChange={(e) =>
setFilterSuccess(e.target.value as "" | "true" | "false")
}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
<option value="true">{t("admin.auditLogSuccess")}</option>
<option value="false">{t("admin.auditLogFailed")}</option>
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterFrom")}
</label>
<Input
type="datetime-local"
value={filterStartDate}
onChange={(e) => setFilterStartDate(e.target.value)}
className="text-[10px] h-7"
/>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterTo")}
</label>
<Input
type="datetime-local"
value={filterEndDate}
onChange={(e) => setFilterEndDate(e.target.value)}
className="text-[10px] h-7"
/>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="text-[10px] text-muted-foreground">
{t("admin.auditLogTotal", { total })}
</span>
<div className="flex items-center gap-1">
{hasFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] text-muted-foreground hover:text-foreground gap-1"
onClick={clearFilters}
>
<X className="size-3" />
{t("admin.auditLogClearFilters")}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => fetchLogs(buildFilters(), page)}
disabled={loading}
>
<RefreshCw
className={`size-3 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Log table */}
{logs.length === 0 && !loading ? (
<div className="py-6 text-center text-[10px] text-muted-foreground">
{t("admin.auditLogEmpty")}
</div>
) : (
<div className="flex flex-col">
{logs.map((log) => (
<div
key={log.id}
className="border-b border-border last:border-0 cursor-pointer hover:bg-muted/20 transition-colors"
onClick={() =>
setExpandedId(expandedId === log.id ? null : log.id)
}
>
<div className="flex items-start gap-2 py-2">
<span
className={`shrink-0 mt-0.5 w-1.5 h-1.5 rounded-full ${log.success ? "bg-green-500" : "bg-destructive"}`}
/>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] font-semibold text-foreground">
{(log.action || "unknown").replace(/_/g, " ")}
</span>
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground">
{log.resourceType}
</span>
{log.resourceName && (
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{log.resourceName}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground font-semibold">
{log.username}
</span>
<span className="text-[9px] text-muted-foreground">
{formatTimestamp(log.timestamp)}
</span>
</div>
</div>
<span
className={`shrink-0 text-[9px] font-semibold px-1 py-px border ${
log.success
? "border-green-500/40 text-green-500 bg-green-500/10"
: "border-destructive/40 text-destructive bg-destructive/10"
}`}
>
{log.success
? t("admin.auditLogSuccess")
: t("admin.auditLogFailed")}
</span>
</div>
{expandedId === log.id && (
<div className="ml-3.5 pb-2 flex flex-col gap-1">
{log.ipAddress && (
<span className="text-[9px] text-muted-foreground">
{t("admin.auditLogIp")}: {log.ipAddress}
</span>
)}
{log.resourceId && (
<span className="text-[9px] text-muted-foreground">
{t("admin.auditLogResourceId")}: {log.resourceId}
</span>
)}
{log.details && (
<span className="text-[9px] text-muted-foreground font-mono break-all">
{log.details}
</span>
)}
{log.errorMessage && (
<span className="text-[9px] text-destructive">
{log.errorMessage}
</span>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-1 border-t border-border">
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={page <= 1 || loading}
onClick={() => handlePageChange(page - 1)}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] text-muted-foreground">
{t("admin.auditLogPage", { page, totalPages, total })}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={page >= totalPages || loading}
onClick={() => handlePageChange(page + 1)}
>
<ChevronRight className="size-3" />
</Button>
</div>
)}
</div>
</AccordionSection>
);
}
-154
View File
@@ -1,154 +0,0 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { PaintbrushIcon } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import type { BrandingSettings } from "@/api/settings-api";
import { AccordionSection } from "./AdminSettingsShared";
import {
BRANDING_LOGO_ACCEPT,
isAcceptedBrandingLogoFile,
} from "./branding-logo-file";
export function AdminBrandingSection({
open,
onToggle,
settings,
setSettings,
saving,
onSave,
onResetLogo,
}: {
open: boolean;
onToggle: () => void;
settings: BrandingSettings | null;
setSettings: (settings: BrandingSettings) => void;
saving: boolean;
onSave: () => void;
onResetLogo: () => void;
}) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !settings) return;
if (!isAcceptedBrandingLogoFile(file)) {
alert(t("admin.brandingLogoInvalid"));
return;
}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setSettings({ ...settings, logo: reader.result });
}
};
reader.readAsDataURL(file);
}
return (
<AccordionSection
label={t("admin.sectionBranding")}
icon={<PaintbrushIcon className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{settings === null ? (
<div className="pt-3 text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : (
<div className="@container flex flex-col gap-0 pt-2">
<div className="grid grid-cols-1 gap-x-4 gap-y-3 py-3 @md:grid-cols-2 border-b border-border">
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.brandingAppName")}
</span>
<Input
aria-label={t("admin.brandingAppName")}
value={settings.appName}
onChange={(event) =>
setSettings({ ...settings, appName: event.target.value })
}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.brandingAppNameDesc")}
</span>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.brandingTagline")}
</span>
<Input
aria-label={t("admin.brandingTagline")}
value={settings.tagline}
onChange={(event) =>
setSettings({ ...settings, tagline: event.target.value })
}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.brandingTaglineDesc")}
</span>
</label>
</div>
<div className="flex flex-col gap-2 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("admin.brandingLogo")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("admin.brandingLogoDesc")}
</span>
</div>
<div className="flex items-center gap-3">
{settings.logo ? (
<img
src={settings.logo}
alt=""
className="size-10 object-contain border border-border bg-muted/40"
/>
) : (
<div className="size-10 border border-dashed border-border" />
)}
<input
ref={fileInputRef}
type="file"
accept={BRANDING_LOGO_ACCEPT}
className="hidden"
onChange={handleFileChange}
/>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
{t("admin.brandingLogoUpload")}
</Button>
{settings.logo && (
<Button size="sm" variant="outline" onClick={onResetLogo}>
{t("admin.brandingLogoReset")}
</Button>
)}
</div>
</div>
<div className="flex gap-2 pt-3">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={onSave}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)}
</AccordionSection>
);
}
-99
View File
@@ -1,99 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { X } from "lucide-react";
import { getHostTags, saveHostTags } from "@/api/host-tags-api";
export function AdminHostTags() {
const { t } = useTranslation();
const [tags, setTags] = useState<string[]>([]);
const [input, setInput] = useState("");
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
getHostTags()
.then((list) => {
if (cancelled) return;
setTags(list);
setLoaded(true);
})
.catch(() => {
if (!cancelled) toast.error(t("admin.hostTagsError"));
});
return () => {
cancelled = true;
};
}, [t]);
async function save(next: string[]) {
const previous = tags;
setTags(next);
setSaving(true);
try {
setTags(await saveHostTags(next));
window.dispatchEvent(new Event("termix:host-tags-changed"));
} catch {
setTags(previous);
toast.error(t("admin.hostTagsError"));
} finally {
setSaving(false);
}
}
function add(raw: string) {
const tag = raw.trim();
setInput("");
if (!tag || tags.includes(tag)) return;
void save([...tags, tag]);
}
return (
<div className="flex flex-col gap-1.5">
<label htmlFor="global-host-tags" className="text-xs font-medium">
{t("admin.hostTags")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.hostTagsDesc")}
</span>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
disabled={saving}
aria-label={t("common.remove")}
onClick={() => void save(tags.filter((tg) => tg !== tag))}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
id="global-host-tags"
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={tags.length === 0 ? t("hosts.addTag") : ""}
disabled={!loaded || saving}
value={input}
onChange={(e) => setInput(e.target.value)}
onBlur={() => add(input)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return;
if ((e.key === " " || e.key === "Enter") && input.trim()) {
e.preventDefault();
add(input);
} else if (e.key === "Backspace" && !input && tags.length > 0) {
void save(tags.slice(0, -1));
}
}}
/>
</div>
</div>
);
}
-822
View File
@@ -1,822 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
deleteRole,
deleteUser,
getPermissionsCatalog,
revokeAllUserSessions,
revokeSession,
updateRole,
} from "@/main-axios";
import type { PermissionCatalogEntry, Role } from "@/main-axios";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PanelSearch } from "@/components/panel-layout";
import {
Activity,
Check,
KeyRound,
Pencil,
Plus,
RefreshCw,
Settings2,
Share2,
Trash2,
Unlink,
User,
} from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import { roleLabel } from "@/lib/role-label";
export type AdminUser = {
id: string;
username: string;
isAdmin: boolean;
isExternal: boolean;
passwordHash?: string;
dataUnlocked?: boolean;
secondFactorEnabled?: boolean;
};
export type AdminSession = {
id: string;
userId: string;
username?: string;
deviceType: string;
deviceInfo: string;
createdAt: string;
expiresAt: string;
lastActiveAt: string;
isRevoked?: boolean;
isCurrentSession?: boolean;
};
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
};
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
type UsersSectionProps = {
open: boolean;
onToggle: () => void;
users: AdminUser[];
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
loadUsers: () => void;
setCreateUserOpen: Dispatch<SetStateAction<boolean>>;
setEditUserTarget: Dispatch<SetStateAction<AdminUser | null>>;
setEditUserOpen: Dispatch<SetStateAction<boolean>>;
setLinkAccountTarget: Dispatch<
SetStateAction<{ id: string; username: string; isExternal: boolean } | null>
>;
setLinkAccountOpen: Dispatch<SetStateAction<boolean>>;
setUnlinkAccountTarget: Dispatch<
SetStateAction<{ id: string; username: string } | null>
>;
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
onManageUser: (user: AdminUser) => void;
search: string;
onSearchChange: (value: string) => void;
page: number;
pageSize: number;
total: number;
onPageChange: Dispatch<SetStateAction<number>>;
};
export function AdminUsersSection({
open,
onToggle,
users,
setUsers,
loadUsers,
setCreateUserOpen,
setEditUserTarget,
setEditUserOpen,
setLinkAccountTarget,
setLinkAccountOpen,
setUnlinkAccountTarget,
setUnlinkAccountOpen,
onManageUser,
search,
onSearchChange,
page,
pageSize,
total,
onPageChange,
}: UsersSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 0;
const hasNext = page + 1 < pageCount;
return (
<AccordionSection
label={t("admin.sectionUsers")}
icon={<User className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.usersCount", { count: total })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadUsers}
>
<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={() => setCreateUserOpen(true)}
>
<Plus className="size-3" />
{t("admin.createUser")}
</Button>
</div>
</div>
<div className="py-2 border-b border-border">
<PanelSearch
value={search}
onChange={onSearchChange}
placeholder={t("admin.searchUsers")}
fill
/>
</div>
{users.map((user) => {
const authLabel =
user.isExternal && user.passwordHash
? t("admin.authTypeDual")
: user.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal");
return (
<div
key={user.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">
<div className="size-6 bg-muted border border-border flex items-center justify-center text-[10px] font-bold shrink-0">
{user.username[0].toUpperCase()}
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[120px]">
{user.username}
</span>
<div className="flex items-center gap-1">
{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>
)}
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
{authLabel}
</span>
</div>
</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-accent-brand"
title={t("admin.manageUserData")}
onClick={() => onManageUser(user)}
>
<Settings2 className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditUserTarget(user);
setEditUserOpen(true);
}}
>
<Pencil className="size-3" />
</Button>
{user.isExternal && user.passwordHash ? (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
title={t("admin.unlinkAccount")}
onClick={() => {
setUnlinkAccountTarget({
id: user.id,
username: user.username,
});
setUnlinkAccountOpen(true);
}}
>
<Unlink className="size-3" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
title={t("admin.linkAccountTitle")}
onClick={() => {
setLinkAccountTarget({
id: user.id,
username: user.username,
isExternal: user.isExternal,
});
setLinkAccountOpen(true);
}}
>
<Share2 className="size-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
disabled={user.isAdmin}
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: user.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteUser(user.username);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
toast.success(
t("admin.deleteUserSuccess", {
username: user.username,
}),
);
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("admin.deleteUserFailed")),
);
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
);
})}
{users.length === 0 && (
<div className="py-4 text-center text-[10px] text-muted-foreground">
{search ? t("admin.noUsersMatch") : t("admin.noUsers")}
</div>
)}
{pageCount > 1 && (
<div className="flex items-center justify-between py-2 border-t border-border">
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] rounded-none"
disabled={!hasPrev}
onClick={() => onPageChange((p) => Math.max(0, p - 1))}
>
{t("common.previous")}
</Button>
<span className="text-[10px] text-muted-foreground">
{t("admin.pageOf", { page: page + 1, total: pageCount })}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] rounded-none"
disabled={!hasNext}
onClick={() => onPageChange((p) => p + 1)}
>
{t("common.next")}
</Button>
</div>
)}
</div>
</AccordionSection>
);
}
type SessionsSectionProps = {
open: boolean;
onToggle: () => void;
sessions: AdminSession[];
setSessions: Dispatch<SetStateAction<AdminSession[]>>;
loadSessions: () => void;
};
export function AdminSessionsSection({
open,
onToggle,
sessions,
setSessions,
loadSessions,
}: SessionsSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
label={t("admin.sectionSessions")}
icon={<Activity className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<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>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadSessions}
>
<RefreshCw className="size-3" />
</Button>
</div>
{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">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold">
{session.username}
</span>
{session.isCurrentSession && (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.youBadge")}
</span>
)}
</div>
<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>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionExpires", { time: session.expiresAt })}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeAllSessionsConfirm", {
username: session.username ?? "",
}),
confirmLabel: t("admin.revokeAll"),
});
if (!ok) return;
try {
await revokeAllUserSessions(session.userId);
setSessions((prev) =>
prev.filter((s) => s.userId !== session.userId),
);
toast.success(t("admin.revokeAllSessionsSuccess"));
} catch {
toast.error(t("admin.revokeAllSessionsFailed"));
}
}}
>
{t("admin.revokeAll")}
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeSessionConfirm"),
confirmLabel: t("admin.revokeSession"),
});
if (!ok) return;
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>
))}
</div>
</AccordionSection>
);
}
type RolesSectionProps = {
open: boolean;
onToggle: () => void;
roles: Role[];
setRoles: Dispatch<SetStateAction<Role[]>>;
showCreateRole: boolean;
setShowCreateRole: Dispatch<SetStateAction<boolean>>;
newRoleName: string;
setNewRoleName: Dispatch<SetStateAction<string>>;
newRoleDisplayName: string;
setNewRoleDisplayName: Dispatch<SetStateAction<string>>;
newRoleDescription: string;
setNewRoleDescription: Dispatch<SetStateAction<string>>;
handleCreateRole: () => void;
createRoleLoading: boolean;
};
export function AdminRolesSection({
open,
onToggle,
roles,
setRoles,
showCreateRole,
setShowCreateRole,
newRoleName,
setNewRoleName,
newRoleDisplayName,
setNewRoleDisplayName,
newRoleDescription,
setNewRoleDescription,
handleCreateRole,
createRoleLoading,
}: RolesSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
new Set(),
);
const [savingPermissions, setSavingPermissions] = useState(false);
function rolePermissions(role: Role): string[] {
if (Array.isArray(role.permissions)) return role.permissions;
if (typeof role.permissions === "string") {
try {
return JSON.parse(role.permissions) as string[];
} catch {
return [];
}
}
return [];
}
async function openPermissionsEditor(role: Role) {
if (editingRoleId === role.id) {
setEditingRoleId(null);
return;
}
try {
if (catalog.length === 0) {
const res = await getPermissionsCatalog();
setCatalog(res.catalog ?? []);
}
setEditingPermissions(new Set(rolePermissions(role)));
setEditingRoleId(role.id);
} catch {
toast.error(t("admin.rolePermissions.loadError"));
}
}
function togglePermission(permission: string) {
setEditingPermissions((prev) => {
const next = new Set(prev);
if (next.has(permission)) next.delete(permission);
else next.add(permission);
return next;
});
}
async function savePermissions(role: Role) {
setSavingPermissions(true);
try {
const permissions = [...editingPermissions];
await updateRole(role.id, { permissions });
setRoles((prev) =>
prev.map((entry) =>
entry.id === role.id ? { ...entry, permissions } : entry,
),
);
setEditingRoleId(null);
toast.success(t("admin.rolePermissions.saved"));
} catch {
toast.error(t("admin.rolePermissions.saveError"));
} finally {
setSavingPermissions(false);
}
}
return (
<AccordionSection
label={t("admin.sectionRoles")}
icon={<KeyRound className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.rolesCount", { count: roles.length })}
</span>
<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={() => setShowCreateRole((o) => !o)}
>
<Plus className="size-3" />
{t("admin.createRole")}
</Button>
</div>
{showCreateRole && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.newRole")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.roleName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="developer"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.roleDisplayName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="Developer"
value={newRoleDisplayName}
onChange={(e) => setNewRoleDisplayName(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.roleDescription")}
</label>
<textarea
rows={2}
placeholder={t("common.optional")}
value={newRoleDescription}
onChange={(e) => setNewRoleDescription(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
setShowCreateRole(false);
setNewRoleName("");
setNewRoleDisplayName("");
setNewRoleDescription("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateRole}
disabled={createRoleLoading}
>
{createRoleLoading
? t("admin.creating")
: t("admin.createRole")}
</Button>
</div>
</div>
)}
{roles.map((role) => {
const permissionCount = rolePermissions(role).length;
return (
<div
key={role.id}
className="flex flex-col py-2.5 border-b border-border last:border-0"
>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{roleLabel(t, role.displayName)}
</span>
{role.isSystem ? (
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
{t("admin.systemBadge")}
</span>
) : (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.customBadge")}
</span>
)}
{permissionCount > 0 && (
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground">
{t("admin.rolePermissions.count", {
count: permissionCount,
})}
</span>
)}
</div>
<span className="text-[10px] font-mono text-muted-foreground">
{role.name}
</span>
</div>
{!role.isSystem && (
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className={`size-6 ${editingRoleId === role.id ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("admin.rolePermissions.editAction")}
onClick={() => openPermissionsEditor(role)}
>
<KeyRound className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteRoleConfirm", {
name: role.displayName,
}),
});
if (!ok) return;
await deleteRole(role.id);
setRoles((prev) =>
prev.filter((r) => r.id !== role.id),
);
toast.success(
t("admin.deleteRoleSuccess", {
name: role.displayName,
}),
);
}}
>
<Trash2 className="size-3" />
</Button>
</div>
)}
</div>
{editingRoleId === role.id && (
<div className="flex flex-col gap-2.5 mt-2.5 p-2.5 border border-border bg-muted/20">
{catalog.map((entry) => {
const wildcard = `${entry.group}.*`;
const wildcardOn = editingPermissions.has(wildcard);
// A plugin group with enabled === false is still granted,
// it just cannot be reached right now.
const offline = entry.enabled === false;
const titleFor = (permission: string) => {
const item = entry.items?.find(
(candidate) => candidate.permission === permission,
);
if (!item) return null;
return entry.pluginId
? t(pluginKey(entry.pluginId, item.titleKey))
: t(item.titleKey);
};
return (
<div
key={entry.group}
className={`flex flex-col gap-1 ${offline ? "opacity-50" : ""}`}
>
<button
onClick={() => togglePermission(wildcard)}
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
>
<span
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${wildcardOn ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{wildcardOn && (
<Check className="size-2 text-background" />
)}
</span>
{entry.pluginId && (
<PluginIcon
name={entry.icon}
className="size-3 shrink-0 text-muted-foreground"
/>
)}
<span
className={
wildcardOn
? "text-accent-brand"
: "text-muted-foreground"
}
>
{entry.label ??
(entry.labelKey
? t(entry.labelKey)
: entry.group)}
</span>
{offline && (
<span className="px-1 border border-border text-[9px] font-normal normal-case tracking-normal text-muted-foreground">
{t("admin.rolePermissions.pluginDisabled")}
</span>
)}
</button>
<div className="flex flex-col gap-0.5 pl-4">
{entry.permissions.map((permission) => {
const checked =
wildcardOn || editingPermissions.has(permission);
const title = titleFor(permission);
return (
<button
key={permission}
disabled={wildcardOn}
onClick={() => togglePermission(permission)}
className="flex items-center gap-1.5 text-[10px] text-left disabled:opacity-60"
>
<span
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${checked ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{checked && (
<Check className="size-2 text-background" />
)}
</span>
{title && (
<span className="text-foreground">
{title}
</span>
)}
<span className="font-mono text-muted-foreground">
{permission}
</span>
</button>
);
})}
</div>
</div>
);
})}
<div className="flex justify-end gap-2 pt-1">
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setEditingRoleId(null)}
>
{t("common.cancel")}
</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"
disabled={savingPermissions}
onClick={() => savePermissions(role)}
>
{savingPermissions
? t("admin.rolePermissions.saving")
: t("admin.rolePermissions.save")}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</AccordionSection>
);
}
@@ -1,77 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { SettingRow } from "@/components/section-card";
import { useConfirm } from "@/components/surface/surface-scope";
import { getDeveloperMode, setDeveloperMode } from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
/** Lets admins install plugins from a file. Asks before turning it on. */
export function AdminPluginDeveloperMode() {
const { t } = useTranslation();
const confirm = useConfirm();
const [enabled, setEnabled] = useState(false);
const [signedOnly, setSignedOnly] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
getDeveloperMode()
.then((state) => {
if (cancelled) return;
setEnabled(state.enabled);
setSignedOnly(state.signedOnly);
setLoaded(true);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const toggle = async () => {
const next = !enabled;
if (next) {
const ok = await confirm({
title: t("plugins.manager.developer.enableTitle"),
description: t("plugins.manager.developer.enableBody"),
confirmLabel: t("plugins.manager.developer.enable"),
});
if (!ok) return;
}
setSaving(true);
try {
await setDeveloperMode(next);
setEnabled(next);
} catch (error) {
toast.error(
t("plugins.manager.errors.developerMode", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setSaving(false);
}
};
return (
<>
<SettingRow
label={t("plugins.manager.developer.title")}
description={t("plugins.manager.developer.hint")}
>
<AdminToggle
on={enabled}
onToggle={() => void toggle()}
disabled={!loaded || saving}
/>
</SettingRow>
{enabled && signedOnly && (
<p className="text-[10px] text-destructive">
{t("plugins.manager.developer.signedOnly")}
</p>
)}
</>
);
}
@@ -1,112 +0,0 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldOff } from "lucide-react";
import { Button } from "@/components/button";
import { pluginKey } from "@/lib/plugin-i18n";
import {
getUserSecondFactors,
resetUserSecondFactors,
type UserSecondFactorSummary,
} from "@/api/auth-methods-api";
/**
* Every second factor a user enrolled in, including ones whose plugin is off
* (which is what locks a user out), with a reset for all of them.
*/
export function AdminSecondFactorsSection({
userId,
username,
heading,
requestConfirm,
onReset,
}: {
userId: string;
username: string;
heading: ReactNode;
requestConfirm: (message: string, onConfirm: () => void) => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const [factors, setFactors] = useState<UserSecondFactorSummary[]>([]);
const [working, setWorking] = useState(false);
const load = useCallback(() => {
getUserSecondFactors(userId)
.then(setFactors)
.catch(() => setFactors([]));
}, [userId]);
useEffect(() => {
load();
}, [load]);
async function reset() {
setWorking(true);
try {
await resetUserSecondFactors(userId);
toast.success(t("admin.secondFactorsResetSuccess"));
onReset();
load();
} catch {
toast.error(t("admin.secondFactorsResetFailed"));
} finally {
setWorking(false);
}
}
return (
<div className="flex flex-col gap-2">
{heading}
{factors.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("admin.secondFactorsNone")}
</span>
) : (
<div className="flex flex-col gap-1">
{factors.map((factor) => (
<div
key={`${factor.pluginId}:${factor.factorId}`}
className="flex items-center justify-between text-xs"
>
<span>
{factor.labelKey
? t(
factor.pluginId === "core"
? factor.labelKey
: pluginKey(factor.pluginId, factor.labelKey),
{ defaultValue: factor.factorId },
)
: factor.factorId}
</span>
{!factor.available && (
<span className="text-[10px] text-destructive">
{t("admin.secondFactorUnavailable")}
</span>
)}
</div>
))}
</div>
)}
{factors.length > 0 && (
<Button
variant="outline"
size="sm"
className="h-7 w-fit text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={working}
onClick={() =>
requestConfirm(
t("admin.resetSecondFactorsConfirm", { username }),
() => {
void reset();
},
)
}
>
<ShieldOff className="size-3" />
{t("admin.resetSecondFactors")}
</Button>
)}
</div>
);
}
-992
View File
@@ -1,992 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useBranding } from "@/contexts/BrandingContext";
import {
getNotificationPrivateEndpoints,
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
} from "@/api/private-endpoints-api";
import {
getUserList,
getSessions,
getRoles,
getApiKeys,
createApiKey,
deleteUser,
revokeAllUserSessions,
createRole,
adminCreateUser,
makeUserAdmin,
removeAdminStatus,
getRegistrationAllowed,
updateRegistrationAllowed,
getPasswordLoginAllowed,
updatePasswordLoginAllowed,
getPasswordResetAllowed,
updatePasswordResetAllowed,
getSessionTimeout,
updateSessionTimeout,
getStatusCheckSettings,
updateStatusCheckSettings,
getLogLevel,
updateLogLevel,
getExternalAutoProvision,
updateExternalAutoProvision,
getSecondFactorAfterExternalLogin,
updateSecondFactorAfterExternalLogin,
isElectron,
getUserRoles,
} from "@/main-axios";
import {
getBranding,
updateBranding,
type BrandingSettings,
} from "@/api/settings-api";
import {
getTlsStatus,
uploadTlsCertificate,
type TlsStatus,
} from "@/api/tls-api";
import {
type ApiKey,
type CreatedApiKey,
type Role,
type UserRole,
} from "@/main-axios";
import { type AdminSection, type Host } from "@/types/ui-types";
import {
FeatureSettingsSection,
featureSectionId,
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import {
AdminRolesSection,
AdminSessionsSection,
AdminUsersSection,
type AdminSession,
type AdminUser,
} from "./AdminManagementSections";
import { toast } from "sonner";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import {
AdminDatabaseSection,
AdminGeneralSettingsSection,
AdminHostDefaultsSection,
AdminSSLSection,
} from "./AdminSettingsSections";
import { AdminApiKeysSection } from "./AdminApiKeysSection";
import { AdminAuditLogSection } from "./AdminAuditLogSection";
import {
AdminCreateUserDialog,
AdminEditUserDialog,
AdminLinkAccountDialog,
AdminUnlinkAccountDialog,
} from "./AdminUserDialogs";
import { AdminUserManagePanel } from "./AdminUserManagePanel";
import { AdminBrandingSection } from "./AdminBrandingSection";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
};
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
const USERS_PAGE_SIZE = 25;
export function AdminSettingsPanel({
onEditingChange,
onOpenHostTab,
section,
}: {
onEditingChange?: (editing: boolean) => void;
onOpenHostTab?: (host: Host) => void;
/** Shows just this section, flat, as a Settings page. */
section?: AdminSection;
} = {}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [openSections, setOpenSections] = useState<
Set<AdminSection | FeatureSectionId>
>(() => new Set(["general"]));
const featureSettings = useFeatureSettings("admin");
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
const [passwordLoginForced, setPasswordLoginForced] = useState(false);
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
const [sessionTimeout, setSessionTimeout] = useState("24");
const [statusInterval, setStatusInterval] = useState("30");
const [logLevel, setLogLevel] = useState("info");
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]);
const [brandingSettings, setBrandingSettings] =
useState<BrandingSettings | null>(null);
const [brandingSaving, setBrandingSaving] = useState(false);
const { applyBranding } = useBranding();
// External login state
const [externalAutoProvision, setExternalAutoProvision] = useState(false);
const [secondFactorAfterExternalLogin, setSecondFactorAfterExternalLogin] =
useState(false);
// Create user dialog
const [createUserOpen, setCreateUserOpen] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [showNewPassword, setShowNewPassword] = useState(false);
const [createUserLoading, setCreateUserLoading] = useState(false);
// Edit user dialog
const [editUserOpen, setEditUserOpen] = useState(false);
const [editUserTarget, setEditUserTarget] = useState<AdminUser | null>(null);
const [editUserLoading, setEditUserLoading] = useState(false);
const [editUserRoles, setEditUserRoles] = useState<UserRole[]>([]);
const [editUserRolesLoading, setEditUserRolesLoading] = useState(false);
// Link account dialog
const [linkAccountOpen, setLinkAccountOpen] = useState(false);
const [linkAccountTarget, setLinkAccountTarget] = useState<{
id: string;
username: string;
isExternal: boolean;
} | null>(null);
// Unlink account dialog
const [unlinkAccountOpen, setUnlinkAccountOpen] = useState(false);
const [unlinkAccountTarget, setUnlinkAccountTarget] = useState<{
id: string;
username: string;
} | null>(null);
// Create role form
const [showCreateRole, setShowCreateRole] = useState(false);
const [newRoleName, setNewRoleName] = useState("");
const [newRoleDisplayName, setNewRoleDisplayName] = useState("");
const [newRoleDescription, setNewRoleDescription] = useState("");
const [createRoleLoading, setCreateRoleLoading] = useState(false);
// Create API key form
const [showCreateKey, setShowCreateKey] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyUserId, setNewKeyUserId] = useState("");
const [newKeyExpiry, setNewKeyExpiry] = useState("");
const [newKeyLoading, setNewKeyLoading] = useState(false);
const [createdKeyToken, setCreatedKeyToken] = useState<string | null>(null);
// Import state
const [importFile, setImportFile] = useState<File | null>(null);
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
const [tlsStatus, setTlsStatus] = useState<TlsStatus | null>(null);
const [manualCertDraft, setManualCertDraft] = useState("");
const [manualKeyDraft, setManualKeyDraft] = useState("");
const [manualUploading, setManualUploading] = useState(false);
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(0);
const [userTotal, setUserTotal] = useState(0);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
useEffect(() => {
loadSessions();
loadRoles();
loadApiKeys();
loadGeneralSettings();
}, []);
// Debounced so typing in the search box does not fire a request per keystroke.
useEffect(() => {
const timer = setTimeout(loadUsers, userSearch ? 250 : 0);
return () => clearTimeout(timer);
}, [userSearch, userPage]);
// A new search term starts again from the first page.
useEffect(() => {
setUserPage(0);
}, [userSearch]);
// The panel stays mounted across Settings pages, so an open user view
// would otherwise replace whichever section is picked next.
useEffect(() => {
setManageUser(null);
}, [section]);
useEffect(() => {
onEditingChange?.(manageUser !== null);
return () => onEditingChange?.(false);
}, [manageUser, onEditingChange]);
useEffect(() => {
if (editUserOpen && editUserTarget) {
setEditUserRoles([]);
setEditUserRolesLoading(true);
getUserRoles(editUserTarget.id)
.then(({ roles: r }) => setEditUserRoles(r))
.catch(() => {})
.finally(() => setEditUserRolesLoading(false));
}
}, [editUserOpen, editUserTarget]);
function loadUsers() {
// Paged server-side so an install with thousands of accounts does not
// ship the whole directory to render one screen of it.
getUserList({
search: userSearch.trim() || undefined,
limit: USERS_PAGE_SIZE,
offset: userPage * USERS_PAGE_SIZE,
})
.then(({ users: u, total }) => {
setUsers(
u.map((user) => ({
id: user.userId,
username: user.username,
isAdmin: user.is_admin,
isExternal: user.is_external ?? false,
passwordHash: user.password_hash,
dataUnlocked: user.data_unlocked,
secondFactorEnabled: user.second_factor_enabled,
})),
);
setUserTotal(total ?? u.length);
})
.catch(() => {});
}
function loadSessions() {
getSessions()
.then(({ sessions: s }) => setSessions(s))
.catch(() => {});
}
function loadRoles() {
getRoles()
.then(({ roles: r }) => setRoles(r))
.catch(() => {});
}
function loadApiKeys() {
getApiKeys()
.then(({ apiKeys: k }) => setApiKeys(k))
.catch(() => {});
}
async function loadGeneralSettings() {
try {
const [
reg,
pwLogin,
pwReset,
timeout,
monitoring,
level,
oidcProv,
secondFactorExternal,
notificationEndpoints,
branding,
] = await Promise.allSettled([
getRegistrationAllowed(),
getPasswordLoginAllowed(),
getPasswordResetAllowed(),
getSessionTimeout(),
getStatusCheckSettings(),
getLogLevel(),
getExternalAutoProvision(),
getSecondFactorAfterExternalLogin(),
getNotificationPrivateEndpoints(),
getBranding(),
]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
if (pwLogin.status === "fulfilled") {
// When forced on, the setting itself is off; show that and warn.
setPasswordLoginForced(!!pwLogin.value.forced);
setAllowPasswordLogin(
pwLogin.value.forced ? false : pwLogin.value.allowed,
);
}
if (oidcProv.status === "fulfilled")
setExternalAutoProvision(oidcProv.value.enabled);
if (secondFactorExternal.status === "fulfilled")
setSecondFactorAfterExternalLogin(secondFactorExternal.value.enabled);
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
if (timeout.status === "fulfilled")
setSessionTimeout(String(timeout.value.timeoutHours));
if (monitoring.status === "fulfilled") {
setStatusInterval(String(monitoring.value.statusCheckInterval));
}
if (level.status === "fulfilled") setLogLevel(level.value.level);
if (notificationEndpoints.status === "fulfilled") {
setNotificationPrivateEndpoints(notificationEndpoints.value);
}
if (branding.status === "fulfilled") {
setBrandingSettings(branding.value);
}
} catch {
// non-fatal
}
getTlsStatus()
.then((s) => setTlsStatus(s))
.catch(() => {});
}
function toggle(id: AdminSection | FeatureSectionId) {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleToggleRegistration() {
const newVal = !allowRegistration;
setAllowRegistration(newVal);
try {
await updateRegistrationAllowed(newVal);
} catch {
setAllowRegistration(!newVal);
toast.error(t("admin.updateRegistrationFailed"));
}
}
async function handleTogglePasswordLogin() {
const newVal = !allowPasswordLogin;
setAllowPasswordLogin(newVal);
try {
await updatePasswordLoginAllowed(newVal);
} catch (e) {
setAllowPasswordLogin(!newVal);
const msg = (e as ApiErrorLike).response?.data?.error;
toast.error(msg || t("admin.updatePasswordLoginFailed"));
}
}
async function handleToggleExternalAutoProvision() {
const newVal = !externalAutoProvision;
setExternalAutoProvision(newVal);
try {
await updateExternalAutoProvision(newVal);
} catch {
setExternalAutoProvision(!newVal);
toast.error(t("admin.updateExternalAutoProvisionFailed"));
}
}
async function handleToggleSecondFactorAfterExternalLogin() {
const newVal = !secondFactorAfterExternalLogin;
setSecondFactorAfterExternalLogin(newVal);
try {
await updateSecondFactorAfterExternalLogin(newVal);
} catch {
setSecondFactorAfterExternalLogin(!newVal);
toast.error(t("admin.updateSecondFactorAfterExternalLoginFailed"));
}
}
async function handleTogglePasswordReset() {
const newVal = !allowPasswordReset;
setAllowPasswordReset(newVal);
try {
await updatePasswordResetAllowed(newVal);
} catch {
setAllowPasswordReset(!newVal);
toast.error(t("admin.updatePasswordResetFailed"));
}
}
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
const previous = notificationPrivateEndpoints;
setNotificationPrivateEndpoints(hosts);
try {
setNotificationPrivateEndpoints(
await setNotificationPrivateEndpointsApi(hosts),
);
} catch {
setNotificationPrivateEndpoints(previous);
toast.error(t("admin.updateNotificationEndpointsFailed"));
}
}
async function handleSaveBranding() {
if (!brandingSettings) return;
setBrandingSaving(true);
try {
const saved = await updateBranding({
appName: brandingSettings.appName,
tagline: brandingSettings.tagline,
logo: brandingSettings.logo,
});
setBrandingSettings(saved);
applyBranding(saved);
toast.success(t("admin.brandingSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.brandingSaveFailed")));
} finally {
setBrandingSaving(false);
}
}
async function handleResetBrandingLogo() {
if (!brandingSettings) return;
setBrandingSaving(true);
try {
const saved = await updateBranding({ logo: null });
setBrandingSettings(saved);
applyBranding(saved);
toast.success(t("admin.brandingSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.brandingSaveFailed")));
} finally {
setBrandingSaving(false);
}
}
async function handleSaveSessionTimeout() {
const hours = parseInt(sessionTimeout, 10);
if (isNaN(hours) || hours < 1 || hours > 720) {
toast.error(t("admin.sessionTimeoutRange2"));
return;
}
try {
await updateSessionTimeout(hours);
toast.success(t("admin.sessionTimeoutSaved"));
} catch {
toast.error(t("admin.sessionTimeoutSaveFailed"));
}
}
async function handleSaveMonitoring() {
const status = parseInt(statusInterval, 10);
if (isNaN(status) || status < 5 || status > 3600) {
toast.error(t("admin.monitoringIntervalInvalid"));
return;
}
try {
await updateStatusCheckSettings({ statusCheckInterval: status });
toast.success(t("admin.monitoringSaved"));
} catch {
toast.error(t("admin.monitoringSaveFailed"));
}
}
async function handleSaveLogLevel(level: string) {
setLogLevel(level);
try {
await updateLogLevel(level);
} catch {
toast.error(t("admin.logLevelUpdateFailed"));
}
}
async function handleManualSslUpload() {
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
toast.error(t("admin.sslManualRequiresFields"));
return;
}
setManualUploading(true);
try {
const result = await uploadTlsCertificate({
certificate: manualCertDraft,
privateKey: manualKeyDraft,
});
setTlsStatus(result);
setManualCertDraft("");
setManualKeyDraft("");
toast.success(t("admin.sslManualUploadSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
} finally {
setManualUploading(false);
}
}
async function handleCreateUser() {
if (!newUsername.trim() || !newPassword.trim()) {
toast.error(t("admin.createUserRequired"));
return;
}
if (newPassword.length < 6) {
toast.error(t("admin.createUserPasswordTooShort"));
return;
}
setCreateUserLoading(true);
try {
await adminCreateUser(newUsername.trim(), newPassword);
toast.success(t("admin.createUserSuccess", { username: newUsername }));
setCreateUserOpen(false);
setNewUsername("");
setNewPassword("");
loadUsers();
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.createUserFailed")));
} finally {
setCreateUserLoading(false);
}
}
async function handleToggleAdmin(user: AdminUser) {
setEditUserLoading(true);
try {
if (user.isAdmin) {
await removeAdminStatus(user.id);
setEditUserTarget((prev) =>
prev ? { ...prev, isAdmin: false } : prev,
);
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, isAdmin: false } : u)),
);
} else {
await makeUserAdmin(user.id);
setEditUserTarget((prev) => (prev ? { ...prev, isAdmin: true } : prev));
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, isAdmin: true } : u)),
);
}
} catch {
toast.error(t("admin.updateAdminStatusFailed"));
} finally {
setEditUserLoading(false);
}
}
async function handleRevokeUserSessions(userId: string) {
try {
await revokeAllUserSessions(userId);
toast.success(t("admin.allSessionsRevoked"));
loadSessions();
} catch {
toast.error(t("admin.revokeSessionsFailed"));
}
}
async function handleDeleteEditUser() {
if (!editUserTarget) return;
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: editUserTarget.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
setEditUserLoading(true);
try {
await deleteUser(editUserTarget.username);
setUsers((prev) => prev.filter((u) => u.id !== editUserTarget.id));
setEditUserOpen(false);
setEditUserTarget(null);
toast.success(
t("admin.deleteUserSuccess", { username: editUserTarget.username }),
);
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.deleteUserFailed")));
} finally {
setEditUserLoading(false);
}
}
async function handleCreateRole() {
if (!newRoleName.trim() || !newRoleDisplayName.trim()) {
toast.error(t("admin.createRoleRequired"));
return;
}
setCreateRoleLoading(true);
const displayName = newRoleDisplayName.trim();
try {
await createRole({
name: newRoleName.trim(),
displayName,
description: newRoleDescription.trim() || null,
});
setShowCreateRole(false);
setNewRoleName("");
setNewRoleDisplayName("");
setNewRoleDescription("");
toast.success(t("admin.createRoleSuccess", { name: displayName }));
loadRoles();
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.createRoleFailed")));
} finally {
setCreateRoleLoading(false);
}
}
async function handleCreateApiKey() {
if (!newKeyName.trim()) {
toast.error(t("admin.apiKeyNameRequired"));
return;
}
if (!newKeyUserId.trim()) {
toast.error(t("admin.apiKeyUserRequired"));
return;
}
setNewKeyLoading(true);
try {
const created: CreatedApiKey = await createApiKey(
newKeyName.trim(),
newKeyUserId.trim(),
newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined,
);
setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]);
setCreatedKeyToken(created.token);
setNewKeyName("");
setNewKeyUserId("");
setNewKeyExpiry("");
toast.success(t("admin.apiKeyCreatedSuccess", { name: created.name }));
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.apiKeyCreateFailed")));
} finally {
setNewKeyLoading(false);
}
}
async function handleExportDatabase() {
setExportLoading(true);
try {
const apiUrl = getDatabaseTransferUrl("export", {
electron: isElectron(),
configuredServerUrl: null,
location: window.location,
});
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({}),
});
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.exportSuccess"));
} else {
const err = await response.json().catch(() => ({}));
toast.error(err.error || t("admin.exportFailed"));
}
} catch {
toast.error(t("admin.exportFailed"));
} finally {
setExportLoading(false);
}
}
async function handleImportDatabase() {
if (!importFile) {
toast.error(t("admin.importSelectFile"));
return;
}
setImportLoading(true);
try {
const apiUrl = getDatabaseTransferUrl("import", {
electron: isElectron(),
configuredServerUrl: null,
location: window.location,
});
const formData = new FormData();
formData.append("file", importFile);
const response = await fetch(apiUrl, {
method: "POST",
credentials: "include",
body: formData,
});
if (response.ok) {
const result = await response.json();
if (result.success) {
const s = result.summary;
const total =
(s.sshHostsImported || 0) +
(s.sshCredentialsImported || 0) +
(s.pluginItemsImported || 0) +
(s.settingsImported || 0);
toast.success(
t("admin.importCompleted", { total, skipped: s.skippedItems || 0 }),
);
setImportFile(null);
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error(
t("admin.importFailed", {
error: result.summary?.errors?.join(", ") || "Unknown error",
}),
);
}
} else {
const err = await response.json().catch(() => ({}));
toast.error(err.error || t("admin.importError"));
}
} catch {
toast.error(t("admin.importError"));
} finally {
setImportLoading(false);
}
}
const isOpen = (id: AdminSection | FeatureSectionId) =>
section ? section === id : openSections.has(id);
if (manageUser) {
return (
<AdminUserManagePanel
key={manageUser.id}
user={manageUser}
roles={roles}
onBack={() => setManageUser(null)}
onOpenHostTab={onOpenHostTab}
onUserDeleted={() => {
setUsers((prev) => prev.filter((u) => u.id !== manageUser.id));
setManageUser(null);
}}
onSecondFactorsReset={() => {
setUsers((prev) =>
prev.map((u) =>
u.id === manageUser.id ? { ...u, secondFactorEnabled: false } : u,
),
);
}}
/>
);
}
return (
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto"
}
>
<AdminGeneralSettingsSection
open={isOpen("general")}
onToggle={() => toggle("general")}
notificationPrivateEndpoints={notificationPrivateEndpoints}
onSaveNotificationPrivateEndpoints={
handleSaveNotificationPrivateEndpoints
}
allowRegistration={allowRegistration}
handleToggleRegistration={handleToggleRegistration}
allowPasswordLogin={allowPasswordLogin}
passwordLoginForced={passwordLoginForced && !allowPasswordLogin}
handleTogglePasswordLogin={handleTogglePasswordLogin}
externalAutoProvision={externalAutoProvision}
handleToggleExternalAutoProvision={handleToggleExternalAutoProvision}
secondFactorAfterExternalLogin={secondFactorAfterExternalLogin}
handleToggleSecondFactorAfterExternalLogin={
handleToggleSecondFactorAfterExternalLogin
}
allowPasswordReset={allowPasswordReset}
handleTogglePasswordReset={handleTogglePasswordReset}
sessionTimeout={sessionTimeout}
setSessionTimeout={setSessionTimeout}
handleSaveSessionTimeout={handleSaveSessionTimeout}
statusInterval={statusInterval}
setStatusInterval={setStatusInterval}
handleSaveMonitoring={handleSaveMonitoring}
logLevel={logLevel}
handleSaveLogLevel={handleSaveLogLevel}
/>
<AdminUsersSection
open={isOpen("users")}
onToggle={() => toggle("users")}
users={users}
setUsers={setUsers}
loadUsers={loadUsers}
setCreateUserOpen={setCreateUserOpen}
setEditUserTarget={setEditUserTarget}
setEditUserOpen={setEditUserOpen}
setLinkAccountTarget={setLinkAccountTarget}
setLinkAccountOpen={setLinkAccountOpen}
setUnlinkAccountTarget={setUnlinkAccountTarget}
setUnlinkAccountOpen={setUnlinkAccountOpen}
onManageUser={setManageUser}
search={userSearch}
onSearchChange={setUserSearch}
page={userPage}
pageSize={USERS_PAGE_SIZE}
total={userTotal}
onPageChange={setUserPage}
/>
<AdminSessionsSection
open={isOpen("sessions")}
onToggle={() => toggle("sessions")}
sessions={sessions}
setSessions={setSessions}
loadSessions={loadSessions}
/>
<AdminRolesSection
open={isOpen("roles")}
onToggle={() => toggle("roles")}
roles={roles}
setRoles={setRoles}
showCreateRole={showCreateRole}
setShowCreateRole={setShowCreateRole}
newRoleName={newRoleName}
setNewRoleName={setNewRoleName}
newRoleDisplayName={newRoleDisplayName}
setNewRoleDisplayName={setNewRoleDisplayName}
newRoleDescription={newRoleDescription}
setNewRoleDescription={setNewRoleDescription}
handleCreateRole={handleCreateRole}
createRoleLoading={createRoleLoading}
/>
<AdminHostDefaultsSection
open={isOpen("host-defaults")}
onToggle={() => toggle("host-defaults")}
/>
<AdminBrandingSection
open={isOpen("branding")}
onToggle={() => toggle("branding")}
settings={brandingSettings}
setSettings={setBrandingSettings}
saving={brandingSaving}
onSave={() => void handleSaveBranding()}
onResetLogo={() => void handleResetBrandingLogo()}
/>
<AdminDatabaseSection
open={isOpen("database")}
onToggle={() => toggle("database")}
importFile={importFile}
setImportFile={setImportFile}
exportLoading={exportLoading}
importLoading={importLoading}
handleExportDatabase={handleExportDatabase}
handleImportDatabase={handleImportDatabase}
/>
<AdminSSLSection
open={isOpen("ssl")}
onToggle={() => toggle("ssl")}
status={tlsStatus}
manualCertDraft={manualCertDraft}
setManualCertDraft={setManualCertDraft}
manualKeyDraft={manualKeyDraft}
setManualKeyDraft={setManualKeyDraft}
manualUploading={manualUploading}
handleManualUpload={handleManualSslUpload}
/>
<AdminApiKeysSection
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
loadApiKeys={loadApiKeys}
showCreateKey={showCreateKey}
setShowCreateKey={setShowCreateKey}
createdKeyToken={createdKeyToken}
setCreatedKeyToken={setCreatedKeyToken}
newKeyName={newKeyName}
setNewKeyName={setNewKeyName}
newKeyUserId={newKeyUserId}
setNewKeyUserId={setNewKeyUserId}
newKeyExpiry={newKeyExpiry}
setNewKeyExpiry={setNewKeyExpiry}
users={users}
handleCreateApiKey={handleCreateApiKey}
newKeyLoading={newKeyLoading}
/>
<AdminAuditLogSection
open={isOpen("audit-log")}
onToggle={() => toggle("audit-log")}
users={users}
/>
{featureSettings.map((plugin) => (
<FeatureSettingsSection
key={plugin.id}
plugin={plugin}
scope="admin"
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
<AdminCreateUserDialog
open={createUserOpen}
onOpenChange={setCreateUserOpen}
newUsername={newUsername}
setNewUsername={setNewUsername}
newPassword={newPassword}
setNewPassword={setNewPassword}
showNewPassword={showNewPassword}
setShowNewPassword={setShowNewPassword}
handleCreateUser={handleCreateUser}
createUserLoading={createUserLoading}
/>
<AdminEditUserDialog
open={editUserOpen}
onOpenChange={setEditUserOpen}
editUserTarget={editUserTarget}
editUserLoading={editUserLoading}
editUserRoles={editUserRoles}
editUserRolesLoading={editUserRolesLoading}
roles={roles}
setEditUserRoles={setEditUserRoles}
handleToggleAdmin={handleToggleAdmin}
handleRevokeUserSessions={handleRevokeUserSessions}
handleDeleteEditUser={handleDeleteEditUser}
/>
<AdminLinkAccountDialog
open={linkAccountOpen}
onOpenChange={setLinkAccountOpen}
linkAccountTarget={linkAccountTarget}
setUsers={setUsers}
users={users}
/>
<AdminUnlinkAccountDialog
open={unlinkAccountOpen}
onOpenChange={setUnlinkAccountOpen}
unlinkAccountTarget={unlinkAccountTarget}
onSuccess={(userId) =>
setUsers((prev) =>
prev.map((u) =>
u.id === userId
? { ...u, isExternal: false, passwordHash: undefined }
: u,
),
)
}
/>
</div>
);
}
-530
View File
@@ -1,530 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { AdminHostTags } from "./AdminHostTags";
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
import type { TlsStatus } from "@/api/tls-api";
import { DocsLink } from "@/components/docs-link";
type GeneralSettingsSectionProps = {
open: boolean;
onToggle: () => void;
notificationPrivateEndpoints: string[];
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
allowRegistration: boolean;
handleToggleRegistration: () => void;
allowPasswordLogin: boolean;
passwordLoginForced?: boolean;
handleTogglePasswordLogin: () => void;
externalAutoProvision: boolean;
handleToggleExternalAutoProvision: () => void;
secondFactorAfterExternalLogin: boolean;
handleToggleSecondFactorAfterExternalLogin: () => void;
allowPasswordReset: boolean;
handleTogglePasswordReset: () => void;
sessionTimeout: string;
setSessionTimeout: Dispatch<SetStateAction<string>>;
handleSaveSessionTimeout: () => void;
statusInterval: string;
setStatusInterval: Dispatch<SetStateAction<string>>;
handleSaveMonitoring: () => void;
logLevel: string;
handleSaveLogLevel: (level: string) => void;
};
export function AdminGeneralSettingsSection({
open,
onToggle,
notificationPrivateEndpoints,
onSaveNotificationPrivateEndpoints,
allowRegistration,
handleToggleRegistration,
allowPasswordLogin,
passwordLoginForced,
handleTogglePasswordLogin,
externalAutoProvision,
handleToggleExternalAutoProvision,
secondFactorAfterExternalLogin,
handleToggleSecondFactorAfterExternalLogin,
allowPasswordReset,
handleTogglePasswordReset,
sessionTimeout,
setSessionTimeout,
handleSaveSessionTimeout,
statusInterval,
setStatusInterval,
handleSaveMonitoring,
logLevel,
handleSaveLogLevel,
}: GeneralSettingsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionGeneral")}
icon={<Settings className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.notificationPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.notificationPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={notificationPrivateEndpoints.join(", ")}
placeholder="ntfy.internal, 192.168.1.20"
onBlur={(event) =>
onSaveNotificationPrivateEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
<div className="border-t border-border pt-3 mt-2">
<SettingRow
label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")}
>
<AdminToggle
on={allowRegistration}
onToggle={handleToggleRegistration}
/>
</SettingRow>
</div>
<SettingRow
label={t("admin.allowPasswordLogin")}
description={t("admin.allowPasswordLoginDesc")}
>
<AdminToggle
on={allowPasswordLogin}
onToggle={handleTogglePasswordLogin}
/>
</SettingRow>
{passwordLoginForced && (
<p className="text-[10px] text-destructive">
{t("admin.passwordLoginForced")}
</p>
)}
<SettingRow
label={t("admin.externalAutoProvision")}
description={t("admin.externalAutoProvisionDesc")}
>
<AdminToggle
on={externalAutoProvision}
onToggle={handleToggleExternalAutoProvision}
/>
</SettingRow>
<SettingRow
label={t("admin.secondFactorAfterExternalLogin")}
description={t("admin.secondFactorAfterExternalLoginDesc")}
>
<AdminToggle
on={secondFactorAfterExternalLogin}
onToggle={handleToggleSecondFactorAfterExternalLogin}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordReset")}
description={t("admin.allowPasswordResetDesc")}
>
<AdminToggle
on={allowPasswordReset}
onToggle={handleTogglePasswordReset}
/>
</SettingRow>
<AdminPluginDeveloperMode />
<div className="flex flex-col gap-2 pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.sessionTimeout")}
</span>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={720}
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.hours")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveSessionTimeout}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionTimeoutRange")}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.monitoringDefaults")}
</span>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.statusCheck")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
value={statusInterval}
onChange={(e) => setStatusInterval(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.sec")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveMonitoring}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.logLevel")}
</span>
<div className="flex gap-1.5 flex-wrap">
{["debug", "info", "warn", "error"].map((l) => (
<button
key={l}
onClick={() => handleSaveLogLevel(l)}
className={`px-2 py-1 text-[10px] font-semibold border capitalize transition-colors ${logLevel === l ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{l}
</button>
))}
</div>
</div>
</div>
</AccordionSection>
);
}
type DatabaseSectionProps = {
open: boolean;
onToggle: () => void;
importFile: File | null;
setImportFile: Dispatch<SetStateAction<File | null>>;
exportLoading: boolean;
importLoading: boolean;
handleExportDatabase: () => void;
handleImportDatabase: () => void;
};
export function AdminDatabaseSection({
open,
onToggle,
importFile,
setImportFile,
exportLoading,
importLoading,
handleExportDatabase,
handleImportDatabase,
}: DatabaseSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionDatabase")}
icon={<Database className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium">
{t("admin.exportDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.exportDatabaseDesc")}
</span>
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
onClick={handleExportDatabase}
disabled={exportLoading}
>
{exportLoading ? t("admin.exporting") : t("admin.export")}
</Button>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-xs font-medium">
{t("admin.importDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{importFile
? t("admin.importDatabaseSelected", { name: importFile.name })
: t("admin.importDatabaseDesc")}
</span>
<div className="flex items-center gap-2 mt-1">
<div className="relative">
<input
type="file"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
variant="outline"
size="sm"
className="pointer-events-none text-xs"
>
{importFile ? t("admin.changeFile") : t("admin.selectFile")}
</Button>
</div>
{importFile && (
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleImportDatabase}
disabled={importLoading}
>
{importLoading ? t("admin.importing") : t("admin.import")}
</Button>
)}
</div>
</div>
</div>
</AccordionSection>
);
}
type AdminHostDefaultsSectionProps = {
open: boolean;
onToggle: () => void;
};
export function AdminHostDefaultsSection({
open,
onToggle,
}: AdminHostDefaultsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionHostDefaults")}
icon={<Server className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("hostDefaults.adminSectionDescription")}
</span>
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={() =>
window.dispatchEvent(
new CustomEvent("termix:open-host-defaults", {
detail: { level: "admin" },
}),
)
}
>
{t("hostDefaults.editServerDefaults")}
</Button>
<div className="border-t border-border pt-3">
<AdminHostTags />
</div>
</div>
</AccordionSection>
);
}
function certState(
status: TlsStatus | null,
): "none" | "valid" | "expiring" | "expired" {
const cert = status?.certificate;
if (!cert) return "none";
const left = new Date(cert.notAfter).getTime() - Date.now();
if (left <= 0) return "expired";
if (left < 30 * 86_400_000) return "expiring";
return "valid";
}
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-warning",
expired: "text-destructive",
};
type AdminSSLSectionProps = {
open: boolean;
onToggle: () => void;
status: TlsStatus | null;
manualCertDraft: string;
setManualCertDraft: Dispatch<SetStateAction<string>>;
manualKeyDraft: string;
setManualKeyDraft: Dispatch<SetStateAction<string>>;
manualUploading: boolean;
handleManualUpload: () => void;
};
export function AdminSSLSection({
open,
onToggle,
status,
manualCertDraft,
setManualCertDraft,
manualKeyDraft,
setManualKeyDraft,
manualUploading,
handleManualUpload,
}: AdminSSLSectionProps) {
const { t } = useTranslation();
const cert = status?.certificate ?? null;
const state = certState(status);
const expiry = cert ? new Date(cert.notAfter).toLocaleDateString() : "";
const certStatusLabel: Record<ReturnType<typeof certState>, string> = {
none: t("admin.sslCertStatusNone"),
valid: t("admin.sslCertStatusValid"),
expiring: t("admin.sslCertStatusExpiring"),
expired: t("admin.sslCertStatusExpired"),
};
return (
<AccordionSection
label={t("admin.sectionSsl")}
icon={<Lock className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.sslDescription")}{" "}
<DocsLink core="https">{t("admin.sslDocsLink")}</DocsLink>
</span>
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslCertStatus")}
</span>
<span
className={`text-xs font-medium ${CERT_STATUS_STYLES[state]}`}
>
{certStatusLabel[state]}
</span>
</div>
{cert && (
<>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslNames", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslIssuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", { date: expiry })}
</span>
{cert.selfSigned && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslSelfSigned")}
</span>
)}
{status?.renewal && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslRenewedBy", {
plugin: status.renewal.pluginName,
})}
</span>
)}
</>
)}
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-warning/40 bg-warning/10 text-[10px] text-warning">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest border-t border-border pt-2">
{t("admin.sslManualTitle")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualCert")}
</label>
<textarea
rows={5}
value={manualCertDraft}
onChange={(e) => setManualCertDraft(e.target.value)}
placeholder={t("admin.sslManualCertPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualKey")}
</label>
<textarea
rows={5}
value={manualKeyDraft}
onChange={(e) => setManualKeyDraft(e.target.value)}
placeholder={t("admin.sslManualKeyPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslManualDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleManualUpload}
disabled={manualUploading}
>
<RefreshCw
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
/>
{manualUploading
? t("admin.sslManualUploadLoading")
: t("admin.sslManualUpload")}
</Button>
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
{t("admin.sslInfoNote")}
</span>
</div>
</AccordionSection>
);
}
-68
View File
@@ -1,68 +0,0 @@
import type React from "react";
import { ChevronDown } from "lucide-react";
import { useSettingsEmbedded } from "@/settings/settings-embed";
export function AdminToggle({
on,
onToggle,
disabled = false,
}: {
on: boolean;
onToggle: () => void;
disabled?: boolean;
}) {
return (
<button
onClick={onToggle}
disabled={disabled}
className={`relative inline-flex h-5 w-9 shrink-0 items-center border-2 transition-colors ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"} ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
);
}
export function AccordionSection({
label,
icon,
open,
onToggle,
children,
}: {
label: string;
icon: React.ReactNode;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (embedded) {
if (!open) return null;
return (
<div className="flex flex-col border border-border bg-card px-3 pb-3">
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden shrink-0">
<button
onClick={onToggle}
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
>
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="text-xs font-bold uppercase tracking-widest text-foreground flex-1">
{label}
</span>
<ChevronDown
className={`size-3.5 text-muted-foreground shrink-0 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div className="border-t border-border px-3 pb-3">{children}</div>
)}
</div>
);
}
-576
View File
@@ -1,576 +0,0 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
assignRoleToUser,
linkExternalToPasswordAccount,
removeRoleFromUser,
unlinkExternalFromPasswordAccount,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { roleLabel } from "@/lib/role-label";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
message?: string;
};
function apiErrorMessage(error: unknown, fallback: string) {
const err = error as ApiErrorLike;
return err.response?.data?.error || err.message || fallback;
}
type CreateUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
newUsername: string;
setNewUsername: Dispatch<SetStateAction<string>>;
newPassword: string;
setNewPassword: Dispatch<SetStateAction<string>>;
showNewPassword: boolean;
setShowNewPassword: Dispatch<SetStateAction<boolean>>;
handleCreateUser: () => void;
createUserLoading: boolean;
};
export function AdminCreateUserDialog({
open,
onOpenChange,
newUsername,
setNewUsername,
newPassword,
setNewPassword,
showNewPassword,
setShowNewPassword,
handleCreateUser,
createUserLoading,
}: CreateUserDialogProps) {
const { t } = useTranslation();
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.createUserTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</InlineView>
);
}
type EditUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
editUserTarget: AdminUser | null;
editUserLoading: boolean;
editUserRoles: UserRole[];
editUserRolesLoading: boolean;
roles: Role[];
setEditUserRoles: Dispatch<SetStateAction<UserRole[]>>;
handleToggleAdmin: (user: AdminUser) => void;
handleRevokeUserSessions: (userId: string) => void;
handleDeleteEditUser: () => void;
};
export function AdminEditUserDialog({
open,
onOpenChange,
editUserTarget,
editUserLoading,
editUserRoles,
editUserRolesLoading,
roles,
setEditUserRoles,
handleToggleAdmin,
handleRevokeUserSessions,
handleDeleteEditUser,
}: EditUserDialogProps) {
const { t } = useTranslation();
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.editUserTitle", { username: editUserTarget?.username })}
>
<p className="text-xs text-muted-foreground">{t("admin.editUserDesc")}</p>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isExternal && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">{t("admin.userRoles")}</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.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"
>
{roleLabel(t, ur.roleDisplayName)}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter((r) => r.roleId !== ur.roleId),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(editUserTarget.id, r.id);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.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>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
</div>
</div>
)}
</InlineView>
);
}
type UnlinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
unlinkAccountTarget: { id: string; username: string } | null;
onSuccess: (userId: string) => void;
};
export function AdminUnlinkAccountDialog({
open,
onOpenChange,
unlinkAccountTarget,
onSuccess,
}: UnlinkAccountDialogProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!unlinkAccountTarget) return;
setSubmitting(true);
try {
await unlinkExternalFromPasswordAccount(unlinkAccountTarget.id);
toast.success(t("admin.unlinkAccountSuccess"));
onSuccess(unlinkAccountTarget.id);
onOpenChange(false);
} catch (error: unknown) {
toast.error(apiErrorMessage(error, t("admin.unlinkAccountFailed")));
} finally {
setSubmitting(false);
}
};
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.unlinkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</p>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</InlineView>
);
}
type LinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
linkAccountTarget: {
id: string;
username: string;
isExternal: boolean;
} | null;
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
users: AdminUser[];
};
export function AdminLinkAccountDialog({
open,
onOpenChange,
linkAccountTarget,
setUsers,
users,
}: LinkAccountDialogProps) {
const { t } = useTranslation();
const [otherUsername, setOtherUsername] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) setOtherUsername("");
}, [open, linkAccountTarget]);
const isExternalInitiator = linkAccountTarget?.isExternal ?? true;
const handleSubmit = async () => {
const trimmed = otherUsername.trim();
if (!linkAccountTarget || !trimmed) return;
setSubmitting(true);
try {
if (isExternalInitiator) {
await linkExternalToPasswordAccount(linkAccountTarget.id, trimmed);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
} else {
const oidcUser = users.find(
(u) => u.username === trimmed && u.isExternal && !u.passwordHash,
);
if (!oidcUser) {
toast.error(t("admin.linkAccountExternalNotFound"));
return;
}
await linkExternalToPasswordAccount(
oidcUser.id,
linkAccountTarget.username,
);
setUsers((prev) => prev.filter((u) => u.id !== oidcUser.id));
}
toast.success(t("admin.linkAccountSuccess", { username: trimmed }));
setOtherUsername("");
onOpenChange(false);
} catch (error: unknown) {
toast.error(apiErrorMessage(error, t("admin.linkAccountFailed")));
} finally {
setSubmitting(false);
}
};
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.linkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountExternalUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isExternalInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountExternalPlaceholder")
}
autoFocus
disabled={submitting}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
</div>
</InlineView>
);
}
-996
View File
@@ -1,996 +0,0 @@
import { Select2 } from "@/components/select2";
import { useConfirm } from "@/components/surface/surface-scope";
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 { TabStrip } from "./HostManagerTabs";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useActionSlot } from "@/hooks/use-action-slot";
import { docsUrl } from "@/lib/docs";
import { roleLabel } from "@/lib/role-label";
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 [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
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>
);
// Editing one of the user's hosts or credentials takes over the panel.
if (editor) {
const close = () => setEditor(null);
return (
<div className="flex flex-col flex-1 min-h-0">
<PanelShell
leading={<BackButton onClick={close} />}
title={t("admin.manageEditorBack", { username: user.username })}
docs={docsUrl("users")}
status={
editor.kind === "host"
? (editor.host?.name ?? t("manage.newHost"))
: (editor.credential?.name ?? t("manage.newCredential"))
}
scroll={false}
>
{editor.kind === "host" ? (
<HostEditor
key={editor.host ? editor.host.id : "new-host"}
host={editor.host}
onBack={close}
onSave={() => {
close();
reloadHosts();
}}
protocols={editorProtocols}
onProtocolChange={(p) =>
setEditorProtocols((prev) => ({ ...prev, ...p }))
}
hosts={hosts}
credentials={credentials}
adminTargetUserId={user.id}
/>
) : (
<CredentialEditorView
key={editor.credential ? editor.credential.id : "new-cred"}
credential={editor.credential}
existingFolders={Array.from(
new Set(
credentials
.map((c) => c.folder)
.filter((f): f is string => !!f),
),
).sort()}
onBack={close}
onSave={() => {
close();
reloadCredentials();
}}
adminTargetUserId={user.id}
/>
)}
</PanelShell>
</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.isExternal && !user.passwordHash ? (
<span className="text-xs text-muted-foreground">
{t("admin.resetPasswordExternalOnly")}
</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"
>
{roleLabel(t, 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));
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));
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={() => {
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="flex gap-2.5 text-[10px] text-muted-foreground min-w-0">
<span className="truncate">{cred.username || "-"}</span>
<span className="shrink-0">{cred.type}</span>
</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={() => {
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")}
<Select2
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>
</Select2>
<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>
</div>
);
}
-617
View File
@@ -1,617 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Eye,
EyeOff,
LogOut,
PanelRight,
Pin,
Search,
SlidersHorizontal,
SquareArrowOutUpRight,
} from "lucide-react";
import type { TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import {
groupRailItems,
moveRailItem,
useRailItems,
type RailGroup,
type RailItemDef,
} from "./rail-items";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
import { isElectron } from "@/lib/electron";
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
"hosts" | "credentials" | "quick-connect" | ToolsTab | "connections";
export type RailView = CoreRailView | (string & {});
const btnBase =
"relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring";
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60";
const activeClass = "text-accent-brand bg-accent-brand/10";
function RailLabel({
expanded,
children,
}: {
expanded: boolean;
children: React.ReactNode;
}) {
return (
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
expanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{children}
</span>
);
}
function RailIcon({
children,
useBadge,
}: {
children: React.ReactNode;
useBadge?: () => number | null | undefined;
}) {
return (
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{children}
{useBadge && (
<RailBadge useBadge={useBadge} className="-top-1.5 -right-2" />
)}
</span>
);
}
function Rule({ expanded }: { expanded: boolean }) {
return (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: expanded ? `calc(100% - ${rem(16)})` : rem(20) }}
/>
);
}
/**
* The band name rides on the divider between bands, so expanding the rail
* never pushes items down from under the pointer.
*/
function GroupDivider({
group,
expanded,
}: {
group: RailGroup;
expanded: boolean;
}) {
const { t } = useTranslation();
return (
<div className="my-0.5 flex h-3 shrink-0 items-center gap-1.5 px-2.5">
<span
aria-hidden={!expanded}
className={`shrink-0 overflow-hidden text-[10px] font-semibold uppercase leading-none tracking-widest whitespace-nowrap text-muted-foreground/60 transition-[opacity,max-width] duration-200 ${
expanded ? "max-w-32 opacity-100 delay-75" : "max-w-0 opacity-0"
}`}
>
{t(`nav.group.${group}`)}
</span>
<div className="h-px min-w-3 flex-1 bg-border" />
</div>
);
}
interface MenuTarget {
id: string;
title: string;
promotable?: boolean;
rightDockable?: boolean;
hideable: boolean;
}
export function AppRail({
railView,
sidebarOpen,
username,
isAdmin,
pluginsSettled = true,
onRailClick,
onOpenTab,
onOpenInRightDock,
onOpenSettings,
onOpenNavigationSettings,
onOpenPalette,
onLogout,
}: {
railView: RailView;
sidebarOpen: boolean;
username: string;
isAdmin: boolean;
/** False while plugins are still registering their rail items. */
pluginsSettled?: boolean;
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void;
onOpenSettings: () => void;
/** The rail menu's own entry, which jumps to the navigation settings. */
onOpenNavigationSettings: () => void;
onOpenPalette?: () => void;
onLogout: (options?: { manual?: boolean }) => void;
}) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(() => readRailPreference("pinAppRail"));
const [expandOnHover, setExpandOnHover] = useState(() =>
readRailPreference("expandAppRailOnHover"),
);
const [showPinButton, setShowPinButton] = useState(() =>
readRailPreference("showPinAppRailButton"),
);
const [hoverPushes, setHoverPushes] = useState(() =>
readRailPreference("appRailHoverPushesContent"),
);
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
const [managing, setManaging] = useState(false);
const hiddenTabs = useHiddenRailTabs();
useEffect(() => {
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
const hoverHandler = () =>
setExpandOnHover(readRailPreference("expandAppRailOnHover"));
const showPinButtonHandler = () =>
setShowPinButton(readRailPreference("showPinAppRailButton"));
const pushHandler = () =>
setHoverPushes(readRailPreference("appRailHoverPushesContent"));
window.addEventListener("pinAppRailChanged", pinHandler);
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.addEventListener("appRailHoverPushesContentChanged", pushHandler);
window.addEventListener(
"showPinAppRailButtonChanged",
showPinButtonHandler,
);
return () => {
window.removeEventListener("pinAppRailChanged", pinHandler);
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.removeEventListener(
"appRailHoverPushesContentChanged",
pushHandler,
);
window.removeEventListener(
"showPinAppRailButtonChanged",
showPinButtonHandler,
);
};
}, []);
useEffect(() => {
if (!menuPos) return;
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest("[data-rail-context-menu]")) {
setMenuPos(null);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuPos(null);
};
window.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [menuPos]);
// Plugins add and remove rail items at runtime, and permissions hide some.
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const [dragId, setDragId] = useState<string | null>(null);
const [dropBefore, setDropBefore] = useState<string | null>(null);
const railExpanded = pinned || (expandOnHover && hovered) || managing;
const mainItems = railItems.filter((item) => item.placement !== "footer");
const bands = useMemo(
() => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))),
[mainItems, hiddenTabs],
);
const hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id));
const footerItems = railItems.filter(
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
);
useEffect(() => {
if (hiddenItems.length === 0) setManaging(false);
}, [hiddenItems.length]);
const togglePinned = () => {
setRailPreference("pinAppRail", !pinned);
setMenuPos(null);
};
const toggleExpandOnHover = () => {
setRailPreference("expandAppRailOnHover", !expandOnHover);
setMenuPos(null);
};
const sameBand = (a: string, b: string) =>
(mainItems.find((item) => item.id === a)?.group ?? "tools") ===
(mainItems.find((item) => item.id === b)?.group ?? "tools");
const renderItem = (item: RailItemDef) => {
const Icon = item.icon;
const title = t(item.labelKey);
const isTab = item.kind === "tab";
const active = !isTab && sidebarOpen && railView === item.id;
const reorderable = item.placement !== "footer" && !!uiPrefs;
const dropHere =
dragId !== null && dropBefore === item.id && dragId !== item.id;
return (
<button
key={item.id}
type="button"
draggable={reorderable}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
setDragId(item.id);
}}
onDragEnd={() => {
setDragId(null);
setDropBefore(null);
}}
onDragOver={(e) => {
if (!dragId || dragId === item.id || !sameBand(dragId, item.id))
return;
e.preventDefault();
setDropBefore(item.id);
}}
onDrop={(e) => {
if (!dragId || !sameBand(dragId, item.id)) return;
e.preventDefault();
uiPrefs?.setOverride(
"rail",
"order",
moveRailItem(mainItems, dragId, item.id),
);
setDragId(null);
setDropBefore(null);
}}
onClick={(e) => {
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() =>
setMenuTarget({
id: item.id,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
hideable: !item.alwaysVisible && item.hideable !== false,
})
}
data-rail-item=""
aria-current={active ? "page" : undefined}
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
style={btnStyle}
className={`${btnBase} ${active ? activeClass : idle} ${dragId === item.id ? "opacity-40" : ""} ${dropHere ? "relative before:absolute before:inset-x-1 before:-top-0.5 before:h-0.5 before:bg-accent-brand" : ""}`}
>
<RailIcon useBadge={item.useBadge}>
<Icon size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{title}</RailLabel>
</button>
);
};
// Expanding on hover draws over the content instead of pushing it, so open
// sessions like remote desktop are not resized each time, unless the user
// asked for the old behavior.
const takesSpace = hoverPushes ? railExpanded : pinned || managing;
return (
<div
// The desktop app keeps the rail however narrow its window gets.
className={`${isElectron() ? "flex" : "hidden md:flex"} relative shrink-0 transition-[width] duration-200 min-h-0`}
style={{ width: rem(takesSpace ? 172 : 40) }}
>
<div
className="absolute inset-y-0 left-0 z-40 flex flex-col items-stretch bg-sidebar border-r border-border overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
style={{ width: rem(railExpanded ? 172 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => {
e.preventDefault();
// Keep the menu on screen when right-clicking near the viewport edges
const MENU_W = 200;
const MENU_H = 180;
setMenuPos({
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
});
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
setMenuTarget(null);
}
}}
>
{onOpenPalette && (
<>
<button
type="button"
onClick={onOpenPalette}
title={t("nav.searchEverything")}
aria-label={t("nav.searchEverything")}
style={btnStyle}
className={`${btnBase} ${idle}`}
>
<RailIcon>
<Search size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
{!pluginsSettled
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
<div
key={`rail-skeleton-${i}`}
style={btnStyle}
className="flex items-center h-7 shrink-0"
>
<Skeleton className="size-4 shrink-0" />
</div>
))
: bands.map((band, i) => (
<div key={band.group} className="flex flex-col gap-1">
{i > 0 && (
<GroupDivider group={band.group} expanded={railExpanded} />
)}
{band.items.map(renderItem)}
</div>
))}
{pluginsSettled && hiddenItems.length > 0 && (
<>
<Rule expanded={railExpanded} />
<button
type="button"
onClick={() => setManaging((v) => !v)}
aria-expanded={managing}
title={t("nav.hiddenCount", { count: hiddenItems.length })}
style={btnStyle}
className={`${btnBase} ${managing ? activeClass : idle}`}
>
<RailIcon>
<EyeOff size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{t("nav.hiddenCount", { count: hiddenItems.length })}
</RailLabel>
</button>
{managing &&
hiddenItems.map((item) => {
const Icon = item.icon;
return (
<button
key={`hidden-${item.id}`}
type="button"
onClick={() => void toggleHiddenRailTab(item.id)}
title={t("nav.showInRail")}
style={btnStyle}
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
>
<RailIcon>
<Icon size={16} />
</RailIcon>
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
{t(item.labelKey)}
</span>
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
</button>
);
})}
</>
)}
</div>
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
{showPinButton && (
<>
<button
type="button"
onClick={() => setRailPreference("pinAppRail", !pinned)}
style={btnStyle}
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
className={`${btnBase} ${pinned ? activeClass : idle}`}
>
<RailIcon>
<Pin size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{pinned
? t("nav.collapseSideMenu")
: t("nav.keepSideMenuOpen")}
</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
{footerItems.map(renderItem)}
{footerItems.length > 0 && !isElectron() && (
<Rule expanded={railExpanded} />
)}
{/* The desktop signs in to its own local profile by itself. */}
{!isElectron() && (
<button
type="button"
onClick={() => onLogout({ manual: true })}
style={btnStyle}
title={t("common.logout")}
aria-label={t("common.logout")}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
<RailIcon>
<LogOut size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{t("common.logout")}
</RailLabel>
</button>
)}
</div>
<div className="shrink-0 border-t border-border">
<button
type="button"
onClick={onOpenSettings}
title={t("nav.openSettings")}
aria-label={t("nav.openSettings")}
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
style={{ padding: `0 ${rem(8)}` }}
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
>
{username.charAt(0).toUpperCase() || "U"}
</div>
<div
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0"
}`}
>
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
{username || t("nav.roleUser")}
</span>
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
</span>
</div>
</button>
</div>
{menuPos && (
<div
data-rail-context-menu
role="menu"
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
>
{menuTarget && (
<>
{menuTarget.promotable && (
<MenuItem
icon={<SquareArrowOutUpRight className="size-3" />}
onClick={() => {
onOpenTab?.(menuTarget.id as TabType);
setMenuPos(null);
}}
>
{t("nav.openAsTab")}
</MenuItem>
)}
{menuTarget.rightDockable && (
<MenuItem
icon={<PanelRight className="size-3" />}
onClick={() => {
onOpenInRightDock?.(menuTarget.id);
setMenuPos(null);
}}
>
{t("nav.openInRightDock")}
</MenuItem>
)}
{menuTarget.hideable && (
<MenuItem
icon={<EyeOff className="size-3" />}
onClick={() => {
void toggleHiddenRailTab(menuTarget.id);
setMenuPos(null);
}}
>
{t("nav.hideFromRail")}
</MenuItem>
)}
<div className="h-px bg-border my-1" />
</>
)}
<MenuItem
icon={pinned ? <Check className="size-3" /> : null}
onClick={togglePinned}
checked={pinned}
>
{t("newUi.sidebar.userProfile.pinAppRail")}
</MenuItem>
<MenuItem
icon={expandOnHover ? <Check className="size-3" /> : null}
onClick={toggleExpandOnHover}
checked={expandOnHover}
>
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
</MenuItem>
<div className="h-px bg-border my-1" />
<MenuItem
icon={<SlidersHorizontal className="size-3" />}
onClick={() => {
onOpenNavigationSettings();
setMenuPos(null);
}}
>
{t("nav.openSettings")}
</MenuItem>
</div>
)}
</div>
</div>
);
}
function MenuItem({
icon,
onClick,
checked,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
checked?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
role={checked === undefined ? "menuitem" : "menuitemcheckbox"}
aria-checked={checked}
onClick={onClick}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
{icon}
</span>
{children}
</button>
);
}
+9 -2
View File
@@ -5,9 +5,16 @@ import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
* Enrolment sections login methods and second factors register, such as
* adding a passkey or setting up an authenticator app.
*/
export function AuthEnrollmentSections() {
export function AuthEnrollmentSections({
factorsOnly = false,
}: {
/** Only second factors, for a login policy that requires one. */
factorsOnly?: boolean;
} = {}) {
const { t } = useTranslation();
const sections = [...useLoginMethods(), ...useSecondFactors()].filter(
const methods = useLoginMethods();
const factors = useSecondFactors();
const sections = [...(factorsOnly ? [] : methods), ...factors].filter(
(entry) => entry.enrollment,
);
if (sections.length === 0) return null;
-548
View File
@@ -1,548 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, X, Pencil, Check } from "lucide-react";
import {
getActiveSessions,
deleteOpenTab,
type ActiveSessionInfo,
type OpenTabRecord,
} from "@/main-axios";
import { tabIcon } from "@/shell/tabUtils";
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
import type { Tab, TabType } from "@/types/ui-types";
import { EmptyState } from "@/components/empty-state";
import { Facts, GroupHeading, PanelSearch } from "@/components/panel-layout";
import { ListBadge, ListRow, ListRowAction } from "@/components/list-kit";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
/** Core badge labels; plugin tabs are labelled by their registered title. */
const CORE_TYPE_LABELS: Record<string, string> = {
files: "Files",
};
/** Saved connection tabs: the ones reopened after login. */
function isConnectionTabType(type: TabType): boolean {
return isPersistentTabType(type);
}
function formatDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function sessionsUnchanged(
prev: ActiveSessionInfo[],
next: ActiveSessionInfo[],
): boolean {
if (prev.length !== next.length) return false;
for (let i = 0; i < prev.length; i++) {
const a = prev[i];
const b = next[i];
if (
a.sessionId !== b.sessionId ||
a.hostId !== b.hostId ||
a.hostName !== b.hostName ||
a.tabInstanceId !== b.tabInstanceId ||
a.isConnected !== b.isConnected ||
a.createdAt !== b.createdAt
) {
return false;
}
}
return true;
}
function formatExpiry(updatedAt: string): string {
const TTL_MS = 30 * 60 * 1000;
const elapsed = Date.now() - new Date(updatedAt).getTime();
const remaining = TTL_MS - elapsed;
if (remaining <= 0) return "expiring";
return formatDuration(remaining);
}
function ConnectionRow({
isActive,
isLive,
tabType,
name,
hostName,
subLabel,
icon,
stripe,
onSwitch,
onClose,
switchTitle,
faded,
onRename,
isDragging,
}: {
isActive?: boolean;
isLive: boolean;
tabType: string;
name: string;
hostName?: string;
subLabel: string;
icon: React.ReactNode;
stripe: number;
onSwitch?: () => void;
onClose: () => void;
switchTitle?: string;
faded?: boolean;
onRename?: (newLabel: string) => void;
isDragging?: boolean;
}) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(name);
function commitEdit() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== name && onRename) {
onRename(trimmed);
}
setEditing(false);
}
const registered = getTabType(tabType)?.titleKey;
const typeLabel =
CORE_TYPE_LABELS[tabType] ?? (registered ? t(registered) : tabType);
const stop = (e: React.PointerEvent) => e.stopPropagation();
return (
<ListRow
stripe={stripe}
tone={isLive ? "success" : "muted"}
selected={isActive}
active={editing}
dimmed={faded || isDragging}
onClick={!editing ? onSwitch : undefined}
icon={icon}
title={
editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={stop}
className="w-full min-w-0 border-b border-accent-brand bg-transparent text-[13px] font-semibold text-foreground outline-none"
autoFocus
/>
) : (
name
)
}
badges={<ListBadge className="ml-auto font-mono">{typeLabel}</ListBadge>}
meta={
<Facts>
{hostName && hostName !== name ? <span>{hostName}</span> : null}
<span>{subLabel}</span>
</Facts>
}
actions={
editing ? (
<ListRowAction
label={t("common.save")}
tone="brand"
onPointerDown={stop}
onClick={commitEdit}
>
<Check />
</ListRowAction>
) : (
<>
{onRename && (
<ListRowAction
label={t("connections.rename")}
onPointerDown={stop}
onClick={() => {
setEditValue(name);
setEditing(true);
}}
>
<Pencil />
</ListRowAction>
)}
{switchTitle && onSwitch && (
<ListRowAction
label={switchTitle}
tone="brand"
onPointerDown={stop}
onClick={onSwitch}
>
<ExternalLink />
</ListRowAction>
)}
<ListRowAction
label={t("common.close")}
tone="destructive"
onPointerDown={stop}
onClick={onClose}
>
<X />
</ListRowAction>
</>
)
}
/>
);
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<GroupHeading
title={label}
count={count}
className="border-b border-border/40 px-3 pb-1.5 pt-2.5"
/>
);
}
export function ConnectionsPanel({
tabs,
activeTabId,
allHosts,
backgroundTabRecords,
onSwitchToTab,
onCloseTab,
onReopenTab,
onForgetBackground,
onRenameTab,
onReorderTabs,
}: {
tabs: Tab[];
activeTabId: string;
allHosts: { id: string; name: string }[];
backgroundTabRecords: OpenTabRecord[];
onSwitchToTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onReopenTab: (
record: OpenTabRecord,
restoredSessionId: string | null,
) => void;
onForgetBackground: (recordId: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onReorderTabs?: (tabs: Tab[]) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [now, setNow] = useState(Date.now());
const [persistMinutes, setPersistMinutes] = useState(30);
useEffect(() => {
getSessionTimeoutMinutes()
.then((minutes) => setPersistMinutes(minutes))
.catch(() => {});
}, []);
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
const activeSessionsRef = useRef(activeSessions);
activeSessionsRef.current = activeSessions;
const [search, setSearch] = useState("");
// Drag-to-reorder state
const [dragTabId, setDragTabId] = useState<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
const rowEls = useRef<Map<string, HTMLDivElement>>(new Map());
const dragStartY = useRef<number>(0);
const didDragRef = useRef(false);
const openTabs = tabs.filter((tab) => isConnectionTabType(tab.type));
const openInstanceIds = new Set(
tabs.map((t) => t.instanceId).filter(Boolean),
);
const backgroundTabs = backgroundTabRecords.filter(
(r) => !openInstanceIds.has(r.id),
);
const q = search.trim().toLowerCase();
const filteredOpenTabs = q
? openTabs.filter((tab) => {
const displayName = tab.customLabel ?? tab.host?.name ?? tab.label;
return (
displayName.toLowerCase().includes(q) ||
(tab.host?.name ?? "").toLowerCase().includes(q)
);
})
: openTabs;
const filteredBackgroundTabs = q
? backgroundTabs.filter((r) => {
const host = allHosts.find((h) => h.id === String(r.hostId));
return (host?.name ?? r.label).toLowerCase().includes(q);
})
: backgroundTabs;
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
const refresh = useCallback(async () => {
const sessions = await getActiveSessions();
const next = Array.isArray(sessions) ? sessions : [];
const changed = !sessionsUnchanged(activeSessionsRef.current, next);
if (changed) {
activeSessionsRef.current = next;
setActiveSessions(next);
}
return changed;
}, []);
useAdaptivePolling(refresh, {
minIntervalMs: 5_000,
maxIntervalMs: 30_000,
stablePollsPerStep: 3,
});
// Global pointer listeners for drag reorder
useEffect(() => {
if (!dragTabId) return;
function onPointerMove(e: PointerEvent) {
if (Math.abs(e.clientY - dragStartY.current) > 4)
didDragRef.current = true;
if (!didDragRef.current) return;
// Find which row the pointer is over
let overTabId: string | null = null;
rowEls.current.forEach((el, id) => {
if (id === dragTabId) return;
const rect = el.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
overTabId = id;
}
});
setDragOverTabId(overTabId);
}
function onPointerUp(e: PointerEvent) {
if (didDragRef.current && dragTabId && onReorderTabs) {
// Find drop target
let targetTabId: string | null = null;
rowEls.current.forEach((el, id) => {
if (id === dragTabId) return;
const rect = el.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
targetTabId = id;
}
});
if (targetTabId) {
const fromIdx = openTabs.findIndex((t) => t.id === dragTabId);
const toIdx = openTabs.findIndex((t) => t.id === targetTabId);
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
const reordered = [...openTabs];
reordered.splice(toIdx, 0, reordered.splice(fromIdx, 1)[0]);
const nonConnectionTabs = tabs.filter(
(t) => !isConnectionTabType(t.type),
);
onReorderTabs([...nonConnectionTabs, ...reordered]);
}
}
}
setDragTabId(null);
setDragOverTabId(null);
setTimeout(() => {
didDragRef.current = false;
}, 0);
}
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
}, [dragTabId, openTabs, tabs, onReorderTabs]);
const sessionByInstanceId = new Map(
activeSessions.map((s) => [s.tabInstanceId, s]),
);
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
const hasResults =
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
const pluginSections = (
<ComponentSlot
slotId="connections.sections"
props={{
search: q,
openTabData: tabs.map((tab) => tab.data ?? {}),
}}
/>
);
if (!hasAnything) {
return (
<div className="flex flex-col flex-1">
{pluginSections}
<EmptyState
icon={Plug}
title={t("connections.noConnections")}
hint={t("connections.noConnectionsDesc")}
className="flex-1"
/>
</div>
);
}
return (
<div className="flex flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<PanelSearch
value={search}
onChange={setSearch}
placeholder={t("connections.search")}
fill
/>
</div>
{!hasResults && <EmptyState title={t("connections.noSearchResults")} />}
{filteredOpenTabs.length > 0 && (
<div className="flex flex-col">
<SectionHeader
label={t("connections.sectionOpen")}
count={filteredOpenTabs.length}
/>
{filteredOpenTabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const liveSession = tab.instanceId
? sessionByInstanceId.get(tab.instanceId)
: undefined;
const tracksSession = !!getTabType(tab.type)?.commandTarget;
const isLive = tracksSession
? (liveSession?.isConnected ?? false)
: true;
const duration = liveSession?.createdAt
? formatDuration(now - liveSession.createdAt)
: formatDuration(now - tab.openedAt);
const displayName = tab.customLabel ?? tab.host?.name ?? tab.label;
const hostName = tab.host?.name;
const isDraggingThis = dragTabId === tab.id;
const isDropTarget = dragOverTabId === tab.id && !isDraggingThis;
return (
<div
key={tab.id}
ref={(el) => {
if (el) rowEls.current.set(tab.id, el);
else rowEls.current.delete(tab.id);
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
dragStartY.current = e.clientY;
didDragRef.current = false;
setDragTabId(tab.id);
}}
className={`relative ${isDropTarget ? "before:absolute before:inset-x-0 before:-top-px before:z-20 before:h-0.5 before:bg-accent-brand" : ""}`}
style={{
cursor: dragTabId
? isDraggingThis
? "grabbing"
: "default"
: "grab",
}}
>
<ConnectionRow
stripe={index}
isActive={isActive}
isLive={isLive}
tabType={tab.type}
name={displayName}
hostName={tab.customLabel ? hostName : undefined}
subLabel={
isLive && tracksSession
? t("connections.connectedFor", { duration })
: isLive
? t("connections.connected")
: t("connections.disconnected")
}
icon={tabIcon(tab.type)}
onSwitch={() => {
if (!didDragRef.current) onSwitchToTab(tab.id);
}}
onClose={() => onCloseTab(tab.id)}
onRename={
onRenameTab
? (newLabel) => onRenameTab(tab.id, newLabel)
: undefined
}
isDragging={isDraggingThis}
/>
</div>
);
})}
</div>
)}
{filteredBackgroundTabs.length > 0 && (
<div className="flex flex-col">
<SectionHeader
label={t("connections.sectionBackground")}
count={filteredBackgroundTabs.length}
/>
<div className="px-3 py-1.5 border-b border-border/40">
<span className="text-[10px] text-muted-foreground/50">
{t("connections.backgroundDesc", {
minutes: persistMinutes,
})}
</span>
</div>
{filteredBackgroundTabs.map((record, index) => {
const host = record.hostId
? allHosts.find((h) => h.id === String(record.hostId))
: undefined;
const expiresIn = formatExpiry(record.updatedAt);
return (
<ConnectionRow
key={record.id}
stripe={index}
isLive={false}
faded
tabType={record.tabType}
name={host?.name ?? record.label}
subLabel={t("connections.expiresIn", { duration: expiresIn })}
icon={tabIcon(record.tabType as TabType)}
onSwitch={() => {
const liveSession = sessionByInstanceId.get(record.id);
onReopenTab(record, liveSession?.sessionId ?? null);
}}
onClose={async () => {
const ok = await confirm({
title: t("connections.forgetConfirm", {
name: host?.name ?? record.label,
}),
confirmLabel: t("connections.forget"),
});
if (!ok) return;
await deleteOpenTab(record.id).catch(() => {});
onForgetBackground(record.id);
}}
switchTitle={t("connections.reconnect")}
/>
);
})}
</div>
)}
{/* Sections a plugin adds, such as sessions others shared with you. */}
{pluginSections}
</div>
);
}
-613
View File
@@ -1,613 +0,0 @@
import type React from "react";
import { useRef, useState } from "react";
import { EditorPane, EditorSection } from "@/manage/EditorPane";
import { PluginComponent } from "@/plugin-host/component-registry";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { Copy, Info, Lock, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { SectionCard } from "@/components/section-card";
import {
createCredential,
generateKeyPair,
generatePublicKeyFromPrivate,
updateCredential,
duplicateCredential,
adminCreateUserCredential,
adminUpdateUserCredential,
} from "@/main-axios";
import type { Credential, Host } from "@/types/ui-types";
import { FolderPathPicker } from "./FolderPathPicker";
export function CredentialEditorView({
credential,
banner,
onBack,
onSave,
adminTargetUserId,
existingFolders = [],
saveAsNewHost,
}: {
credential: Credential | null;
/** Shown above the form. */
banner?: React.ReactNode;
onBack: () => void;
onSave: (
saved: Record<string, unknown>,
options?: { assignToHost?: boolean },
) => void;
adminTargetUserId?: string;
existingFolders?: string[];
// When set, this credential is being edited from within a host's editor;
// shows a "Save as New" action that clones it and reassigns the host.
saveAsNewHost?: Host | "new";
}) {
const [showSecretSources, setShowSecretSources] = useState(false);
const [credForm, setCredForm] = useState(() => ({
name: credential?.name ?? "",
username: credential?.username ?? "",
folder: credential?.folder ?? "",
description: credential?.description ?? "",
tags: credential?.tags ?? ([] as string[]),
tagInput: "",
type: credential?.type ?? "password",
value: credential?.type === "key" ? (credential?.value ?? "") : "",
password:
credential?.type === "password"
? (credential?.value ?? "")
: (credential?.password ?? ""),
publicKey: credential?.publicKey ?? "",
passphrase: credential?.passphrase ?? "",
certPublicKey: credential?.certPublicKey ?? "",
}));
const { t } = useTranslation();
const [generatingKey, setGeneratingKey] = useState(false);
const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
const credFileInputRef = useRef<HTMLInputElement>(null);
const certFileInputRef = useRef<HTMLInputElement>(null);
const setCredField = <K extends keyof typeof credForm>(
k: K,
v: (typeof credForm)[K],
) => setCredForm((p) => ({ ...p, [k]: v }));
const [saving, setSaving] = useState(false);
const [savingAsNew, setSavingAsNew] = useState(false);
const buildCredentialData = () => {
const hasKey =
credForm.value === "existing_key" || credForm.value.trim() !== "";
return {
hasKey,
data: {
name: credForm.name,
username: credForm.username,
folder: credForm.folder || null,
description: credForm.description || null,
tags: credForm.tags,
authType: hasKey ? "key" : "password",
password: credForm.password || null,
key: hasKey
? credForm.value === "existing_key"
? undefined
: credForm.value || null
: null,
publicKey: hasKey ? credForm.publicKey : null,
certPublicKey: hasKey ? credForm.certPublicKey || null : null,
keyPassword: hasKey
? credForm.passphrase === "existing_key_password"
? undefined
: credForm.passphrase || null
: null,
},
};
};
const validateForm = () => {
if (!credForm.name.trim()) {
toast.error(t("hosts.credentialNameRequired"));
return false;
}
const hasKey =
credForm.value === "existing_key" || credForm.value.trim() !== "";
if (!hasKey && !credForm.password) {
toast.error(t("hosts.credentialAuthRequired"));
return false;
}
return true;
};
const handleSave = async () => {
if (!validateForm()) return;
setSaving(true);
try {
const { data } = buildCredentialData();
let saved: Record<string, unknown>;
if (adminTargetUserId) {
saved = credential
? await adminUpdateUserCredential(
adminTargetUserId,
Number(credential.id),
data,
)
: await adminCreateUserCredential(adminTargetUserId, data);
} else {
saved = credential
? await updateCredential(Number(credential.id), data)
: await createCredential(data);
}
toast.success(
credential
? t("hosts.credentialUpdated")
: t("hosts.credentialCreated"),
);
if (!adminTargetUserId) {
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
}
onSave(saved);
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("hosts.failedToSaveCredential"));
} finally {
setSaving(false);
}
};
const handleSaveAsNew = async () => {
if (!credential || adminTargetUserId) return;
if (!validateForm()) return;
setSavingAsNew(true);
try {
const { data } = buildCredentialData();
const saved = await duplicateCredential(Number(credential.id), data);
toast.success(t("hosts.credentialSavedAsNew"));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
onSave(saved, { assignToHost: true });
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("hosts.failedToSaveCredentialAsNew"));
} finally {
setSavingAsNew(false);
}
};
const nameMissing = !credForm.name.trim();
const bands = [
{
id: "credential",
label: t("manage.bandCredential"),
items: [
{
id: "general",
label: t("hosts.basicInformation"),
icon: <Info className="size-3.5" />,
error: nameMissing,
},
{
id: "auth",
label: t("hosts.authDetailsSection"),
icon: <Lock className="size-3.5" />,
},
],
},
];
return (
<EditorPane
bands={bands}
banner={banner}
errorCount={nameMissing ? 1 : 0}
footer={
<>
<Button
variant="ghost"
size="sm"
onClick={onBack}
disabled={saving || savingAsNew}
>
{t("hosts.cancelBtn")}
</Button>
{saveAsNewHost && credential && !adminTargetUserId && (
<Button
variant="outline"
size="sm"
className="px-4"
onClick={handleSaveAsNew}
disabled={saving || savingAsNew || nameMissing}
title={t("hosts.saveCredentialAsNewDesc")}
>
{savingAsNew
? t("hosts.savingBtn")
: t("hosts.saveCredentialAsNewBtn")}
</Button>
)}
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-6"
onClick={handleSave}
disabled={saving || savingAsNew || nameMissing}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</>
}
>
<EditorSection id="general" showHeading={false}>
<SectionCard
title={t("hosts.basicInformation")}
icon={<Info className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyNameLabel")}
</label>
<Input
placeholder={t("placeholders.credentialName")}
value={credForm.name}
onChange={(e) => setCredField("name", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.folder")}
</label>
<FolderPathPicker
value={credForm.folder}
onChange={(path) => setCredField("folder", path)}
folderPaths={existingFolders}
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.descriptionLabel")}
</label>
<Input
placeholder={t("placeholders.optionalDetails")}
value={credForm.description}
onChange={(e) => setCredField("description", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{credForm.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setCredField(
"tags",
credForm.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={
credForm.tags.length === 0
? t("hosts.addTagsPlaceholder")
: ""
}
value={credForm.tagInput}
onChange={(e) => setCredField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
credForm.tagInput.trim()
) {
e.preventDefault();
const tag = credForm.tagInput.trim();
if (!credForm.tags.includes(tag))
setCredField("tags", [...credForm.tags, tag]);
setCredField("tagInput", "");
} else if (
e.key === "Backspace" &&
!credForm.tagInput &&
credForm.tags.length > 0
) {
setCredField("tags", credForm.tags.slice(0, -1));
}
}}
/>
</div>
</div>
</div>
</SectionCard>
</EditorSection>
<EditorSection id="auth" showHeading={false}>
<SectionCard
title={t("hosts.authDetailsSection")}
icon={<Lock className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.username")}
</label>
<Input
placeholder={t("placeholders.sshUsername")}
value={credForm.username}
onChange={(e) => setCredField("username", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.password")} ({t("common.optional")})
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={credForm.password}
onChange={(e) => setCredField("password", e.target.value)}
/>
<PluginComponent
id="credentials.secretHint"
onManage={() => setShowSecretSources((v) => !v)}
/>
</div>
{showSecretSources && (
<PluginComponent
id="credentials.secretManager"
onClose={() => setShowSecretSources(false)}
/>
)}
<div className="flex flex-col gap-4">
<div className="p-3 border border-border bg-muted/20">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
{t("hosts.generateKeyPairTitle")}
</p>
<p className="text-[10px] text-muted-foreground mb-2">
{t("hosts.generateKeyPairDescription")}
</p>
<div className="flex flex-wrap gap-2">
{[
{ label: "Ed25519", type: "ssh-ed25519" },
{
label: "ECDSA (nistp256)",
type: "ecdsa-sha2-nistp256",
},
{ label: "RSA (2048)", type: "ssh-rsa", bits: 2048 },
].map(({ label, type: keyType, bits }) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px] px-2"
disabled={generatingKey}
onClick={async () => {
setGeneratingKey(true);
try {
const result = await generateKeyPair(
keyType as
"ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
bits,
credForm.passphrase === "existing_key_password"
? undefined
: credForm.passphrase || undefined,
);
if (result.success) {
setCredField("value", result.privateKey);
setCredField("publicKey", result.publicKey);
toast.success(
t("hosts.keyPairGenerated", { label }),
);
} else {
toast.error(
result.error ??
t("hosts.failedToGenerateKeyPair"),
);
}
} catch {
toast.error(t("hosts.failedToGenerateKeyPair"));
} finally {
setGeneratingKey(false);
}
}}
>
{generatingKey
? t("hosts.generatingKey")
: t("hosts.generateLabel", { label })}
</Button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sshPrivateKey")}
</label>
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1"
onClick={() => credFileInputRef.current?.click()}
>
<Upload className="size-3" /> {t("hosts.uploadFileBtn")}
</button>
</div>
<input
ref={credFileInputRef}
type="file"
accept=".pem,.key,.ppk,.txt"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
setCredField("value", text.trim());
e.target.value = "";
}}
/>
{credForm.value === "existing_key" && (
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
{t("hosts.keySaved")}. {t("hosts.keyReplaceNotice")}
</div>
)}
<textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={8}
value={
credForm.value === "existing_key" ? "" : credForm.value
}
onChange={(e) => setCredField("value", e.target.value)}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keyPassphraseOptional")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
credForm.passphrase === "existing_key_password"
? t("hosts.keyPassphraseSaved")
: "••••••••"
}
value={
credForm.passphrase === "existing_key_password"
? ""
: credForm.passphrase
}
onChange={(e) => setCredField("passphrase", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sshPublicKeyOptional")}
</label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
disabled={
!credForm.value ||
credForm.value === "existing_key" ||
generatingPublicKey
}
onClick={async () => {
setGeneratingPublicKey(true);
try {
const result = await generatePublicKeyFromPrivate(
credForm.value,
credForm.passphrase === "existing_key_password"
? undefined
: credForm.passphrase || undefined,
);
if (result?.publicKey) {
setCredField("publicKey", result.publicKey);
toast.success(t("hosts.publicKeyGenerated"));
} else {
toast.error(t("hosts.failedToGeneratePublicKey"));
}
} catch {
toast.error(t("hosts.failedToGeneratePublicKey"));
} finally {
setGeneratingPublicKey(false);
}
}}
>
{generatingPublicKey
? t("hosts.generatingKey")
: t("hosts.generateFromPrivateKey")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px] px-2"
disabled={!credForm.publicKey}
onClick={() => {
copyToClipboard(credForm.publicKey ?? "");
toast.success(t("hosts.publicKeyCopied"));
}}
>
<Copy className="size-3 mr-1" /> {t("common.copy")}
</Button>
</div>
<textarea
placeholder="ssh-rsa AAAAB3Nza..."
rows={3}
value={credForm.publicKey}
onChange={(e) => setCredField("publicKey", e.target.value)}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1.5 p-3 border border-border bg-muted/20">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.caCertificate")}
</label>
{credForm.certPublicKey && (
<button
type="button"
className="text-[10px] text-destructive hover:text-destructive/80"
onClick={() => setCredField("certPublicKey", "")}
>
{t("credentials.clearCert")}
</button>
)}
</div>
<p className="text-[10px] text-muted-foreground">
{t("credentials.caCertificateDescription")}
</p>
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1 self-start"
onClick={() => certFileInputRef.current?.click()}
>
<Upload className="size-3" />{" "}
{t("credentials.uploadCertFile")}
</button>
<input
ref={certFileInputRef}
type="file"
accept=".pub,.txt"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
setCredField("certPublicKey", text.trim());
e.target.value = "";
}}
/>
<textarea
placeholder={t("credentials.pasteOrUploadCert")}
rows={2}
value={credForm.certPublicKey}
onChange={(e) =>
setCredField("certPublicKey", e.target.value)
}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
</div>
</div>
</SectionCard>
</EditorSection>
</EditorPane>
);
}
-321
View File
@@ -1,321 +0,0 @@
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>
);
}
-578
View File
@@ -1,578 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Check,
ListChecks,
Search,
Share2,
Shield,
User,
Users,
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { Input } from "@/components/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { Select2 } from "@/components/select2";
import {
getCredentialAccess,
getRoles,
revokeCredentialAccess,
shareCredential,
type CredentialPermissionLevel,
type ShareTarget,
} from "@/api/rbac-api";
import { getUserInfo, getUserList, type AccessRecord } from "@/main-axios";
import type { Credential } from "@/types/ui-types";
import { getErrorMessage } from "@/lib/error-message";
import { DocsLink } from "@/components/docs-link";
import { roleLabel } from "@/lib/role-label";
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
const LEVEL_KEYS: Record<
CredentialPermissionLevel,
{ label: string; description: string }
> = {
use: {
label: "credentials.share.levelUse",
description: "credentials.share.levelUseDesc",
},
manage: {
label: "credentials.share.levelManage",
description: "credentials.share.levelManageDesc",
},
};
const EXPIRY_PRESETS = [
{ key: "never", hours: null },
{ key: "oneHour", hours: 1 },
{ key: "oneDay", hours: 24 },
{ key: "sevenDays", hours: 24 * 7 },
{ key: "thirtyDays", hours: 24 * 30 },
{ key: "custom", hours: undefined },
] as const;
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
function levelOf(record: AccessRecord): CredentialPermissionLevel {
return record.permissionLevel === "manage" ? "manage" : "use";
}
export function CredentialShareModal({
credential,
onClose,
}: {
credential: Credential | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [shareUsers, setShareUsers] = useState<
{ id: string; username: string }[]
>([]);
const [shareRoles, setShareRoles] = useState<
{ id: number; name: string; displayName?: string }[]
>([]);
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
new Set(),
);
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
new Set(),
);
const [permissionLevel, setPermissionLevel] =
useState<CredentialPermissionLevel>("use");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("never");
const [customHours, setCustomHours] = useState("");
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
const [loadError, setLoadError] = useState(false);
const [submitting, setSubmitting] = useState(false);
const credentialId = credential ? Number(credential.id) : null;
const refreshAccessList = useCallback(async () => {
if (!credentialId) return;
setAccessList((await getCredentialAccess(credentialId)).access ?? []);
}, [credentialId]);
useEffect(() => {
setSearch("");
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
setPermissionLevel("use");
setExpiryPreset("never");
setCustomHours("");
setTargetTab("user");
setAccessList([]);
setLoadError(false);
if (!credentialId) return;
Promise.all([
getCredentialAccess(credentialId).catch(() => ({ access: [] })),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes, me]) => {
setAccessList(accessRes.access ?? []);
// Nobody shares a credential with themselves.
setShareUsers(
(usersRes.users ?? [])
.map((u) => ({ id: String(u.userId), username: u.username }))
.filter((u) => u.id !== me?.userId),
);
setShareRoles(
(rolesRes.roles ?? [])
.filter((r) => !r.isSystem)
.map((r) => ({
id: Number(r.id),
name: r.name,
displayName: r.displayName,
})),
);
})
.catch(() => setLoadError(true));
}, [credentialId]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareUsers.filter((u) => u.username.toLowerCase().includes(q))
: shareUsers;
}, [shareUsers, search]);
const filteredRoles = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareRoles.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
(r.displayName ?? "").toLowerCase().includes(q),
)
: shareRoles;
}, [shareRoles, search]);
const selectedCount = selectedUserIds.size + selectedRoleIds.size;
const durationHours = (() => {
if (expiryPreset === "never") return undefined;
if (expiryPreset === "custom") {
const hours = Number(customHours);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
}
return (
EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined
);
})();
async function handleShare() {
if (!credentialId || selectedCount === 0) return;
const targets: ShareTarget[] = [
...Array.from(selectedUserIds, (id) => ({ type: "user" as const, id })),
...Array.from(selectedRoleIds, (id) => ({ type: "role" as const, id })),
];
setSubmitting(true);
try {
await shareCredential(
credentialId,
targets,
permissionLevel,
durationHours,
);
await refreshAccessList();
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
toast.success(t("credentials.share.shared"));
} catch (e) {
toast.error(getErrorMessage(e));
} finally {
setSubmitting(false);
}
}
// No update route for credential grants, so re-share with the time left.
async function handleLevelChange(
record: AccessRecord,
level: CredentialPermissionLevel,
) {
if (!credentialId || levelOf(record) === level) return;
const target: ShareTarget | null =
record.targetType === "role" && record.roleId != null
? { type: "role", id: Number(record.roleId) }
: record.userId
? { type: "user", id: String(record.userId) }
: null;
if (!target) return;
let remainingHours: number | undefined;
if (record.expiresAt) {
remainingHours =
(new Date(record.expiresAt).getTime() - Date.now()) / 3_600_000;
if (remainingHours <= 0) {
toast.error(t("hosts.sharing.accessUpdateFailed"));
return;
}
}
try {
await shareCredential(credentialId, [target], level, remainingHours);
await refreshAccessList();
toast.success(t("hosts.sharing.accessUpdated"));
} catch {
toast.error(t("hosts.sharing.accessUpdateFailed"));
}
}
async function handleRevoke(record: AccessRecord) {
if (!credentialId) return;
const who =
record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
"";
const ok = await confirm({
title: t("sharing.revokeConfirm", { name: who }),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeCredentialAccess(credentialId, record.id);
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
toast.success(t("credentials.share.accessRevoked"));
} catch (e) {
toast.error(getErrorMessage(e));
}
}
return (
<InlineView
open={!!credential}
onOpenChange={(open) => {
if (!open) onClose();
}}
icon={<Share2 className="size-4" />}
title={t("credentials.share.title", { name: credential?.name ?? "" })}
bare
>
{loadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>{t("hosts.sharing.loadError")}</div>
</div>
)}
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div>
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{t("credentials.share.description")}
</p>
<div className="flex gap-1.5">
{(["user", "role"] as const).map((tab) => (
<button
key={tab}
onClick={() => setTargetTab(tab)}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${targetTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{tab === "user" ? (
<User className="size-3 shrink-0" />
) : (
<Shield className="size-3 shrink-0" />
)}
{tab === "user"
? t("hosts.sharing.usersTab")
: t("hosts.sharing.rolesTab")}
{tab === "user" && selectedUserIds.size > 0 && (
<span>({selectedUserIds.size})</span>
)}
{tab === "role" && selectedRoleIds.size > 0 && (
<span>({selectedRoleIds.size})</span>
)}
</button>
))}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("hosts.sharing.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{targetTab === "user" &&
(filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserIds.has(user.id);
return (
<button
key={user.id}
onClick={() =>
setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(user.id)) next.delete(user.id);
else next.add(user.id);
return next;
})
}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
))}
{targetTab === "role" &&
(filteredRoles.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div>
) : (
filteredRoles.map((role) => {
const isSelected = selectedRoleIds.has(role.id);
return (
<button
key={role.id}
onClick={() =>
setSelectedRoleIds((prev) => {
const next = new Set(prev);
if (next.has(role.id)) next.delete(role.id);
else next.add(role.id);
return next;
})
}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{roleLabel(t, role.displayName || role.name)}
</span>
</button>
);
})
))}
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hosts.sharing.permissionLevelLabel")}
</span>
<Select2
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(e.target.value as CredentialPermissionLevel)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
{PERMISSION_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_KEYS[level].label)}
</option>
))}
</Select2>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("hosts.sharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
selectedCount === 0 ||
submitting ||
(expiryPreset === "custom" && !durationHours)
}
onClick={handleShare}
>
<Share2 className="size-3.5 mr-1.5" />
{selectedCount > 0
? t("hosts.sharing.shareWithCount", { count: selectedCount })
: t("hosts.sharing.shareButton")}
</Button>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{t(LEVEL_KEYS[permissionLevel].description)}
</p>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("hosts.sharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
</div>
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<ListChecks className="size-3.5" />
{t("hosts.sharing.currentAccess")}
{accessList.length > 0 && (
<span className="text-muted-foreground/40">
({accessList.length})
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{accessList.length === 0 && (
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("credentials.share.noAccessEntries")}
</div>
)}
{accessList.map((record) => {
const expired =
record.expiresAt && new Date(record.expiresAt) < new Date();
const recordLevel = levelOf(record);
return (
<div
key={record.id}
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{record.targetType === "user" ? (
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
record.userId ??
record.roleId}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
{t(LEVEL_KEYS[recordLevel].label)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{PERMISSION_LEVELS.map((level) => (
<DropdownMenuItem
key={level}
onClick={() => handleLevelChange(record, level)}
>
{recordLevel === level ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(LEVEL_KEYS[level].label)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={() => void handleRevoke(record)}
>
{t("hosts.sharing.revoke")}
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.sharing.grantedBy")}:{" "}
<span className="text-foreground/70">
{record.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.sharing.expires")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.sharing.expired")}
</span>
) : record.expiresAt ? (
<span className="text-foreground/70">
{new Date(record.expiresAt).toLocaleString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.sharing.never")}
</span>
)}
</span>
</div>
</div>
);
})}
</div>
</div>
</InlineView>
);
}
-323
View File
@@ -1,323 +0,0 @@
import { useState } from "react";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
Check,
Filter,
GripVertical,
Plus,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { toast } from "sonner";
import { CredentialList } from "@/sidebar/CredentialList";
import { requestManage } from "@/manage/manage-requests";
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
import { Button } from "@/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
import { DocsLink } from "@/components/docs-link";
export function CredentialsPanel({
active: _active = true,
}: {
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
const { t } = useTranslation();
const sshAuthProviders = useSshAuthProviders();
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
const [allTags, setAllTags] = useState<string[]>([]);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
const sortKey = sidebarPrefs.sort.key;
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
"credentialSidebarArrangeLocked",
);
const filterState = sidebarPrefs.filters;
const filterActive =
filterState.type.length > 0 || filterState.tags.length > 0;
function handleSortChange(key: CredentialSortKey) {
updateSidebarPrefs((prev) => ({
...prev,
sort: { ...prev.sort, key },
}));
}
function handleArrangeLockToggle() {
const unlocking = arrangeLocked;
toggleArrangeLock();
// Same reasoning as the hosts panel: reordering only sticks visually
// under manual sort, so unlocking switches to it.
if (unlocking && sortKey !== "manual") {
handleSortChange("manual");
toast.info(t("credentials.arrangeUnlockedSwitchedToManual"));
}
}
function handleFilterToggle<K extends keyof typeof filterState>(
group: K,
value: (typeof filterState)[K][number],
) {
updateSidebarPrefs((prev) => {
const arr = prev.filters[group] as string[];
const next = arr.includes(value as string)
? arr.filter((v) => v !== value)
: [...arr, value as string];
return {
...prev,
filters: { ...prev.filters, [group]: next },
};
});
}
function handleFilterClear() {
updateSidebarPrefs((prev) => ({
...prev,
filters: { type: [], tags: [] },
}));
}
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("credentials.searchCredentials")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 overflow-x-auto overflow-y-hidden toolbar-scrollbar">
<div className="flex items-center border border-border shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${sortKey !== "default" || !arrangeLocked ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.sortCredentials")}
>
<ArrowUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[160px]"
>
<DropdownMenuItem
onClick={() => handleSortChange("default")}
className="flex items-center gap-1.5"
>
{sortKey === "default" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t("credentials.sortDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(["name-asc", "name-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["username-asc", "username-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "username-asc" ? "UsernameAsc" : "UsernameDesc"}`,
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleSortChange("manual")}
className="flex items-center gap-1.5"
>
{sortKey === "manual" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<GripVertical className="size-3 shrink-0 text-muted-foreground/40" />
)}
{t("credentials.sortManual")}
</DropdownMenuItem>
<DropdownMenuCheckboxItem
checked={!arrangeLocked}
onCheckedChange={handleArrangeLockToggle}
onSelect={(event) => event.preventDefault()}
>
{t("credentials.arrangeUnlocked")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.filterCredentials")}
>
<Filter className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[180px]"
>
{filterActive && (
<>
<DropdownMenuItem
onClick={handleFilterClear}
className="flex items-center gap-1.5 text-accent-brand"
>
<X className="size-3 shrink-0" />
{t("credentials.filterClearAll")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>
{t("credentials.filterTypeGroup")}
</DropdownMenuLabel>
{sshAuthProviders.providers
.filter((option) => option.credentialType)
.map((option) => (
<DropdownMenuCheckboxItem
key={option.type}
checked={filterState.type.includes(option.type)}
onCheckedChange={() =>
handleFilterToggle("type", option.type)
}
onSelect={(e) => e.preventDefault()}
>
{t(
`credentials.filterType${option.type.charAt(0).toUpperCase() + option.type.slice(1)}`,
{ defaultValue: t(option.labelKey) },
)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("credentials.filterTagsGroup")}
</DropdownMenuLabel>
{allTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={filterState.tags.includes(tag)}
onCheckedChange={() =>
handleFilterToggle("tags", tag)
}
onSelect={(e) => e.preventDefault()}
>
{tag}
</DropdownMenuCheckboxItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<DocsLink core="credentials" variant="icon" />
</div>
<div className="flex items-center border border-border shrink-0">
<button
onClick={() => setCustomizePanelOpen(true)}
title={t("credentials.customizeSidebar")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
>
<SlidersHorizontal className="size-3.5" />
</button>
</div>
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
<button
onClick={() =>
requestManage({ kind: "credential", credentialId: null })
}
title={t("credentials.addCredential")}
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
>
<Plus className="size-3 shrink-0" />
<span className="hidden min-[280px]:inline">
{t("credentials.addCredential")}
</span>
</button>
</div>
</div>
</div>
}
<div className="flex flex-col flex-1 min-h-0">
<CredentialList
search={search}
sort={sortKey}
arrangeLocked={arrangeLocked}
filter={filterState}
density={sidebarPrefs.display.density}
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
rowFields={sidebarPrefs.display}
onTagsChange={setAllTags}
/>
</div>
<CustomizeCredentialsSidebarPanel
open={customizePanelOpen}
onOpenChange={setCustomizePanelOpen}
preferences={sidebarPrefs}
update={updateSidebarPrefs}
/>
</div>
);
}
@@ -1,157 +0,0 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { ListChecks, Rows3, SquareStack } from "lucide-react";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import {
CREDENTIAL_ROW_FIELD_KEYS,
type CredentialDensity,
type CredentialSidebarPreferences,
type CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
export function CustomizeCredentialsSidebarPanel({
open,
onOpenChange,
preferences,
update,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
preferences: CredentialSidebarPreferences;
update: (
patch:
| Partial<CredentialSidebarPreferences>
| ((prev: CredentialSidebarPreferences) => CredentialSidebarPreferences),
) => void;
}) {
const { t } = useTranslation();
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("credentials.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
}
>
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</Select2>
</SettingRow>
</SectionCard>
<SectionCard
title={t("credentials.rowDetailsTitle")}
icon={<ListChecks className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("credentials.rowDetailsDesc")}
</p>
{CREDENTIAL_ROW_FIELD_KEYS.map((key) => (
<SettingRow
key={key}
label={t(`credentials.${key}`)}
description={t(`credentials.${key}Desc`)}
>
<FakeSwitch
checked={preferences.display[key]}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, [key]: v },
}))
}
/>
</SettingRow>
))}
</SectionCard>
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</Select2>
</div>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
-308
View File
@@ -1,308 +0,0 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { ListChecks, PanelTop, Rows3, SquareStack } from "lucide-react";
import { actionOrder, useHostActions } from "./host-contributions";
import { showsInBar } from "./tree/host-bar-actions";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
HostDensity,
HostRowFields,
HostSidebarPreferences,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
const ROW_FIELDS: { key: keyof HostRowFields; dependsOnAddress?: boolean }[] = [
{ key: "showAddress" },
{ key: "showUsername", dependsOnAddress: true },
{ key: "showPort", dependsOnAddress: true },
{ key: "showPinIcon" },
{ key: "showSharedBadge" },
{ key: "showBadges" },
];
export function CustomizeSidebarPanel({
open,
onOpenChange,
preferences,
update,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
preferences: HostSidebarPreferences;
update: (
patch:
| Partial<HostSidebarPreferences>
| ((prev: HostSidebarPreferences) => HostSidebarPreferences),
) => void;
}) {
const { t } = useTranslation();
const actions = [...useHostActions()].sort(
(a, b) => actionOrder(a) - actionOrder(b) || a.id.localeCompare(b.id),
);
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("hosts.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
}
>
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</Select2>
</SettingRow>
</SectionCard>
<SectionCard
title={t("hosts.rowDetailsTitle")}
icon={<ListChecks className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("hosts.rowDetailsDesc")}
</p>
{ROW_FIELDS.map(({ key, dependsOnAddress }) => (
<SettingRow
key={key}
label={t(`hosts.${key}`)}
description={t(`hosts.${key}Desc`)}
>
<FakeSwitch
checked={preferences.display[key]}
disabled={dependsOnAddress && !preferences.display.showAddress}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, [key]: v },
}))
}
/>
</SettingRow>
))}
</SectionCard>
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</Select2>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<Select2
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</Select2>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.closeOnOpen")}
description={t("hosts.closeOnOpenDesc")}
>
<FakeSwitch
checked={preferences.display.closeOnOpen}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, closeOnOpen: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
{actions.length > 0 && (
<SectionCard
title={t("hosts.connectBarTitle")}
icon={<PanelTop className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("hosts.connectBarDesc")}
</p>
{actions.map((action) => {
const Icon = action.icon;
return (
<SettingRow
key={action.id}
label={t(action.titleKey)}
rowId={`bar-action-${action.id}`}
>
<span className="flex items-center gap-2">
<Icon className="size-3.5 text-muted-foreground" />
<FakeSwitch
checked={showsInBar(
preferences.display.barActions,
action,
)}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
barActions: {
...prev.display.barActions,
[action.id]: v,
},
},
}))
}
/>
</span>
</SettingRow>
);
})}
</SectionCard>
)}
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
-48
View File
@@ -1,48 +0,0 @@
import { useTranslation } from "react-i18next";
import { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
/** Shortcuts the shell handles itself; they cannot be rebound. */
export function fixedShortcuts(): Array<{ keys: string[]; labelKey: string }> {
return [
{ keys: ["Ctrl", "Shift", "\\"], labelKey: "splitRight" },
{ keys: ["Ctrl", "Shift", "-"], labelKey: "splitDown" },
{ keys: ["Ctrl", "Shift", "Enter"], labelKey: "zoomPane" },
{ keys: ["Alt", "↑↓←→"], labelKey: "navigatePane" },
{ keys: ["Ctrl", "Shift", "]"], labelKey: "nextTab" },
{ keys: ["Ctrl", "Shift", "["], labelKey: "previousTab" },
{ keys: tabJumpHotkeyKeys(), labelKey: "jumpTab" },
{ keys: ["Ctrl", "Shift", "E"], labelKey: "previousView" },
{ keys: ["Ctrl", "Shift", "F"], labelKey: "fullscreen" },
];
}
export function FixedShortcutsList() {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.fixedHeading")}
</span>
{fixedShortcuts().map(({ keys, labelKey }) => (
<div
key={labelKey}
className="flex items-center justify-between gap-2 border border-border/60 px-2.5 py-1.5"
>
<span className="text-xs text-muted-foreground flex-1 min-w-0 truncate">
{t(`newUi.sidebar.keybindings.fixed.${labelKey}`)}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{keys.map((key, index) => (
<kbd
key={index}
className="inline-flex items-center px-1 py-0.5 text-[10px] font-mono bg-muted border border-border/60 text-muted-foreground leading-none"
>
{key}
</kbd>
))}
</div>
</div>
))}
</div>
);
}
-244
View File
@@ -1,244 +0,0 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
DEFAULT_FOLDER_COLOR,
DEFAULT_FOLDER_ICON,
FolderIconEl,
HexColorPicker,
IconPicker,
} from "@/components/folder-style";
import { normalizePath, splitPath } from "./FolderPathPicker";
import { getCredentials } from "@/main-axios";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { FakeSwitch, SettingRow } from "@/components/section-card";
export type FolderMetadataValue = {
name: string;
color: string;
icon: string;
credentialId: number | null;
localOnly: boolean;
};
type CredentialOption = { id: string; name: string; username?: string };
export function FolderMetadataDialog({
open,
mode,
initial,
existingPaths = [],
currentPath,
onOpenChange,
onSubmit,
}: {
open: boolean;
mode: "create" | "edit";
initial?: {
name: string;
color?: string;
icon?: string;
credentialId?: number | null;
localOnly?: boolean;
};
/** Every folder path that already exists, for duplicate-name validation. */
existingPaths?: string[];
/** The folder's own current full path, in edit mode -- excluded from the duplicate check. */
currentPath?: string;
onOpenChange: (v: boolean) => void;
onSubmit: (value: FolderMetadataValue) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState(DEFAULT_FOLDER_COLOR);
const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON);
const [credentialId, setCredentialId] = useState<string>("");
const [localOnly, setLocalOnly] = useState(false);
const syncLinked = !!useSyncStatus()?.linked;
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
const parentPath =
mode === "edit" && currentPath?.includes(" / ")
? currentPath.slice(0, currentPath.lastIndexOf(" / "))
: "";
const resultingPath = name
? mode === "edit"
? parentPath
? `${parentPath} / ${normalizePath(name)}`
: normalizePath(name)
: normalizePath(name)
: "";
const isDuplicate =
!!resultingPath &&
resultingPath !== currentPath &&
existingPaths.some((p) => p.toLowerCase() === resultingPath.toLowerCase());
useEffect(() => {
if (open) {
setName(initial?.name ?? "");
setColor(initial?.color ?? DEFAULT_FOLDER_COLOR);
setIcon(initial?.icon ?? DEFAULT_FOLDER_ICON);
setCredentialId(
initial?.credentialId ? String(initial.credentialId) : "",
);
setLocalOnly(!!initial?.localOnly);
}
}, [open, initial]);
useEffect(() => {
if (!open) return;
getCredentials()
.then((data) => {
const list = Array.isArray(data) ? data : [];
setCredentials(
list.map((c) => ({
id: String(c.id),
name: String(c.name ?? ""),
username: c.username ? String(c.username) : undefined,
})),
);
})
.catch(() => setCredentials([]));
}, [open]);
function handleSubmit() {
const normalized = normalizePath(name);
if (!normalized || isDuplicate) return;
onSubmit({
name: normalized,
color,
icon,
credentialId: credentialId ? Number(credentialId) : null,
localOnly,
});
onOpenChange(false);
}
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{mode === "create"
? t("hosts.createFolderTitle")
: t("hosts.editFolderTitle")}
</>
}
>
<p className="text-xs text-muted-foreground">
{t("hosts.folderDialogDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("hosts.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderNestingHint")}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderColor")}
</label>
<HexColorPicker value={color} onChange={setColor} />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderIcon")}
</label>
<IconPicker value={icon} color={color} onChange={setIcon} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<Select2
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
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"
>
<option value="">{t("hosts.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
{name
? splitPath(name).map((seg, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{seg}
</span>
))
: t("hosts.folderNameFallback")}
</span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("hosts.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
: t("hosts.saveFolderButton")}
</Button>
</div>
</InlineView>
);
}
-207
View File
@@ -1,207 +0,0 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
ChevronsUpDown,
FolderPlus,
Search,
X,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import { FolderIconEl } from "@/components/folder-style";
const SEP = " / ";
export function splitPath(path: string): string[] {
return path
.split(/\s*\/\s*/)
.map((p) => p.trim())
.filter(Boolean);
}
export function normalizePath(path: string): string {
return splitPath(path).join(SEP);
}
export function FolderPathPicker({
value,
onChange,
folderPaths,
folderMeta,
}: {
value: string;
onChange: (path: string) => void;
folderPaths: string[];
folderMeta?: Map<string, { color?: string; icon?: string }>;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const allPaths = useMemo(
() =>
[...new Set(folderPaths.filter(Boolean))].sort((a, b) =>
a.localeCompare(b),
),
[folderPaths],
);
const query = search.trim();
const normalizedQuery = normalizePath(query);
const normalizedQueryLower = normalizedQuery.toLowerCase();
const filtered = query
? allPaths.filter((p) => {
const lower = p.toLowerCase();
return (
lower.includes(query.toLowerCase()) ||
lower.includes(normalizedQueryLower)
);
})
: allPaths;
const canCreate =
normalizedQuery.length > 0 &&
!allPaths.some((p) => p.toLowerCase() === normalizedQuery.toLowerCase());
function commit(path: string) {
onChange(normalizePath(path));
setSearch("");
setOpen(false);
}
const segments = value ? splitPath(value) : [];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 text-xs transition-colors hover:border-ring/60 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 outline-none"
>
{segments.length > 0 ? (
<span className="flex items-center gap-1 min-w-0 flex-1">
<FolderIconEl
icon={folderMeta?.get(value)?.icon ?? "folder"}
className="size-3.5 shrink-0"
style={{ color: folderMeta?.get(value)?.color }}
/>
<span className="flex items-center min-w-0 truncate">
{segments.map((seg, i) => (
<span key={i} className="flex items-center min-w-0">
{i > 0 && (
<ChevronRight className="size-3 shrink-0 text-muted-foreground/40" />
)}
<span className="truncate">{seg}</span>
</span>
))}
</span>
</span>
) : (
<span className="flex-1 text-left text-muted-foreground">
{t("hosts.folderPickerPlaceholder")}
</span>
)}
{value && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="shrink-0 text-muted-foreground/50 hover:text-foreground"
>
<X className="size-3" />
</span>
)}
<ChevronsUpDown className="size-3 shrink-0 text-muted-foreground/50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={4}
collisionPadding={8}
className="w-max min-w-(--radix-popover-trigger-width) max-w-96 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 h-8 shrink-0">
<Search className="size-3 shrink-0 text-muted-foreground/60" />
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (canCreate) commit(normalizedQuery);
else if (filtered.length > 0) commit(filtered[0]);
}
}}
placeholder={t("hosts.folderPickerSearch")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
{value && (
<button
type="button"
onClick={() => commit("")}
className="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<X className="size-3.5 shrink-0" />
{t("hosts.folderPickerNone")}
</button>
)}
{canCreate && (
<button
type="button"
onClick={() => commit(normalizedQuery)}
className="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-accent-brand hover:bg-accent transition-colors"
>
<FolderPlus className="size-3.5 shrink-0" />
<span className="truncate">
{t("hosts.folderPickerCreate", { path: normalizedQuery })}
</span>
</button>
)}
{filtered.map((p) => {
const meta = folderMeta?.get(p);
const parts = splitPath(p);
return (
<button
key={p}
type="button"
onClick={() => commit(p)}
className={`flex items-center gap-2 w-full px-2.5 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors ${
p === value
? "bg-accent/60 text-foreground"
: "text-foreground/80"
}`}
>
<FolderIconEl
icon={meta?.icon ?? "folder"}
className="size-3.5 shrink-0"
style={{ color: meta?.color }}
/>
<span className="flex items-center min-w-0 truncate">
{parts.map((seg, i) => (
<span key={i} className="flex items-center min-w-0">
{i > 0 && (
<ChevronRight className="size-3 shrink-0 text-muted-foreground/40" />
)}
<span className="truncate">{seg}</span>
</span>
))}
</span>
</button>
);
})}
{filtered.length === 0 && !canCreate && (
<div className="py-4 text-center text-xs text-muted-foreground">
{t("hosts.folderPickerEmpty")}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
-202
View File
@@ -1,202 +0,0 @@
import { Select2 } from "@/components/select2";
import { FormFooter } from "@/components/list-kit";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getCredentials,
getHostAuthOverride,
setHostAuthOverride,
} from "@/main-axios";
import type { Credential, Host } from "@/types/ui-types";
import {
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import { authProtocolLabel } from "./host-permissions";
import { mapCredentials } from "./HostManagerData";
import { getConnectedRemoteApi } from "@/lib/remote-server-api";
export function HostAuthOverrideModal({
open,
onOpenChange,
host,
protocol,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
host: Host;
protocol: AuthOverrideProtocol;
}) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [selectedId, setSelectedId] = useState("");
const [initialId, setInitialId] = useState("");
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [saving, setSaving] = useState(false);
const overrideState = host.authOverrides?.[protocol];
const ownerAuthShared =
overrideState?.ownerAuthShared ??
(protocol === SSH_AUTH_PROTOCOL ? !!host.shareSshAuth : false);
// A shared host's copy on a linked desktop: its override lives on the
// server, against the server's own credentials.
const sharedCopySyncId = host.sharedCopy ? (host.syncId ?? null) : null;
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setLoadError(false);
const credentialsRequest = sharedCopySyncId
? getConnectedRemoteApi().then((api) => {
if (!api) throw new Error("The linked server is not reachable");
return api.get("/credentials").then((response) => response.data);
})
: getCredentials();
Promise.all([
credentialsRequest,
getHostAuthOverride(Number(host.id), protocol, sharedCopySyncId),
])
.then(([credentialResult, overrideResult]) => {
if (cancelled) return;
const nextCredentials = mapCredentials(credentialResult);
const nextId =
overrideResult.credentialId == null
? ""
: String(overrideResult.credentialId);
setCredentials(nextCredentials);
setSelectedId(nextId);
setInitialId(nextId);
})
.catch(() => {
if (!cancelled) setLoadError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [host.id, open, protocol, sharedCopySyncId]);
const handleSave = async () => {
setSaving(true);
try {
const credentialId = selectedId ? Number(selectedId) : null;
await setHostAuthOverride(
Number(host.id),
protocol,
credentialId,
sharedCopySyncId,
);
toast.success(
credentialId === null
? t(
ownerAuthShared
? "hosts.sharing.authOverrideClearedToShared"
: "hosts.sharing.authOverrideCleared",
)
: t("hosts.sharing.authOverrideSaved"),
);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
onOpenChange(false);
} catch {
toast.error(t("hosts.sharing.authOverrideSaveError"));
} finally {
setSaving(false);
}
};
return (
<span
className="contents"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: authProtocolLabel(protocol, t),
})}
</>
}
footer={
<FormFooter
onCancel={() => onOpenChange(false)}
onSave={() => void handleSave()}
saving={saving}
disabled={loading || loadError || selectedId === initialId}
/>
}
>
<p className="text-xs text-muted-foreground">
{t(
ownerAuthShared
? "hosts.sharing.authOverrideDescriptionShared"
: "hosts.sharing.authOverrideDescriptionPrivate",
{ host: host.name },
)}
</p>
{loading ? (
<p className="py-4 text-center text-muted-foreground">
{t("common.loading")}
</p>
) : loadError ? (
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
{t("hosts.sharing.authOverrideLoadError")}
</p>
) : (
<div className="flex flex-col gap-2">
<label
htmlFor={`auth-override-${host.id}`}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<Select2
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
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"
>
<option value="">
{t(
ownerAuthShared
? "hosts.sharing.useSharedAuthentication"
: "hosts.sharing.noPersonalCredential",
)}
</option>
{credentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.username
? `${credential.name} (${credential.username})`
: credential.name}
</option>
))}
</Select2>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
</p>
)}
{overrideState?.required && selectedId === "" && (
<p className="border border-warning/30 bg-warning/5 p-2 text-[10px] text-warning">
{t("hosts.sharing.authOverrideRequired")}
</p>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverridePrivateHint")}
</p>
</div>
)}
</InlineView>
</span>
);
}
File diff suppressed because it is too large. Load diff
-311
View File
@@ -1,311 +0,0 @@
import type { Host } from "@/types/ui-types";
import type { HostProtocolAuthInput, SSHHostData } from "@/types";
import {
HOST_PROTOCOL_SECRET_KEPT,
type HostDraft,
type HostProtocolAuthForm,
type HostProtocolAuthSummary,
} from "@termix-ssh/plugin-sdk/frontend";
import type { DefaultOverrides } from "@/types/host-defaults";
import {
listHostProtocols,
protocolPort,
type HostProtocols,
} from "./host-protocols";
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
export type { HostProtocols };
export type HostAuthType = Host["authType"];
/**
* Whether the Connection Origin control is meaningful for a host.
*
* Every protocol Termix can dial from either backend belongs here, plugin
* protocols included (Termix-SSH/Support#1240); before that the control was
* gated on SSH alone, so a host enabling only remote desktop could never
* reach the setting.
*/
export function connectionOriginAppliesTo(protocols: HostProtocols): boolean {
return Object.values(protocols).some(Boolean);
}
/** Overlays a plugin's draft on a new host's form. */
export function applyHostDraft(
form: HostEditorForm,
draft: HostDraft | undefined,
): HostEditorForm {
if (!draft) return form;
return {
...form,
...(draft.name !== undefined ? { name: draft.name } : {}),
...(draft.ip !== undefined ? { ip: draft.ip } : {}),
...(draft.port !== undefined ? { sshPort: draft.port } : {}),
...(draft.username !== undefined ? { username: draft.username } : {}),
...(draft.authType !== undefined
? { authType: draft.authType as HostAuthType }
: {}),
};
}
/** A saved login as the editor form holds it; saved secrets stay behind a placeholder. */
export function protocolAuthForm(
summaries: Record<string, HostProtocolAuthSummary> | undefined,
): Record<string, HostProtocolAuthForm> {
const form: Record<string, HostProtocolAuthForm> = {};
for (const [protocol, summary] of Object.entries(summaries ?? {})) {
const fields: Record<string, string> = { ...(summary.fields ?? {}) };
for (const key of summary.secretFieldKeys ?? []) {
fields[key] = HOST_PROTOCOL_SECRET_KEPT;
}
form[protocol] = {
authType:
summary.authType ?? (summary.credentialId ? "credential" : "direct"),
credentialId:
summary.credentialId != null ? String(summary.credentialId) : "",
username: summary.username ?? "",
password: summary.hasPassword ? HOST_PROTOCOL_SECRET_KEPT : "",
fields,
};
}
return form;
}
/**
* The logins a host save sends: one per plugin protocol the editor knows,
* removed when its switch is off. A saved secret left alone is not sent.
*/
export function protocolAuthPayload(
form: Record<string, HostProtocolAuthForm>,
protocols: HostProtocols,
): Record<string, HostProtocolAuthInput | null> {
const payload: Record<string, HostProtocolAuthInput | null> = {};
for (const protocol of listHostProtocols()) {
if (!protocols[protocol.settingKey]) {
payload[protocol.id] = null;
continue;
}
const login = form[protocol.id];
if (!login) continue;
const direct = login.authType === "direct";
payload[protocol.id] = {
authType: login.authType,
credentialId:
login.authType === "credential" && login.credentialId
? Number(login.credentialId)
: null,
username: direct ? login.username || null : null,
...(direct && login.password !== HOST_PROTOCOL_SECRET_KEPT
? { password: login.password || null }
: {}),
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([, value]) => value !== HOST_PROTOCOL_SECRET_KEPT,
),
),
};
}
return payload;
}
export function createHostEditorForm(host: Host | null) {
const sshOptions = host?.sshOptions ?? {};
return {
name: host?.name ?? "",
ip: host?.ip ?? "",
username: host?.username ?? (host ? "" : "root"),
sshPort: host?.sshPort ?? host?.port ?? 22,
authType: host?.authType ?? "password",
shareSshAuth: host?.shareSshAuth ?? false,
password: host?.hasPassword ? "existing_password" : (host?.password ?? ""),
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
keyPassword: host?.hasKeyPassword
? "existing_key_password"
: (host?.keyPassword ?? ""),
keyType: host?.keyType ?? "auto",
keySubTab: "paste" as "paste" | "upload",
credentialId: host?.credentialId != null ? String(host.credentialId) : "",
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
folder: host?.folder ?? "",
parentHostId: host?.parentHostId ?? "",
tags: host?.tags ?? ([] as string[]),
tagInput: "",
notes: host?.notes ?? "",
pin: host?.pin ?? false,
useSocks5: host?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? "",
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
? "chain"
: "single") as "single" | "chain",
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
connectionOrigin: (host?.connectionOrigin ?? null) as
"local" | "remote" | null,
localOnly: host?.localOnly ?? false,
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
sudoPassword: host?.hasSudoPassword
? "existing_sudo_password"
: (host?.sudoPassword ?? ""),
keepaliveInterval: sshOptions.keepaliveInterval ?? 60,
keepaliveCountMax: sshOptions.keepaliveCountMax ?? 5,
allowLegacyAlgorithms: sshOptions.allowLegacyAlgorithms ?? true,
agentForwarding: sshOptions.agentForwarding ?? false,
agentSocketPath: sshOptions.agentSocketPath ?? "",
agentIdentity: sshOptions.agentIdentity ?? "",
environmentVariables:
sshOptions.environmentVariables ??
([] as { key: string; value: string }[]),
jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]),
portKnockSequence:
host?.portKnockSequence ??
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
// Each plugin protocol's login, edited by that plugin's host editor tab.
protocolAuth: protocolAuthForm(host?.protocolAuth),
statusCheckEnabled: host?.statusCheckEnabled ?? true,
statusCheckInterval: (host?.statusCheckInterval ?? null) as number | null,
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
// saved through the plugin's own scoped route, not in the host payload.
pluginSettings: (host?.pluginSettings ?? {}) as Record<
string,
Record<string, unknown>
>,
// Host default keys the host sets itself; the rest follow its defaults.
// Null until known, for a host saved before host defaults.
defaultOverrides: (host
? (host.defaultOverrides ?? null)
: {}) as DefaultOverrides | null,
};
}
export type HostEditorForm = ReturnType<typeof createHostEditorForm>;
export function omitOwnerSshAuthFromSharedEdit(
payload: SSHHostData,
): SSHHostData {
const {
authType: _authType,
password: _password,
key: _key,
keyPassword: _keyPassword,
keyType: _keyType,
sudoPassword: _sudoPassword,
credentialId: _credentialId,
overrideCredentialUsername: _overrideCredentialUsername,
shareSshAuth: _shareSshAuth,
...editableFields
} = payload;
const sshOptions = editableFields.sshOptions
? { ...editableFields.sshOptions }
: undefined;
if (sshOptions) delete sshOptions.agentSocketPath;
return {
...editableFields,
sshOptions,
} as SSHHostData;
}
export function buildHostEditorPayload(
form: HostEditorForm,
protocols: HostProtocols,
): SSHHostData {
// Only carry the auth fields that belong to the selected method so switching
// method (e.g. on a cloned host) doesn't leave a stale credentialId or key
// behind that the backend would keep resolving.
const usesCredential = form.authType === "credential";
const usesKey = form.authType === "key";
const usesPassword = form.authType === "password";
const usesAgent = form.authType === "agent";
// With SSH off, the host's primary protocol is the first plugin protocol
// switched on, and its port stands in for the host port.
const primaryProtocol = protocols.enableSsh
? undefined
: listHostProtocols().find((protocol) => protocols[protocol.settingKey]);
return {
connectionType: primaryProtocol?.id ?? "ssh",
name: form.name,
ip: form.ip,
port: primaryProtocol
? protocolPort(form.pluginSettings, primaryProtocol)
: Number(form.sshPort),
username: form.username,
folder: form.folder,
parentHostId: form.parentHostId ? Number(form.parentHostId) : null,
tags: form.tags,
pin: form.pin,
authType: form.authType,
shareSshAuth: form.shareSshAuth,
password:
usesPassword || usesKey || usesCredential
? form.password === "existing_password"
? undefined
: form.password || null
: null,
key: usesKey
? form.key === "existing_key"
? undefined
: form.key || null
: null,
keyPassword: usesKey
? form.keyPassword === "existing_key_password"
? undefined
: form.keyPassword || null
: null,
keyType: usesKey && form.keyType !== "auto" ? form.keyType : null,
credentialId:
usesCredential && form.credentialId ? Number(form.credentialId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
useSocks5: form.useSocks5,
socks5Host:
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
socks5Port:
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
socks5Username:
form.socks5ProxyMode === "single" ? form.socks5Username || null : null,
socks5Password:
form.socks5ProxyMode === "single" ? form.socks5Password || null : null,
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
connectionOrigin: form.connectionOrigin,
localOnly: form.localOnly,
enableSsh: protocols.enableSsh,
sshPort: Number(form.sshPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
protocolAuth: protocolAuthPayload(form.protocolAuth, protocols),
// The editor keeps ids as strings; the API and every backend lookup take
// a number, and a string id does not compare equal on Postgres/MySQL.
jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })),
portKnockSequence: form.portKnockSequence,
statusCheckEnabled: form.statusCheckEnabled,
statusCheckInterval: form.statusCheckInterval,
...(form.defaultOverrides
? { defaultOverrides: form.defaultOverrides }
: {}),
// Left out with SSH off, so the stored values stay as they are.
...(protocols.enableSsh
? {
sudoPassword:
form.sudoPassword === "existing_sudo_password"
? undefined
: form.sudoPassword || "",
sshOptions: {
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
agentForwarding: form.agentForwarding,
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
agentIdentity: usesAgent ? form.agentIdentity || null : null,
environmentVariables: form.environmentVariables,
},
}
: {}),
};
}
-948
View File
@@ -1,948 +0,0 @@
import React from "react";
import { getHostTags } from "@/api/host-tags-api";
import type { ProxyNode } from "@/types/index";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import type { Host } from "@/types/ui-types";
import { Activity, Globe, Plus, Tag, Terminal, Trash2, X } from "lucide-react";
import { FolderPathPicker } from "./FolderPathPicker";
import { HostParentPicker } from "./HostParentPicker";
import { getSSHFolders, isElectron } from "@/main-axios";
import { connectionOriginAppliesTo } from "./HostEditorData";
import { useSyncStatus } from "@/hooks/use-sync-status";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import type { HostFormErrors } from "@/manage/host-validation";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
import {
DefaultsOnly,
HostDefaultBadge,
HostOnly,
} from "@/lib/host-defaults-context";
import { DocsLink } from "@/components/docs-link";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostEditorGeneralTab({
form,
setField,
protocols,
handleProtocolToggle,
hosts,
host,
simpleMode = false,
errors = {},
}: {
form: HostEditorForm;
setField: HostEditorSetField;
protocols: HostProtocols;
handleProtocolToggle: (proto: keyof HostProtocols, value: boolean) => void;
hosts: Host[];
host: Host | null;
/** Hides organizational/advanced fields; their values still save unchanged. */
simpleMode?: boolean;
errors?: HostFormErrors;
}) {
const { t } = useTranslation();
const syncLinked = !!useSyncStatus()?.linked;
const pluginProtocols = useHostProtocols();
const [predefinedTags, setPredefinedTags] = React.useState<string[]>([]);
React.useEffect(() => {
let cancelled = false;
const load = () => {
void getHostTags()
.then((tags) => {
if (!cancelled) setPredefinedTags(tags);
})
.catch(() => {});
};
load();
window.addEventListener("termix:host-tags-changed", load);
return () => {
cancelled = true;
window.removeEventListener("termix:host-tags-changed", load);
};
}, []);
const suggestedTags = [
...new Set([
...predefinedTags,
...hosts.flatMap((item) => item.tags ?? []),
]),
].filter(
(tag) =>
!form.tags.includes(tag) &&
tag
.toLocaleLowerCase()
.includes(form.tagInput.trim().toLocaleLowerCase()),
);
const addTag = (tag: string) => {
if (!form.tags.includes(tag)) setField("tags", [...form.tags, tag]);
setField("tagInput", "");
};
// Tracks which picker is shown, independent of whether a value is set yet
// -- switching to "parent host" mode with nothing picked shouldn't bounce
// back to the folder picker just because parentHostId is still empty.
const [placementMode, setPlacementMode] = React.useState<
"folder" | "parentHost"
>(form.parentHostId ? "parentHost" : "folder");
const [folderMeta, setFolderMeta] = React.useState<
Map<string, { color?: string; icon?: string }>
>(new Map());
React.useEffect(() => {
let cancelled = false;
const load = () => {
getSSHFolders()
.then((folders) => {
if (cancelled) return;
const map = new Map<string, { color?: string; icon?: string }>();
for (const f of folders) {
map.set(f.name, {
color: f.color ?? undefined,
icon: f.icon ?? undefined,
});
}
setFolderMeta(map);
})
.catch(() => {});
};
load();
window.addEventListener("termix:hosts-changed", load);
return () => {
cancelled = true;
window.removeEventListener("termix:hosts-changed", load);
};
}, []);
// Folders come from two sources: paths referenced by existing hosts, and
// standalone folder records (including empty ones just created).
// Intermediate ancestor paths are expanded so "A" appears even when only
// "A / B" is directly stored on a host.
const folderPaths = React.useMemo(() => {
const set = new Set<string>();
const addWithAncestors = (path: string) => {
const parts = path.split(" / ");
let accumulated = "";
for (const part of parts) {
accumulated = accumulated ? `${accumulated} / ${part}` : part;
set.add(accumulated);
}
};
for (const h of hosts) {
if (h.folder) addWithAncestors(h.folder);
}
for (const path of folderMeta.keys()) addWithAncestors(path);
return [...set];
}, [hosts, folderMeta]);
return (
<>
<HostOnly>
{/* Protocols, enable/disable each connection type */}
<SectionCard
title={t("hosts.protocols")}
icon={<Globe className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
{[
{
proto: "enableSsh",
label: t("hosts.tabSsh"),
desc: t("hosts.secureShell"),
icon: <Terminal className="size-4" />,
},
...pluginProtocols.map((protocol) => {
const Icon = protocol.icon;
return {
proto: protocol.settingKey,
label: t(protocol.titleKey),
desc: protocol.descriptionKey
? t(protocol.descriptionKey)
: "",
icon: <Icon className="size-4" />,
};
}),
].map(({ proto, label, desc, icon }) => {
const enabled = !!protocols[proto];
return (
<div
key={proto}
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
>
<div
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
>
{icon}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
>
{label}
</span>
<span className="text-[10px] text-muted-foreground/50">
{desc}
</span>
</div>
<FakeSwitch
checked={enabled}
onChange={(v: boolean) => handleProtocolToggle(proto, v)}
/>
</div>
);
})}
</div>
</SectionCard>
<SectionCard
title={t("hosts.connectionDetails")}
icon={<Globe className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.addressIp")}
</label>
<Input
placeholder={t("placeholders.hostAddress")}
value={form.ip}
aria-invalid={!!errors.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
{errors.ip && (
<span className="text-[10px] text-destructive">
{t(errors.ip)}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyName")}
</label>
<Input
placeholder={t("placeholders.hostName")}
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
</div>
</div>
</div>
</SectionCard>
{!Object.values(protocols).some(Boolean) && (
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
</div>
)}
</HostOnly>
<SectionCard
title={t("hosts.folderAndAdvanced")}
icon={<Tag className="size-3.5" />}
className={simpleMode ? "hidden" : undefined}
>
<HostOnly>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5 col-span-2 md:col-span-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{placementMode === "parentHost"
? t("hosts.parentHost")
: t("hosts.folder")}
</label>
<button
type="button"
onClick={() => {
if (placementMode === "parentHost") {
setPlacementMode("folder");
setField("parentHostId", "");
} else {
setPlacementMode("parentHost");
setField("folder", "");
}
}}
className="text-[10px] text-accent-brand hover:underline"
>
{placementMode === "parentHost"
? t("hosts.useFolderInstead")
: t("hosts.useParentHostInstead")}
</button>
</div>
{placementMode === "parentHost" ? (
<HostParentPicker
value={String(form.parentHostId)}
onChange={(hostId) => setField("parentHostId", hostId)}
hosts={hosts}
excludeHostId={host?.id}
/>
) : (
<FolderPathPicker
value={form.folder}
onChange={(path) => setField("folder", path)}
folderPaths={folderPaths}
folderMeta={folderMeta}
/>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setField(
"tags",
form.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={form.tags.length === 0 ? t("hosts.addTag") : ""}
value={form.tagInput}
onChange={(e) => setField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
!e.nativeEvent.isComposing &&
form.tagInput.trim()
) {
e.preventDefault();
addTag(form.tagInput.trim());
} else if (
e.key === "Backspace" &&
!form.tagInput &&
form.tags.length > 0
) {
setField("tags", form.tags.slice(0, -1));
}
}}
/>
</div>
</div>
{suggestedTags.length > 0 && (
<div
className="col-span-2 flex flex-wrap gap-1 max-h-24 overflow-y-auto"
aria-label={t("admin.hostTags")}
>
{suggestedTags.map((tag) => (
<button
type="button"
key={tag}
onClick={() => addTag(tag)}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground/40 transition-colors"
>
<Plus className="size-2.5" />
{tag}
</button>
))}
</div>
)}
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.privateNotes")}
</label>
<textarea
rows={3}
placeholder={t("hosts.privateNotesPlaceholder")}
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
value={form.notes}
onChange={(e) => setField("notes", e.target.value)}
/>
</div>
<SettingRow
label={t("hosts.pinToTop")}
description={t("hosts.pinToTopDesc")}
>
<FakeSwitch
checked={form.pin}
onChange={(v) => setField("pin", v)}
/>
</SettingRow>
{syncLinked && (
<SettingRow
label={t("hosts.localOnly")}
description={t("hosts.localOnlyDesc")}
>
<FakeSwitch
checked={form.localOnly}
onChange={(v) => setField("localOnly", v)}
/>
</SettingRow>
)}
</div>
</HostOnly>
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.portKnockingSequence")}
</span>
<HostDefaultBadge settingKey="core.portKnockSequence" />
<DocsLink core="portKnocking">{t("hosts.docsLink")}</DocsLink>
</div>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("portKnockSequence", [
...form.portKnockSequence,
{ port: 0, protocol: "tcp" as const, delay: 0 },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addKnockBtn")}
</Button>
</div>
{form.portKnockSequence.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noPortKnocking")}
</p>
)}
<div className="flex flex-col gap-2">
{form.portKnockSequence.map((knock, i) => (
<div
key={i}
className="flex items-end gap-1.5 p-1.5 bg-muted/30 border border-border"
>
<span className="text-[9px] font-bold text-muted-foreground/50 mb-1.5 shrink-0">
{i + 1}.
</span>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.knockPort")}
</span>
<Input
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="8080"
type="number"
value={knock.port}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
port: Number(e.target.value),
};
setField("portKnockSequence", updated);
}}
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.protocol")}
</span>
<Select2
className="h-7 text-[10px] bg-background border border-border px-1"
value={knock.protocol}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
protocol: e.target.value as "tcp" | "udp",
};
setField("portKnockSequence", updated);
}}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
{t("hosts.delayAfterMs")}
</span>
<Input
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="100"
type="number"
value={knock.delay}
onChange={(e) => {
const updated = [...form.portKnockSequence];
updated[i] = {
...updated[i],
delay: Number(e.target.value),
};
setField("portKnockSequence", updated);
}}
/>
</div>
<button
className="text-destructive p-1 mb-0.5"
onClick={() =>
setField(
"portKnockSequence",
form.portKnockSequence.filter((_, idx) => idx !== i),
)
}
>
<X className="size-3" />
</button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-4 border-t border-border pt-4 pb-2">
<SettingRow
label={t("hosts.useSocks5Proxy")}
description={t("hosts.useSocks5ProxyDesc")}
defaultKey="core.socks5"
>
<FakeSwitch
checked={form.useSocks5}
onChange={(v) => setField("useSocks5", v)}
/>
</SettingRow>
{form.useSocks5 && (
<div className="flex flex-col gap-3">
{/* Single / Chain mode toggle */}
<div className="flex gap-2">
{(["single", "chain"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setField("socks5ProxyMode", m)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${form.socks5ProxyMode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "single"
? t("hosts.proxySingleMode")
: t("hosts.proxyChainMode")}
</button>
))}
</div>
{form.socks5ProxyMode === "single" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 bg-muted/20 border border-border">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="proxy.example.com"
value={form.socks5Host}
onChange={(e) => setField("socks5Host", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPort")}
</label>
<Input
className="h-7 text-xs"
type="number"
placeholder="1080"
value={form.socks5Port}
onChange={(e) =>
setField("socks5Port", Number(e.target.value))
}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyUsername")}
</label>
<Input
className="h-7 text-xs"
placeholder={t("hosts.optional")}
value={form.socks5Username}
onChange={(e) =>
setField("socks5Username", e.target.value)
}
/>
</div>
<HostOnly>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={form.socks5Password}
onChange={(e) =>
setField("socks5Password", e.target.value)
}
/>
</div>
</HostOnly>
</div>
)}
{form.socks5ProxyMode === "chain" && (
<div className="flex flex-col gap-2">
{form.socks5ProxyChain.map((node, ni) => (
<div
key={ni}
className="flex flex-col gap-2 p-3 bg-muted/20 border border-border"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-muted-foreground">
{t("hosts.proxyNode")} {ni + 1}
</span>
<button
type="button"
className="text-destructive"
onClick={() =>
setField(
"socks5ProxyChain",
form.socks5ProxyChain.filter(
(_, idx) => idx !== ni,
),
)
}
>
<X className="size-3.5" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="proxy.example.com"
value={node.host}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = { ...u[ni], host: e.target.value };
setField("socks5ProxyChain", u);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPort")}
</label>
<Input
className="h-7 text-xs"
type="number"
placeholder="1080"
value={node.port}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
port: Number(e.target.value),
};
setField("socks5ProxyChain", u);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyType")}
</label>
<Select2
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={node.type}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
type: e.target.value as ProxyNode["type"],
};
setField("socks5ProxyChain", u);
}}
>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
<option value="http">HTTP</option>
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyUsername")}
</label>
<Input
className="h-7 text-xs"
placeholder={t("hosts.optional")}
value={node.username}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
username: e.target.value,
};
setField("socks5ProxyChain", u);
}}
/>
</div>
<HostOnly>
<div className="flex flex-col gap-1 col-span-2">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={node.password}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
password: e.target.value,
};
setField("socks5ProxyChain", u);
}}
/>
</div>
</HostOnly>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand self-start"
onClick={() =>
setField("socks5ProxyChain", [
...form.socks5ProxyChain,
{
host: "",
port: 1080,
type: "socks5",
username: "",
password: "",
},
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addProxyNode")}
</Button>
</div>
)}
{/* Connection path visualization */}
{(form.socks5ProxyMode === "single" && form.socks5Host) ||
(form.socks5ProxyMode === "chain" &&
form.socks5ProxyChain.length > 0) ? (
<div className="flex items-center gap-1 flex-wrap p-2 bg-muted/30 border border-border text-[10px]">
<span className="px-2 py-0.5 bg-background border border-border text-foreground font-mono">
{t("hosts.you")}
</span>
{form.socks5ProxyMode === "single" && form.socks5Host ? (
<>
<span className="text-muted-foreground">→</span>
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
{form.socks5Host}:{form.socks5Port}
</span>
</>
) : (
form.socks5ProxyChain
.filter((n) => n.host)
.map((n, ni) => (
<React.Fragment key={ni}>
<span className="text-muted-foreground">→</span>
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
{n.host}:{n.port}
</span>
</React.Fragment>
))
)}
{form.jumpHosts
.filter((j) => j.hostId)
.map((j, ji) => {
const jh = hosts.find((h) => h.id === j.hostId);
return jh ? (
<React.Fragment key={`j${ji}`}>
<span className="text-muted-foreground">→</span>
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
{jh.name || jh.ip}
</span>
</React.Fragment>
) : null;
})}
<span className="text-muted-foreground">→</span>
<span className="px-2 py-0.5 bg-accent-brand/10 border border-accent-brand/30 text-accent-brand font-mono">
{form.ip || "target"}:{form.sshPort}
</span>
</div>
) : null}
</div>
)}
{isElectron() && connectionOriginAppliesTo(protocols) && (
<HostOnly>
<SettingRow
label={t("hosts.connectionOrigin")}
description={[
t("hosts.connectionOriginDesc"),
...new Set(
pluginProtocols
.filter(
(protocol) =>
protocols[protocol.settingKey] &&
protocol.connectionOriginNoteKey,
)
.map((protocol) => t(protocol.connectionOriginNoteKey!)),
),
].join(" ")}
>
<Select2
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
setField(
"connectionOrigin",
(e.target.value || null) as "local" | "remote" | null,
)
}
>
<option value="">{t("hosts.connectionOriginDefault")}</option>
<option value="local">
{t("hosts.connectionOriginLocal")}
</option>
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</Select2>
</SettingRow>
</HostOnly>
)}
<DefaultsOnly settingKey="core.jumpHosts">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.jumpHostChainLabel")}
<HostDefaultBadge settingKey="core.jumpHosts" />
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("jumpHosts", [...form.jumpHosts, { hostId: "" }])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
</Button>
</div>
{form.jumpHosts.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noJumpHosts")}
</p>
)}
<div className="flex flex-col gap-2">
{form.jumpHosts.map((jh, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-background border border-border"
>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{i + 1}.
</span>
<Select2
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={jh.hostId}
onChange={(e) => {
const updated = [...form.jumpHosts];
updated[i] = { hostId: e.target.value };
setField("jumpHosts", updated);
}}
>
<option value="">{t("hosts.selectAServer")}</option>
{hosts
.filter((h) => (host ? h.id !== host.id : true))
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive"
onClick={() =>
setField(
"jumpHosts",
form.jumpHosts.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
</DefaultsOnly>
</div>
</SectionCard>
<SectionCard
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
defaultKey="core.statusCheckEnabled"
>
<FakeSwitch
checked={form.statusCheckEnabled}
onChange={(value) => setField("statusCheckEnabled", value)}
/>
</SettingRow>
{form.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
defaultKey="core.statusCheckInterval"
>
<FakeSwitch
checked={form.statusCheckInterval === null}
onChange={(useGlobal) =>
setField("statusCheckInterval", useGlobal ? null : 60)
}
/>
</SettingRow>
)}
{form.statusCheckEnabled && form.statusCheckInterval !== null && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
min={5}
max={86400}
value={form.statusCheckInterval}
onChange={(e) =>
setField("statusCheckInterval", Number(e.target.value))
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
</>
);
}
-347
View File
@@ -1,347 +0,0 @@
import { InlineView } from "@/components/surface/surface-scope";
import { FormFooter } from "@/components/list-kit";
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
import { Input } from "@/components/input";
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
buildExportPayload,
pluginSecretKeys,
hostKey,
maskSecrets,
type ExportPayload,
type FieldGroup,
} from "@/sidebar/host-export-payload";
const GROUPS: { key: FieldGroup; label: string }[] = [
{ key: "connection", label: "groupConnection" },
{ key: "notes", label: "groupNotes" },
{ key: "tags", label: "groupTags" },
{ key: "proxy", label: "groupProxy" },
{ key: "jumpHosts", label: "groupJumpHosts" },
{ key: "featureFlags", label: "groupFeatureFlags" },
{ key: "advanced", label: "groupAdvanced" },
];
const DEFAULT_GROUPS: FieldGroup[] = [
"connection",
"notes",
"tags",
"proxy",
"jumpHosts",
"featureFlags",
"advanced",
];
const PREVIEW_HOST_LIMIT = 20;
export function HostExportDialog({
open,
onClose,
hosts,
preselectedHostIds,
}: {
open: boolean;
onClose: () => void;
hosts: SSHHostWithStatus[];
preselectedHostIds?: Set<string>;
}) {
const { t } = useTranslation();
const [scope, setScope] = useState<"all" | "selected">("all");
const [withCredentials, setWithCredentials] = useState(false);
const [groups, setGroups] = useState<Set<FieldGroup>>(
() => new Set(DEFAULT_GROUPS),
);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const [raw, setRaw] = useState<ExportPayload | null>(null);
const [loading, setLoading] = useState(false);
const cache = useRef<Partial<Record<"share" | "full", ExportPayload>>>({});
const prevOpen = useRef(false);
const exportableHosts = useMemo(
() =>
hosts.filter(
(h) =>
!isFolder(h as unknown as Parameters<typeof isFolder>[0]) &&
!h.isShared,
),
[hosts],
);
useEffect(() => {
const justOpened = open && !prevOpen.current;
prevOpen.current = open;
if (!justOpened) return;
cache.current = {};
setRaw(null);
setSearch("");
setWithCredentials(false);
setGroups(new Set(DEFAULT_GROUPS));
const preselected = preselectedHostIds ?? new Set<string>();
if (preselected.size > 0) {
setScope("selected");
setSelectedKeys(
new Set(
exportableHosts
.filter((h) => preselected.has(String(h.id)))
.map((h) => hostKey(h as unknown as Record<string, unknown>)),
),
);
} else {
setScope("all");
setSelectedKeys(new Set());
}
}, [open, preselectedHostIds, exportableHosts]);
useEffect(() => {
if (!open) return;
const mode = withCredentials ? "full" : "share";
const cached = cache.current[mode];
if (cached) {
setRaw(cached);
return;
}
let cancelled = false;
setLoading(true);
(withCredentials ? exportAllSSHHosts() : exportAllSSHHosts({ share: true }))
.then((result) => {
if (cancelled) return;
cache.current[mode] = result as unknown as ExportPayload;
setRaw(result as unknown as ExportPayload);
})
.catch((err: unknown) => {
if (cancelled) return;
setRaw(null);
const message = getErrorMessage(err, "");
toast.error(message || t("hosts.export.fetchFailed"));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, withCredentials, t]);
const { records: pluginRecords } = usePluginStore();
const pluginSecrets = useMemo(
() =>
pluginSecretKeys(
[...pluginRecords.values()].map((record) => record.summary),
),
[pluginRecords],
);
const payload = useMemo(() => {
if (!raw) return null;
return buildExportPayload(
raw,
scope === "all" ? null : selectedKeys,
groups,
withCredentials,
pluginSecrets,
);
}, [raw, scope, selectedKeys, groups, withCredentials, pluginSecrets]);
const preview = useMemo(() => {
if (!payload) return "";
const masked = maskSecrets(payload, pluginSecrets);
if (masked.hosts.length <= PREVIEW_HOST_LIMIT) {
return JSON.stringify(masked, null, 2);
}
const text = JSON.stringify(
{ ...masked, hosts: masked.hosts.slice(0, PREVIEW_HOST_LIMIT) },
null,
2,
);
return (
text +
"\n" +
t("hosts.export.moreHosts", {
count: payload.hosts.length - PREVIEW_HOST_LIMIT,
})
);
}, [payload, pluginSecrets, t]);
function toggleGroup(group: FieldGroup) {
setGroups((prev) => {
const next = new Set(prev);
if (next.has(group)) next.delete(group);
else next.add(group);
return next;
});
}
function toggleHost(key: string) {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function handleExport() {
if (!payload) return;
const data = JSON.stringify(payload, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = withCredentials
? "termix-hosts.json"
: "termix-hosts-share.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("hosts.hostsExported"));
onClose();
}
const visibleHosts = exportableHosts.filter((h) =>
(h.name ?? "").toLowerCase().includes(search.trim().toLowerCase()),
);
const count = payload?.hosts.length ?? 0;
const canExport = !loading && count > 0;
return (
<InlineView
open={open}
onOpenChange={(next) => !next && onClose()}
title={t("hosts.export.title")}
width="wide"
footer={
<FormFooter
onCancel={onClose}
cancelLabel={t("hosts.export.cancel")}
onSave={() => void handleExport()}
saveLabel={t("hosts.export.confirm")}
disabled={!canExport}
/>
}
>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
</div>
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
))}
</div>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
</InlineView>
);
}
+1
View File
@@ -74,6 +74,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
connectionOrigin: h.connectionOrigin ?? null,
localOnly: !!h.localOnly,
sharedCopy: !!h.sharedCopy,
...(h.lock ? { lock: h.lock } : {}),
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
sshPort:
h.sshPort ??
-176
View File
@@ -1,176 +0,0 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronsUpDown, Search, Server, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import type { Host } from "@/types/ui-types";
/**
* Walks parentHostId to collect every descendant id of `hostId`, so a host
* can never be assigned under its own descendant (which would form a cycle).
* Mirrors the ancestor-walk cycle guard used server-side and in buildHostTree.
*/
export function collectDescendantIds(
hostId: string,
hosts: Host[],
): Set<string> {
const childrenByParent = new Map<string, string[]>();
for (const h of hosts) {
if (!h.parentHostId) continue;
const list = childrenByParent.get(h.parentHostId) ?? [];
list.push(h.id);
childrenByParent.set(h.parentHostId, list);
}
const out = new Set<string>();
const stack = [...(childrenByParent.get(hostId) ?? [])];
while (stack.length > 0) {
const id = stack.pop()!;
if (out.has(id)) continue;
out.add(id);
stack.push(...(childrenByParent.get(id) ?? []));
}
return out;
}
export function HostParentPicker({
value,
onChange,
hosts,
excludeHostId,
}: {
value: string;
onChange: (hostId: string) => void;
hosts: Host[];
/** The host being edited, if any -- excluded from the list along with its descendants. */
excludeHostId?: string | null;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const excludedIds = useMemo(() => {
if (!excludeHostId) return new Set<string>();
const descendants = collectDescendantIds(excludeHostId, hosts);
descendants.add(excludeHostId);
return descendants;
}, [excludeHostId, hosts]);
const candidates = useMemo(
() =>
hosts
.filter((h) => !excludedIds.has(h.id))
.sort((a, b) => a.name.localeCompare(b.name)),
[hosts, excludedIds],
);
const query = search.trim().toLowerCase();
const filtered = query
? candidates.filter(
(h) =>
h.name.toLowerCase().includes(query) ||
h.ip.toLowerCase().includes(query),
)
: candidates;
const selected = value ? hosts.find((h) => h.id === value) : undefined;
function commit(hostId: string) {
onChange(hostId);
setSearch("");
setOpen(false);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 text-xs transition-colors hover:border-ring/60 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 outline-none"
>
{selected ? (
<span className="flex items-center gap-1.5 min-w-0 flex-1">
<Server className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{selected.name || selected.ip}</span>
</span>
) : (
<span className="flex-1 text-left text-muted-foreground">
{t("hosts.parentHostPickerPlaceholder")}
</span>
)}
{value && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="shrink-0 text-muted-foreground/50 hover:text-foreground"
>
<X className="size-3" />
</span>
)}
<ChevronsUpDown className="size-3 shrink-0 text-muted-foreground/50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={4}
collisionPadding={8}
className="w-max min-w-(--radix-popover-trigger-width) max-w-96 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 h-8 shrink-0">
<Search className="size-3 shrink-0 text-muted-foreground/60" />
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filtered.length > 0) {
e.preventDefault();
commit(filtered[0].id);
}
}}
placeholder={t("hosts.parentHostPickerSearch")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
{value && (
<button
type="button"
onClick={() => commit("")}
className="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<X className="size-3.5 shrink-0" />
{t("hosts.parentHostPickerNone")}
</button>
)}
{filtered.map((h) => (
<button
key={h.id}
type="button"
onClick={() => commit(h.id)}
className={`flex items-center gap-2 w-full px-2.5 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors ${
h.id === value
? "bg-accent/60 text-foreground"
: "text-foreground/80"
}`}
>
<Server className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate flex-1 min-w-0">{h.name || h.ip}</span>
<span className="text-[10px] text-muted-foreground/50 truncate shrink-0">
{h.ip}
</span>
</button>
))}
{filtered.length === 0 && (
<div className="py-4 text-center text-xs text-muted-foreground">
{t("hosts.parentHostPickerEmpty")}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
-701
View File
@@ -1,701 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import {
getFolderAccess,
revokeFolderAccess,
type FolderAccessRule,
} from "@/api/rbac-api";
import { useTranslation } from "react-i18next";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
Check,
ListChecks,
Search,
Share2,
Shield,
User,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import {
getHostAccess,
shareHost,
shareFolder,
updateHostAccess,
revokeHostAccess,
getUserList,
getUserInfo,
getRoles,
type AccessRecord,
type SharePermissionLevel,
type ShareTarget,
} from "@/main-axios";
import type { Host } from "@/types/ui-types";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
import { roleLabel } from "@/lib/role-label";
const PERMISSION_LEVELS: SharePermissionLevel[] = [
"connect",
"view",
"edit",
"manage",
];
const EXPIRY_PRESETS = [
{ key: "never", hours: null },
{ key: "oneHour", hours: 1 },
{ key: "oneDay", hours: 24 },
{ key: "sevenDays", hours: 24 * 7 },
{ key: "thirtyDays", hours: 24 * 30 },
{ key: "custom", hours: undefined },
] as const;
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
export function HostShareModal({
open,
onClose,
host,
folder,
}: {
open: boolean;
onClose: () => void;
host: Host | null;
folder?: string | null;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const isFolderShare = !host && !!folder;
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
new Set(),
);
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
new Set(),
);
const [permissionLevel, setPermissionLevel] =
useState<SharePermissionLevel>("connect");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("never");
const [customHours, setCustomHours] = useState("");
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
const [shareUsers, setShareUsers] = useState<
{ id: string; username: string }[]
>([]);
const [shareRoles, setShareRoles] = useState<
{ id: number; name: string; displayName?: string }[]
>([]);
const [sharingLoaded, setSharingLoaded] = useState(false);
const [sharingLoadError, setSharingLoadError] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [folderShareSummary, setFolderShareSummary] = useState<{
hostsShared: number;
hostsTotal: number;
} | null>(null);
useEffect(() => {
if (!open || (!host && !folder)) return;
if (sharingLoaded) return;
setSharingLoaded(true);
Promise.all([
host
? getHostAccess(Number(host.id), host.syncId).catch(() => ({
accessList: [],
}))
: Promise.resolve({ accessList: [] }),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes, me]) => {
setAccessList(accessRes.accessList ?? []);
// Nobody shares with themselves.
setShareUsers(
(usersRes.users ?? [])
.map((u) => ({
id: String(u.id ?? u.userId),
username: u.username,
}))
.filter((u) => u.id !== me?.userId),
);
setShareRoles(
(rolesRes.roles ?? [])
.filter((r) => !r.isSystem)
.map((r) => ({
id: Number(r.id),
name: r.name,
displayName: r.displayName,
})),
);
})
.catch(() => setSharingLoadError(true));
}, [open, host, folder, sharingLoaded]);
useEffect(() => {
setSharingLoaded(false);
setSharingLoadError(false);
setAccessList([]);
setSearch("");
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
setPermissionLevel("connect");
setExpiryPreset("never");
setCustomHours("");
setTargetTab("user");
setFolderShareSummary(null);
}, [host?.id, folder]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareUsers.filter((u) => u.username.toLowerCase().includes(q))
: shareUsers;
}, [shareUsers, search]);
const filteredRoles = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareRoles.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
(r.displayName ?? "").toLowerCase().includes(q),
)
: shareRoles;
}, [shareRoles, search]);
const selectedCount = selectedUserIds.size + selectedRoleIds.size;
const durationHours = (() => {
if (expiryPreset === "never") return undefined;
if (expiryPreset === "custom") {
const hours = Number(customHours);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
}
return (
EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined
);
})();
async function refreshAccessList() {
if (!host) return;
const res = await getHostAccess(Number(host.id), host.syncId);
setAccessList(res.accessList ?? []);
}
const [folderRules, setFolderRules] = useState<FolderAccessRule[]>([]);
const refreshFolderRules = useCallback(async () => {
if (!isFolderShare || !folder) return;
try {
setFolderRules((await getFolderAccess(folder)).rules);
} catch {
setFolderRules([]);
}
}, [isFolderShare, folder]);
useEffect(() => {
if (open) void refreshFolderRules();
}, [open, refreshFolderRules]);
async function handleShare() {
if ((!host && !folder) || selectedCount === 0) return;
const targets: ShareTarget[] = [
...[...selectedUserIds].map(
(id) => ({ type: "user", id }) as ShareTarget,
),
...[...selectedRoleIds].map(
(id) => ({ type: "role", id }) as ShareTarget,
),
];
setSubmitting(true);
try {
if (isFolderShare && folder) {
const result = await shareFolder(folder, {
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
});
await refreshFolderRules();
setFolderShareSummary({
hostsShared: result.hostsShared,
hostsTotal: result.hostsTotal,
});
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
toast.success(
t("hosts.folderSharedSuccessfully", {
count: result.hostsShared,
}),
);
} else if (host) {
await shareHost(
Number(host.id),
{
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
},
host.syncId,
);
await refreshAccessList();
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
toast.success(t("hosts.hostSharedSuccessfully"));
}
} catch {
toast.error(
isFolderShare
? t("hosts.failedToShareFolder")
: t("hosts.failedToShareHost"),
);
} finally {
setSubmitting(false);
}
}
async function handleLevelChange(
record: AccessRecord,
level: SharePermissionLevel,
) {
if (!host || record.permissionLevel === level) return;
try {
await updateHostAccess(
Number(host.id),
record.id,
{
permissionLevel: level,
},
host.syncId,
);
setAccessList((prev) =>
prev.map((entry) =>
entry.id === record.id ? { ...entry, permissionLevel: level } : entry,
),
);
toast.success(t("hosts.sharing.accessUpdated"));
} catch {
toast.error(t("hosts.sharing.accessUpdateFailed"));
}
}
return (
<InlineView
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
icon={<Share2 className="size-4" />}
title={
isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })
}
bare
>
{sharingLoadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>{t("hosts.sharing.loadError")}</div>
</div>
)}
{/* Share form: fixed, non-scrolling */}
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div>
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
</div>
<div className="flex gap-1.5">
{(["user", "role"] as const).map((tab) => (
<button
key={tab}
onClick={() => setTargetTab(tab)}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${targetTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{tab === "user" ? (
<User className="size-3 shrink-0" />
) : (
<Shield className="size-3 shrink-0" />
)}
{tab === "user"
? t("hosts.sharing.usersTab")
: t("hosts.sharing.rolesTab")}
{tab === "user" && selectedUserIds.size > 0 && (
<span>({selectedUserIds.size})</span>
)}
{tab === "role" && selectedRoleIds.size > 0 && (
<span>({selectedRoleIds.size})</span>
)}
</button>
))}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("hosts.sharing.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{targetTab === "user" &&
(filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserIds.has(user.id);
return (
<button
key={user.id}
onClick={() =>
setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(user.id)) next.delete(user.id);
else next.add(user.id);
return next;
})
}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
))}
{targetTab === "role" &&
(filteredRoles.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div>
) : (
filteredRoles.map((role) => {
const isSelected = selectedRoleIds.has(role.id);
return (
<button
key={role.id}
onClick={() =>
setSelectedRoleIds((prev) => {
const next = new Set(prev);
if (next.has(role.id)) next.delete(role.id);
else next.add(role.id);
return next;
})
}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{roleLabel(t, role.displayName || role.name)}
</span>
</button>
);
})
))}
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hosts.sharing.permissionLevelLabel")}
</span>
<Select2
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(e.target.value as SharePermissionLevel)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
{PERMISSION_LEVELS.map((level) => (
<option key={level} value={level}>
{t(`hosts.sharing.levels.${level}.label`)}
</option>
))}
</Select2>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("hosts.sharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
selectedCount === 0 ||
submitting ||
(expiryPreset === "custom" && !durationHours)
}
onClick={handleShare}
>
<Share2 className="size-3.5 mr-1.5" />
{selectedCount > 0
? t("hosts.sharing.shareWithCount", { count: selectedCount })
: t("hosts.sharing.shareButton")}
</Button>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{t(`hosts.sharing.levels.${permissionLevel}.description`)}
</p>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("hosts.sharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
</div>
{/* Folder share summary */}
{isFolderShare && folderShareSummary && (
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 text-xs text-muted-foreground">
<ListChecks className="size-3.5 shrink-0" />
{t("hosts.sharing.folderShareSummary", {
shared: folderShareSummary.hostsShared,
total: folderShareSummary.hostsTotal,
})}
</div>
)}
{isFolderShare && folderRules.length > 0 && (
<div className="flex flex-col gap-1 px-3 pb-2 shrink-0">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sharing.folderRulesTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("hosts.sharing.folderRulesHint")}
</span>
{folderRules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 text-xs px-1 py-0.5"
>
<span className="flex-1 truncate">
{rule.targetType === "role"
? roleLabel(t, rule.roleDisplayName) || rule.roleName
: rule.username}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
{rule.permissionLevel}
</span>
<button
type="button"
className="text-[10px] text-muted-foreground hover:text-destructive"
title={t("hosts.sharing.folderRuleRemove")}
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
rule.username ??
roleLabel(t, rule.roleDisplayName) ??
rule.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (ok)
void revokeFolderAccess(rule.id).then(refreshFolderRules);
}}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
)}
{/* Current access: takes remaining space, scrolls independently */}
{!isFolderShare && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<ListChecks className="size-3.5" />
{t("hosts.sharing.currentAccess")}
{accessList.length > 0 && (
<span className="text-muted-foreground/40">
({accessList.length})
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{accessList.length === 0 && (
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noAccessEntries")}
</div>
)}
{accessList.map((record) => {
const expired =
record.expiresAt && new Date(record.expiresAt) < new Date();
return (
<div
key={record.id}
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{record.targetType === "user" ? (
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
record.userId ??
record.roleId}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
{t(
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{PERMISSION_LEVELS.map((level) => (
<DropdownMenuItem
key={level}
onClick={() => handleLevelChange(record, level)}
>
{record.permissionLevel === level ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.levels.${level}.label`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeHostAccess(
Number(host!.id),
record.id,
host!.syncId,
);
setAccessList((prev) =>
prev.filter((entry) => entry.id !== record.id),
);
toast.success(t("hosts.accessRevoked"));
} catch {
toast.error(t("hosts.failedToRevokeAccess"));
}
}}
>
{t("hosts.sharing.revoke")}
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.sharing.grantedBy")}:{" "}
<span className="text-foreground/70">
{record.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.sharing.expires")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.sharing.expired")}
</span>
) : record.expiresAt ? (
<span className="text-foreground/70">
{new Date(record.expiresAt).toLocaleString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.sharing.never")}
</span>
)}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</InlineView>
);
}
File diff suppressed because it is too large. Load diff
-208
View File
@@ -1,208 +0,0 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useConfirm } from "@/components/surface/surface-scope";
import {
Check,
Gauge,
Layers,
RotateCcw,
Sparkles,
Wrench,
} from "lucide-react";
import { Button } from "@/components/button";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
UI_AREA_KEYS,
type UiAreaKey,
type UiPreset,
} from "@/types/ui-preferences";
const SELECTABLE_PRESETS: Exclude<UiPreset, "custom">[] = [
"simple",
"balanced",
"advanced",
];
const PRESET_ICONS = {
simple: Sparkles,
balanced: Gauge,
advanced: Wrench,
} as const;
export function InterfacePresetSettings({
onRunSetupAgain,
}: {
onRunSetupAgain?: () => void;
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const confirm = useConfirm();
const [applying, setApplying] = useState(false);
// The chosen preset stays highlighted even once knobs diverge from it --
// the customizations are listed separately below.
const activeLabel = ctx?.preferences.preset ?? "balanced";
const pluginStore = usePluginStore();
const customizedAreas = useMemo(() => {
if (!ctx) return [] as Array<{ key: string; label: string }>;
const overrides = ctx.preferences.overrides as Record<
string,
Record<string, unknown> | undefined
>;
const core = UI_AREA_KEYS.filter(
(area) => Object.keys(overrides[area] ?? {}).length > 0,
).map((area) => ({
key: area as string,
label: t(`newUi.sidebar.userProfile.uiArea_${area}`),
}));
// A plugin's own area is labelled with the plugin's name.
const plugins = Object.keys(overrides)
.filter(
(area) =>
area.startsWith("plugin:") &&
Object.keys(overrides[area] ?? {}).length > 0,
)
.map((area) => {
const pluginId = area.slice("plugin:".length);
const summary = pluginStore.records.get(pluginId)?.summary;
return {
key: area,
label: summary ? pluginName(t, summary) : pluginId,
};
});
return [...core, ...plugins];
}, [ctx, pluginStore, t]);
if (!ctx) return null;
async function choosePreset(preset: Exclude<UiPreset, "custom">) {
const ok = await confirm({
title: t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: t(`newUi.sidebar.userProfile.preset_${preset}`),
}),
description: t("newUi.sidebar.userProfile.presetConfirmDesc"),
confirmLabel: t("newUi.sidebar.userProfile.presetConfirmApply"),
destructive: false,
});
if (!ok) return;
setApplying(true);
try {
ctx.setPreset(preset);
// Picking a preset is a deliberate reset, so any knob the user had
// pinned goes back to following the preset.
ctx.clearAllOverrides();
await applyPresetSideEffects(preset);
} finally {
setApplying(false);
}
}
return (
<div className="flex flex-col gap-3">
<p className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.interfacePresetDesc")}
</p>
{/* Stacked rather than a grid: this panel lives in the sidebar, which
can be as narrow as 160px, so side-by-side cards would wrap. */}
<div className="flex flex-col gap-1.5">
{SELECTABLE_PRESETS.map((preset) => {
const Icon = PRESET_ICONS[preset];
const active = activeLabel === preset;
return (
<button
key={preset}
type="button"
disabled={applying}
onClick={() => void choosePreset(preset)}
aria-pressed={active}
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
active
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<Icon
size={13}
className={`mt-0.5 shrink-0 ${
active ? "text-accent-brand" : "text-muted-foreground"
}`}
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-xs font-semibold">
{t(`newUi.sidebar.userProfile.preset_${preset}`)}
</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{t(`newUi.sidebar.userProfile.preset_${preset}_desc`)}
</span>
</span>
{active && (
<Check
size={12}
className="mt-0.5 shrink-0 text-accent-brand"
/>
)}
</button>
);
})}
</div>
{customizedAreas.length > 0 && (
<div className="flex flex-col gap-1 border border-border bg-card p-2">
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<Layers size={11} className="shrink-0" />
<span className="min-w-0 truncate">
{t("newUi.sidebar.userProfile.presetOverridesTitle")}
</span>
</span>
{customizedAreas.map((area) => (
<div
key={area.key}
className="flex items-center justify-between gap-2 py-0.5"
>
<span className="min-w-0 flex-1 truncate text-xs">
{area.label}
</span>
<button
type="button"
onClick={() => ctx.clearArea(area.key as UiAreaKey)}
className="shrink-0 text-[10px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
{t("newUi.sidebar.userProfile.presetRevertArea")}
</button>
</div>
))}
<Button
variant="outline"
size="sm"
className="mt-1 h-7 w-full text-[10px]"
onClick={() => ctx.clearAllOverrides()}
>
<RotateCcw size={11} />
{t("newUi.sidebar.userProfile.presetClearOverrides")}
</Button>
</div>
)}
{onRunSetupAgain && (
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-[10px] leading-snug text-muted-foreground">
{t("newUi.sidebar.userProfile.runSetupAgainDesc")}
</span>
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={onRunSetupAgain}
>
{t("newUi.sidebar.userProfile.runSetupAgainButton")}
</Button>
</div>
)}
</div>
);
}
@@ -1,113 +0,0 @@
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
import { FakeSwitch } from "@/components/section-card";
import { Button } from "@/components/button";
import {
useAreaPreferences,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
import { moveRailItem, useRailItems } from "./rail-items";
/**
* Appearance > Sidebar > Navigation: one switch per rail destination,
* including the ones plugins register, which appear and disappear here as
* their plugin is turned on and off. The arrows reorder the rail.
*/
export function NavigationVisibilityToggles({
hidden,
onChange,
}: {
hidden: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const { t } = useTranslation();
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const { order } = useAreaPreferences("rail");
const main = railItems.filter((item) => item.placement !== "footer");
const band = (id: string) =>
main.find((item) => item.id === id)?.group ?? "tools";
const move = (id: string, direction: -1 | 1) => {
const peers = main.filter((item) => band(item.id) === band(id));
const at = peers.findIndex((item) => item.id === id);
const target = at + direction;
if (target < 0 || target >= peers.length) return;
// Moving down means sitting before the item after the next one.
const before =
direction === -1 ? peers[target].id : (peers[target + 1]?.id ?? null);
uiPrefs?.setOverride("rail", "order", moveRailItem(main, id, before));
};
return (
<>
{railItems
.filter((item) => item.hideable !== false)
.map((item) => {
const reorderable = item.placement !== "footer" && !!uiPrefs;
const peers = main.filter((peer) => band(peer.id) === band(item.id));
const index = peers.findIndex((peer) => peer.id === item.id);
return (
<div
key={item.id}
className="flex items-center justify-between gap-2 py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
</span>
<span className="truncate">{t(item.labelKey)}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{reorderable && (
<>
<Button
variant="ghost"
size="icon-xs"
disabled={index <= 0}
title={t("nav.moveUp")}
aria-label={t("nav.moveUp")}
onClick={() => move(item.id, -1)}
>
<ChevronUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={index < 0 || index >= peers.length - 1}
title={t("nav.moveDown")}
aria-label={t("nav.moveDown")}
onClick={() => move(item.id, 1)}
>
<ChevronDown />
</Button>
</>
)}
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</span>
</div>
);
})}
{uiPrefs && order && order.length > 0 && (
<Button
variant="ghost"
size="xs"
className="mt-1 self-start text-muted-foreground"
onClick={() => uiPrefs.setOverride("rail", "order", null)}
>
<RotateCcw />
{t("nav.resetRailOrder")}
</Button>
)}
</>
);
}
-316
View File
@@ -1,316 +0,0 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Textarea } from "@/components/textarea";
import { GroupHeading, PANEL } from "@/components/panel-layout";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
import { resolveHostDefaults } from "@/api/host-defaults-api";
import {
pluginSettingsFrom,
withDefaultPluginSettings,
} from "./host-defaults/quick-connect-defaults";
import {
createQuickConnectHost,
quickConnectTargets,
} from "./quick-connect-host";
import { useHostProtocols } from "./host-protocols";
import { Select2 } from "@/components/select2";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import { useHostActions, type HostActionDef } from "./host-contributions";
// Core types Quick Connect draws its own fields for.
const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
const LABEL_CLASS =
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
const SELECT_CLASS =
"flex h-8 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
interface QuickConnectPanelProps {
onConnect: (host: Host, type: string) => void;
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1 min-w-0">
<label className={LABEL_CLASS}>{label}</label>
{children}
</div>
);
}
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const { t } = useTranslation();
const [host, setHost] = useState("");
// "ssh", or the id of a plugin protocol offered in Quick Connect.
const [protocol, setProtocol] = useState("ssh");
const [port, setPort] = useState("22");
const [domain, setDomain] = useState("");
const [username, setUsername] = useState("root");
const [authType, setAuthType] = useState("password");
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [credentialId, setCredentialId] = useState("");
const [credentials, setCredentials] = useState<
{ id: string; name: string; username: string }[]
>([]);
useEffect(() => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {});
}, []);
// Every registered SSH auth type that works for a host that is never saved.
const { providers } = useSshAuthProviders();
const authOptions = providers.filter(
(option) => option.available && option.quickConnect,
);
const authEditor = useSshAuthEditors().find(
(editor) => editor.id === authType,
);
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
? undefined
: authEditor?.component;
const allActions = useHostActions();
const pluginProtocols = useHostProtocols().filter(
(entry) => entry.quickConnect,
);
const selected = pluginProtocols.find((entry) => entry.id === protocol);
const isDesktop = !!selected;
const defaultPort = (id: string) =>
String(pluginProtocols.find((entry) => entry.id === id)?.defaultPort ?? 22);
const switchProtocol = (next: string) => {
// Keep a port the user typed; only swap the protocol default.
if (port === defaultPort(protocol)) setPort(defaultPort(next));
setProtocol(next);
};
// A host that is never saved still follows the user's host defaults.
const [defaultPluginSettings, setDefaultPluginSettings] = useState<
Record<string, Record<string, unknown>>
>({});
useEffect(() => {
resolveHostDefaults({ folder: null })
.then((values) => setDefaultPluginSettings(pluginSettingsFrom(values)))
.catch(() => {});
}, []);
const buildHost = () =>
withDefaultPluginSettings(
createQuickConnectHost({
ip: host.trim(),
port: parseInt(port) || parseInt(defaultPort(protocol)),
username,
authType: isDesktop ? "password" : authType,
password,
key: privateKey,
credentialId,
protocol: selected,
domain: domain || undefined,
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
}),
defaultPluginSettings,
);
const targets = quickConnectTargets(allActions, buildHost());
const connect = (action?: HostActionDef) => {
if (!host.trim()) return;
if (!isDesktop && !username) return;
const hostConfig = buildHost();
const target = action ?? quickConnectTargets(allActions, hostConfig)[0];
if (!target?.tabType) {
toast.error(t("newUi.sidebar.quickConnect.noTarget"));
return;
}
onConnect(hostConfig, target.tabType);
};
const onEnter = (e: React.KeyboardEvent) => {
if (e.key === "Enter") connect();
};
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<GroupHeading title={t("newUi.sidebar.quickConnect.where")} />
<div className="grid grid-cols-[1fr_5rem] gap-2">
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
<Select2
value={protocol}
onChange={(e) => switchProtocol(e.target.value)}
className={SELECT_CLASS}
>
<option value="ssh">
{t("newUi.sidebar.quickConnect.sshProtocol")}
</option>
{pluginProtocols.map((entry) => (
<option key={entry.id} value={entry.id}>
{t(entry.titleKey)}
</option>
))}
</Select2>
</Field>
<Field label={t("newUi.sidebar.quickConnect.portLabel")}>
<Input
placeholder={defaultPort(protocol)}
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={onEnter}
className="h-8 text-xs"
/>
</Field>
</div>
<Field label={t("newUi.sidebar.quickConnect.hostLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={onEnter}
className="h-8 text-xs"
/>
</Field>
<GroupHeading
title={t("newUi.sidebar.quickConnect.who")}
className="pt-1"
/>
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
value={username}
onFocus={() => {
if (username === "root") setUsername("");
}}
onBlur={() => {
if (username === "") setUsername("root");
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={onEnter}
className="h-8 text-xs"
/>
</Field>
{!isDesktop && authOptions.length > 0 && (
<Field label={t("newUi.sidebar.quickConnect.authLabel")}>
<Select2
value={authType}
onChange={(e) => {
setAuthType(e.target.value);
setAuthFields({});
}}
className={SELECT_CLASS}
>
{authOptions.map((option) => (
<option key={option.type} value={option.type}>
{t(option.editorTitleKey ?? option.labelKey)}
</option>
))}
</Select2>
</Field>
)}
{(isDesktop || authType === "password") && (
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
<PasswordInput
placeholder={t("newUi.sidebar.quickConnect.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
className="h-8 pr-8 text-xs"
/>
</Field>
)}
{selected?.quickConnect?.showDomain && (
<Field label={t("newUi.sidebar.quickConnect.domainLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.domainPlaceholder")}
value={domain}
onChange={(e) => setDomain(e.target.value)}
onKeyDown={onEnter}
className="h-8 text-xs"
/>
</Field>
)}
{!isDesktop && authType === "key" && (
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
<Textarea
placeholder={t(
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
)}
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
className="h-24 resize-none font-mono text-xs"
/>
</Field>
)}
{!isDesktop && authType === "credential" && (
<Field label={t("newUi.sidebar.quickConnect.credentialLabel")}>
<Select2
value={credentialId}
onChange={(e) => {
const newId = e.target.value;
setCredentialId(newId);
const cred = credentials.find((c) => c.id === newId);
if (cred?.username) setUsername(cred.username);
}}
className={SELECT_CLASS}
>
<option value="">
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</Select2>
</Field>
)}
{!isDesktop && AuthEditor && (
<AuthEditor
form={{ ...authFields, ip: host, port, username }}
setField={(key, value) => {
if (key === "ip") setHost(String(value ?? ""));
else if (key === "port") setPort(String(value ?? ""));
else if (key === "username") setUsername(String(value ?? ""));
else setAuthFields((current) => ({ ...current, [key]: value }));
}}
/>
)}
<div className="flex flex-col gap-1.5 pt-2">
{targets.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.quickConnect.noTarget")}
</p>
) : (
targets.map((action, index) => (
<button
key={action.id}
type="button"
disabled={!host.trim()}
onClick={() => connect(action)}
className={`flex h-8 w-full items-center justify-center gap-1.5 border text-xs font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 ${
index === 0
? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
: "border-border text-foreground hover:bg-muted"
}`}
>
<action.icon className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToAction", {
name: t(action.titleKey),
})}
</button>
))
)}
</div>
</div>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
/**
* The count a rail item shows on its icon. Each badge is its own component so
* the item's useBadge hook runs once per render of a stable element.
*/
export function RailBadge({
useBadge,
variant = "count",
className = "",
}: {
useBadge: () => number | null | undefined;
variant?: "count" | "dot";
className?: string;
}) {
const count = useBadge();
if (!count || count <= 0) return null;
if (variant === "dot") {
return (
<span
className={`absolute size-1.5 rounded-full bg-accent-brand ${className}`}
/>
);
}
return (
<span
className={`absolute min-w-[14px] h-[14px] px-[3px] rounded-full bg-accent-brand text-[9px] font-bold leading-[14px] text-center text-white tabular-nums ${className}`}
>
{count > 99 ? "99+" : count}
</span>
);
}
-22
View File
@@ -1,22 +0,0 @@
/**
* Drop indicator for sidebar drag-to-reorder.
*
* Drawn once by the tree at an absolute offset from the virtualizer's own
* slot geometry, never by the rows themselves. Rows can't place this
* correctly: in hover mode the hovered row expands its action tray while the
* row below stays collapsed, so a bar at the hovered row's bottom edge and
* one at the next row's top edge land in two different places. That produced
* two candidate lines whose visibility flickered with the pointer.
*/
export function ReorderIndicator({ top }: { top: number }) {
return (
<div
aria-hidden
className="absolute inset-x-0 h-0.5 bg-accent-brand pointer-events-none z-30"
style={{ top: top - 1 }}
>
<span className="absolute -left-px -top-[3px] size-2 bg-accent-brand" />
<span className="absolute -right-px -top-[3px] size-2 bg-accent-brand" />
</div>
);
}
-2
View File
@@ -1,2 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
export * from "./tree";
-82
View File
@@ -1,82 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PluginSettingsField } from "@termix-ssh/plugin-sdk/manifest";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import {
getPluginHostSettings,
updatePluginHostSettings,
PluginSettingsValidationError,
} from "@/api/plugins-api";
/**
* Fields an SSH auth type declares, for a plugin that did not register its
* own editor. They are the plugin's host settings (so the plugin reads them
* with ctx.settings.getHost), which means a host must be saved first.
*/
export function SshAuthProviderFields({
pluginId,
hostId,
fields,
}: {
pluginId: string;
hostId: number | null | undefined;
fields: PluginSettingsField[];
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!hostId) return;
let cancelled = false;
getPluginHostSettings(pluginId, hostId)
.then((loaded) => {
if (!cancelled) setValues(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [pluginId, hostId]);
if (!hostId) {
return (
<p className="text-[10px] text-muted-foreground">
{t("hosts.authFieldsAfterSave")}
</p>
);
}
const setValue = (key: string, value: unknown) => {
const next = { ...values, [key]: value };
setValues(next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
updatePluginHostSettings(pluginId, hostId, { [key]: value })
.then(() => setErrors((prev) => ({ ...prev, [key]: "" })))
.catch((error) => {
if (error instanceof PluginSettingsValidationError) {
setErrors((prev) => ({ ...prev, ...error.errors }));
}
});
}, 400);
};
return (
<div className="flex flex-col gap-3 border-t border-border pt-3">
{fields.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={pluginId}
field={field}
values={values}
setValue={setValue}
running
error={errors[field.key] || undefined}
/>
))}
</div>
);
}
File diff suppressed because it is too large. Load diff
-18
View File
@@ -1,18 +0,0 @@
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
export const BRANDING_LOGO_ACCEPT = "image/png,image/jpeg,image/webp";
const BRANDING_LOGO_ACCEPTED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
export function isAcceptedBrandingLogoFile(file: {
type: string;
size: number;
}): boolean {
return (
BRANDING_LOGO_ACCEPTED_TYPES.has(file.type) &&
file.size > 0 &&
file.size <= BRANDING_LOGO_MAX_BYTES
);
}
-68
View File
@@ -1,68 +0,0 @@
import type { Credential } from "@/types/ui-types";
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
/**
* Sorts a flat credential list. No tree recursion needed -- unlike hosts,
* credential folders are explicitly flat, so this is a plain array sort.
*/
export function sortCredentials(
creds: Credential[],
key: CredentialSortKey,
pinnedFirst = false,
): Credential[] {
const sorted = [...creds];
switch (key) {
case "name-asc":
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case "name-desc":
sorted.sort((a, b) => b.name.localeCompare(a.name));
break;
case "username-asc":
sorted.sort((a, b) => a.username.localeCompare(b.username));
break;
case "username-desc":
sorted.sort((a, b) => b.username.localeCompare(a.username));
break;
case "manual":
sorted.sort((a, b) => {
const aOrder = a.sortOrder ?? null;
const bOrder = b.sortOrder ?? null;
if (aOrder == null && bOrder == null)
return a.name.localeCompare(b.name);
if (aOrder == null) return 1;
if (bOrder == null) return -1;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name);
});
break;
case "default":
default:
break;
}
if (pinnedFirst) {
sorted.sort((a, b) => {
const aPinned = a.pin ? 1 : 0;
const bPinned = b.pin ? 1 : 0;
return bPinned - aPinned;
});
}
return sorted;
}
export function credentialPassesFilters(
cred: Credential,
filters: { type: string[]; tags: string[] },
): boolean {
if (filters.type.length > 0 && !filters.type.includes(cred.type))
return false;
if (
filters.tags.length > 0 &&
!filters.tags.some((tag) => cred.tags?.includes(tag))
)
return false;
return true;
}
@@ -1,415 +0,0 @@
import { rem } from "@/lib/rem";
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 {
defaultCredentialRowFields,
type CredentialDensity,
type CredentialRowFields,
type CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
/**
* 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;
const DEFAULT_ROW_FIELDS = defaultCredentialRowFields();
export function CredentialItem({
cred,
usedByCount = 0,
stripeIndex = 0,
isMenuOpen = false,
isTrayOpen = false,
onTrayOpenChange,
onDragStart,
onDragEnd,
depth = 0,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields = DEFAULT_ROW_FIELDS,
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;
rowFields?: CredentialRowFields;
/** 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 showUsername = rowFields.showUsername && !!cred.username;
const showUsage = rowFields.showUsageCount && usedByCount > 0;
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: rem(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}
{rowFields.showSharedBadge && 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>
{rowFields.showTypeBadge && (
<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) }}
/>
{rowFields.showPinIcon && 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">
{rowFields.showUsageCount && 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 && (showUsername || showUsage) && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{showUsername && cred.username}
{showUsage && (
<span
className={`text-muted-foreground/40${showUsername ? " ml-2" : ""}`}
>
{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>
);
}
@@ -1,463 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { reorderCredentials } from "@/main-axios";
import { planReorder, renumberSiblings } from "@/sidebar/reorder-utils";
import { ReorderIndicator } from "@/sidebar/ReorderIndicator";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialRowFields,
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
import { CredentialItem } from "./CredentialItem/CredentialItem";
import { getCredentialRowHeight } from "./credential-row-height";
import { CredentialFolderItem } from "./FolderItem/CredentialFolderItem";
import {
isFolder,
collectVisibleRows,
collectAllFolderNames,
type CredentialFolder,
} from "./visible-rows";
export function CredentialSidebarTree({
folders,
onDeployCredential,
onEditCredential,
onCloneCredential,
onDeleteCredential,
onShareCredential,
usedByCounts,
query = "",
loading = false,
arrangeLocked = true,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeleteFolder,
onMoveCredentialToFolder,
}: {
folders: CredentialFolder[];
onDeployCredential: (cred: Credential) => void;
onEditCredential: (cred: Credential) => void;
onCloneCredential: (cred: Credential) => void;
onDeleteCredential: (cred: Credential) => void;
onShareCredential?: (cred: Credential) => void;
usedByCounts?: Map<string, number>;
query?: string;
loading?: boolean;
/** When true, drag-to-rearrange is off entirely. Toggled from the panel header. */
arrangeLocked?: boolean;
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
rowFields?: CredentialRowFields;
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeleteFolder?: (folder: string) => void;
/** Drop a credential on a folder header to reassign it. */
onMoveCredentialToFolder?: (
credentialId: string,
targetFolder: string,
) => void | Promise<void>;
}) {
const { t } = useTranslation();
const parentRef = useRef<HTMLDivElement>(null);
const [openFolders, setOpenFolders] = useState<Set<string>>(
() => new Set(collectAllFolderNames(folders)),
);
const seenFolderNames = useRef<Set<string>>(
new Set(collectAllFolderNames(folders)),
);
// Folders default to open, but the initial render often happens before
// credentials finish loading (folders === []), so the useState initializer
// above freezes on an empty set. Newly-appearing folder names (including
// the very first load) are added to the open set as they show up; a
// folder the user has explicitly closed stays closed on future renders
// since it's already tracked in seenFolderNames.
useEffect(() => {
const currentNames = collectAllFolderNames(folders);
const unseen = currentNames.filter((n) => !seenFolderNames.current.has(n));
if (unseen.length === 0) return;
for (const name of unseen) seenFolderNames.current.add(name);
setOpenFolders((prev) => {
const next = new Set(prev);
for (const name of unseen) next.add(name);
return next;
});
}, [folders]);
const [openTrayCredentialId, setOpenTrayCredentialId] = useState<
string | null
>(null);
const [openMenuCredentialId, setOpenMenuCredentialId] = useState<
string | null
>(null);
const [draggedReorderKey, setDraggedReorderKey] = useState<string | null>(
null,
);
// Tracks the single credential being dragged for folder reassignment,
// separate from draggedReorderKey (which drives manual-sort-mode
// position reordering). Only active outside manual sort mode.
const [draggedCredentialId, setDraggedCredentialId] = useState<string | null>(
null,
);
// Tracks which single row is currently the manual-reorder drop target,
// lifted here rather than local per-row state so only one row can ever
// show the drop-indicator bar at a time. See HostItem/SidebarTree's
// identical fix for the full rationale.
const [reorderHoverKey, setReorderHoverKey] = useState<string | null>(null);
const [reorderHoverEdge, setReorderHoverEdge] = useState<
"before" | "after" | null
>(null);
const arrangeMode = !arrangeLocked;
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const alwaysShowActions = trayTrigger === "always";
const actionsOnly = trayTrigger === "actionsOnly";
const clickTrayActive =
!alwaysShowActions &&
!actionsOnly &&
(trayTrigger === "click" || isTouchOnly);
const visibleRows = collectVisibleRows(folders, query, openFolders);
// Fixed, exactly-computed row heights rather than estimate-then-measure,
// matching SidebarTree.tsx's approach -- estimate-then-measure caused
// visible gaps between rows there. All constants below were measured live
// against the running app (Playwright getBoundingClientRect) for every
// density x trayTrigger x open/closed-tray x credential-type combination.
//
// Unlike hosts, credential row height genuinely depends on TYPE, not just
// density/trayTrigger: only "key" credentials render a connection-buttons
// row (deploy/copy-command) -- "password" credentials have no connection
// buttons at all, so a password row is shorter than a key row whenever
// that row is showing (actionsOnly closed, or always mode).
const FOLDER_ROW_HEIGHT = 31.5;
const rowHeight = useCallback(
(index: number) => {
const row = visibleRows[index];
if (!row) return FOLDER_ROW_HEIGHT;
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
const isKey = row.item.type === "key";
const isOpen =
(openTrayCredentialId === row.item.id ||
openMenuCredentialId === row.item.id) &&
(clickTrayActive || actionsOnly);
return getCredentialRowHeight({
density,
isKey,
alwaysShowActions,
actionsOnly,
isOpen,
showTags,
tagCount: row.item.tags?.length ?? 0,
hasDetailRow:
!rowFields ||
(rowFields.showUsername && !!row.item.username) ||
(rowFields.showUsageCount &&
(usedByCounts?.get(row.item.id) ?? 0) > 0),
});
},
[
visibleRows,
openTrayCredentialId,
openMenuCredentialId,
clickTrayActive,
alwaysShowActions,
actionsOnly,
density,
showTags,
rowFields,
usedByCounts,
],
);
const virtualizer = useVirtualizer({
count: visibleRows.length,
getScrollElement: () => parentRef.current,
estimateSize: rowHeight,
overscan: 12,
getItemKey: (index) => {
const row = visibleRows[index];
if (!row) return index;
return isFolder(row.item)
? `folder:${row.item.name}`
: `cred:${row.item.id}`;
},
});
// Single tree-level indicator off the virtualizer's slot geometry -- see
// SidebarTree for why rows can't place this themselves.
const reorderIndicatorTop = (() => {
if (!arrangeMode || !reorderHoverKey || !reorderHoverEdge) return null;
const index = visibleRows.findIndex((row) => {
const key = isFolder(row.item)
? `folder:${row.item.name}`
: `cred:${row.item.id}`;
return key === reorderHoverKey;
});
if (index === -1) return null;
const slot = virtualizer
.getVirtualItems()
.find((vItem) => vItem.index === index);
if (!slot) return null;
return reorderHoverEdge === "before" ? slot.start : slot.start + slot.size;
})();
useLayoutEffect(() => {
virtualizer.measure();
}, [
virtualizer,
openFolders,
openTrayCredentialId,
openMenuCredentialId,
query,
visibleRows.length,
density,
trayTrigger,
showTags,
rowFields,
]);
function toggleFolder(name: string) {
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
/**
* Resolves a credential drop into its new position, scoped to the target
* credential's own folder, and moves it there when the drop crossed
* folders. Credential folders themselves are always alphabetical -- they
* have no stored order, so only credentials are draggable.
*/
/**
* Credential dropped on a folder header: lands at the end of that folder.
* It needs an explicit sortOrder for the same reason hosts do -- a null
* one sorts last under manual sort and then falls back to alphabetical,
* throwing the placement away.
*/
async function handleDropIntoFolder(credentialId: string, folder: string) {
const destination = folders
.find((f) => f.name === folder)
?.children.filter((c) => c.id !== credentialId);
try {
await onMoveCredentialToFolder?.(credentialId, folder);
if (destination) {
const ordered = [
...destination.filter((c) => c.sortOrder != null),
...destination.filter((c) => c.sortOrder == null),
];
await reorderCredentials(
renumberSiblings([...ordered, { id: credentialId }]).map((r) => ({
id: Number(r.id),
sortOrder: r.sortOrder,
})),
);
}
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
} catch {
toast.error(t("credentials.failedToReorder"));
}
}
async function handleReorderDrop(
targetKey: string,
position: "before" | "after",
) {
const draggedKey = draggedReorderKey;
setDraggedReorderKey(null);
setReorderHoverKey(null);
setReorderHoverEdge(null);
if (!draggedKey) return;
if (!draggedKey.startsWith("cred:") || !targetKey.startsWith("cred:"))
return;
// From every folder's children, not visibleRows: a collapsed folder
// contributes no visible rows, so planning off the rendered list
// compared against a partial sibling group.
const rows = folders.flatMap((folder) =>
folder.children.map((cred) => ({
key: `cred:${cred.id}`,
parentKey: `folder:${cred.folder || "Uncategorized"}`,
sortOrder: cred.sortOrder,
})),
);
const plan = planReorder(rows, draggedKey, targetKey, position);
if (!plan) return;
const draggedId = draggedKey.slice("cred:".length);
try {
if (plan.movedTo !== null) {
const folder = plan.movedTo.slice("folder:".length);
await onMoveCredentialToFolder?.(draggedId, folder);
}
await reorderCredentials(
plan.positions.map((pos) => ({
id: Number(pos.key.slice("cred:".length)),
sortOrder: pos.sortOrder,
})),
);
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
} catch {
toast.error(t("credentials.failedToReorder"));
}
}
if (loading) {
return (
<div className="relative flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2 space-y-1.5">
{[60, 45, 55, 40].map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<div className="size-3 bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 bg-muted/50 animate-pulse"
style={{ width: `${w * 2}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">
{t("credentials.loadingCredentials")}
</span>
</div>
</div>
</div>
);
}
return (
<div className="relative flex flex-col flex-1 min-h-0">
<div ref={parentRef} className="flex-1 min-h-0 overflow-y-auto">
{visibleRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{query
? t("credentials.noCredentialsMatchSearch")
: t("credentials.noCredentialsFound")}
</span>
</div>
) : (
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{reorderIndicatorTop !== null && (
<ReorderIndicator top={reorderIndicatorTop} />
)}
{virtualizer.getVirtualItems().map((vItem) => {
const row = visibleRows[vItem.index];
if (!row) return null;
const { item, depth } = row;
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${vItem.start}px)` }}
>
{isFolder(item) ? (
<CredentialFolderItem
folder={item}
isOpen={openFolders.has(item.name)}
stripeIndex={vItem.index}
query={query}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onToggleFolder={toggleFolder}
onEditingFolderNameChange={onEditingFolderNameChange}
onEditingFolderValueChange={onEditingFolderValueChange}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
depth={depth}
arrangeMode={arrangeMode}
draggedCredentialId={draggedCredentialId}
onDropCredential={(credentialId) => {
setDraggedCredentialId(null);
void handleDropIntoFolder(credentialId, item.name);
}}
/>
) : (
<CredentialItem
cred={item}
usedByCount={usedByCounts?.get(item.id) ?? 0}
query={query}
stripeIndex={vItem.index}
isMenuOpen={openMenuCredentialId === item.id}
onMenuOpenChange={(open) =>
setOpenMenuCredentialId(open ? item.id : null)
}
isTrayOpen={openTrayCredentialId === item.id}
onTrayOpenChange={(open) =>
setOpenTrayCredentialId(open ? item.id : null)
}
onDragStart={() => {
// Both arm together: the drop target picks whether
// this ends up a reorder or a folder move.
setDraggedReorderKey(`cred:${item.id}`);
setDraggedCredentialId(item.id);
}}
onDragEnd={() => {
setDraggedReorderKey(null);
setDraggedCredentialId(null);
setReorderHoverKey(null);
setReorderHoverEdge(null);
}}
depth={depth}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
rowFields={rowFields}
arrangeMode={arrangeMode}
isDragging={draggedReorderKey === `cred:${item.id}`}
onReorderDrop={(position) =>
handleReorderDrop(`cred:${item.id}`, position)
}
isReorderHovered={reorderHoverKey === `cred:${item.id}`}
reorderHoverEdge={reorderHoverEdge}
onReorderHoverChange={(edge) => {
setReorderHoverKey(edge ? `cred:${item.id}` : null);
setReorderHoverEdge(edge);
}}
onDeploy={() => onDeployCredential(item)}
onEdit={() => onEditCredential(item)}
onClone={() => onCloneCredential(item)}
onDelete={() => onDeleteCredential(item)}
onShare={() => onShareCredential?.(item)}
/>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -1,174 +0,0 @@
import { rem } from "@/lib/rem";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Folder,
FolderOpen,
Pencil,
Trash2,
X,
} from "lucide-react";
import type { CredentialFolder } from "../visible-rows";
const UNCATEGORIZED = "Uncategorized";
export function CredentialFolderItem({
folder,
isOpen,
stripeIndex,
query = "",
editingFolderName,
editingFolderValue,
onToggleFolder,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeleteFolder,
depth = 0,
arrangeMode = false,
draggedCredentialId,
onDropCredential,
}: {
folder: CredentialFolder;
isOpen: boolean;
stripeIndex?: number;
query?: string;
editingFolderName: string | null;
editingFolderValue: string;
onToggleFolder: (name: string) => void;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeleteFolder?: (folder: string) => void;
depth?: number;
/** When true (rearranging unlocked), the header accepts dropped
* credentials. Credential folders themselves have no stored order, so
* they are never draggable. */
arrangeMode?: boolean;
/** Id of the credential currently being dragged (outside manual sort
* mode), if any -- drives the drop-target highlight below. */
draggedCredentialId?: string | null;
/** Fires when a dragged credential is dropped on this folder header to
* reassign it, outside manual sort mode. */
onDropCredential?: (credentialId: string) => void;
}) {
const { t } = useTranslation();
const [dragOver, setDragOver] = useState(false);
const isRenaming = editingFolderName === folder.name;
const canManage = folder.name !== UNCATEGORIZED;
const actionButtonClass =
"flex items-center justify-center size-6 text-muted-foreground/60 hover:text-foreground hover:bg-background/80 transition-colors";
const acceptsCredentialDrop = arrangeMode && !!draggedCredentialId;
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
>
<div
onDragOver={(e) => {
if (!acceptsCredentialDrop) return;
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (!acceptsCredentialDrop || !draggedCredentialId) return;
e.preventDefault();
e.stopPropagation();
setDragOver(false);
onDropCredential?.(draggedCredentialId);
}}
onClick={() => !query && !isRenaming && onToggleFolder(folder.name)}
className={`group/folder flex items-center gap-2 w-full pl-2.5 pr-2 py-1.5 transition-colors text-left cursor-pointer ${
isOpen ? "bg-muted/40" : "hover:bg-muted/30"
} ${(stripeIndex ?? 0) % 2 === 1 && !isOpen ? "bg-muted/[0.08]" : ""} ${dragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""}`}
>
<ChevronRight
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
{isOpen ? (
<FolderOpen className="size-4 shrink-0 text-accent-brand" />
) : (
<Folder className="size-4 shrink-0 text-muted-foreground/70" />
)}
{isRenaming ? (
<>
<input
autoFocus
value={editingFolderValue}
onChange={(e) => onEditingFolderValueChange(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
onEditingFolderNameChange(null);
if (newName && newName !== folder.name) {
await onRenameFolder(folder.name, newName);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") onEditingFolderNameChange(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[13px] font-bold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(null);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate">
<span className="text-[13px] font-bold text-foreground tracking-tight">
{folder.name}
</span>
</span>
<span className="text-[10px] tabular-nums shrink-0 ml-1 px-1.5 py-[1px] bg-muted/70 text-muted-foreground/50">
{folder.children.length}
</span>
{canManage && (
<span className="flex items-center gap-0.5 ml-0.5 opacity-0 group-hover/folder:opacity-100 transition-opacity">
<button
type="button"
title={t("credentials.renameFolder")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(folder.name);
onEditingFolderValueChange(folder.name);
}}
>
<Pencil className="size-3" />
</button>
{onDeleteFolder && (
<button
type="button"
title={t("credentials.deleteFolder")}
className={`${actionButtonClass} hover:text-destructive`}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder.name);
}}
>
<Trash2 className="size-3" />
</button>
)}
</span>
)}
</>
)}
</div>
</div>
);
}
@@ -1,64 +0,0 @@
import type { CredentialDensity } from "@/types/credential-sidebar-preferences";
const HEIGHTS = {
comfortable: {
base: 45,
openKey: 104.75,
openPassword: 78.5,
actionsOnlyKey: 75.75,
actionsOnlyPassword: 49.5,
alwaysKey: 100.25,
alwaysPassword: 74,
tags: 18.5,
detail: 15,
},
compact: {
base: 23,
openKey: 79.25,
openPassword: 56.5,
actionsOnlyKey: 50.25,
actionsOnlyPassword: 27.5,
alwaysKey: 74.75,
alwaysPassword: 52,
tags: 12.5,
detail: 0,
},
} as const;
export function getCredentialRowHeight({
density,
isKey,
alwaysShowActions,
actionsOnly,
isOpen,
showTags,
tagCount,
hasDetailRow = true,
}: {
density: CredentialDensity;
isKey: boolean;
alwaysShowActions: boolean;
actionsOnly: boolean;
isOpen: boolean;
showTags: boolean;
tagCount: number;
/** False when the username and usage line has nothing to show. */
hasDetailRow?: boolean;
}): number {
const heights = HEIGHTS[density];
let height: number = heights.base;
if (alwaysShowActions) {
height = isKey ? heights.alwaysKey : heights.alwaysPassword;
} else if (isOpen) {
height = isKey ? heights.openKey : heights.openPassword;
} else if (actionsOnly) {
height = isKey ? heights.actionsOnlyKey : heights.actionsOnlyPassword;
}
return (
height +
(showTags && tagCount > 0 ? heights.tags : 0) -
(hasDetailRow ? 0 : heights.detail)
);
}
@@ -1,126 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getCredentialSidebarPreferences,
saveCredentialSidebarPreferences,
getUserPreferences,
} from "@/main-axios";
import {
defaultCredentialSidebarPreferences,
sanitizeCredentialSidebarPreferences,
type CredentialSidebarPreferences,
} from "@/types/credential-sidebar-preferences";
const SAVE_DEBOUNCE_MS = 500;
const LS_KEY = "credentialSidebarPreferences";
const SYNC_EVENT = "credentialSidebarPreferencesChanged";
function readCache(): CredentialSidebarPreferences | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
return sanitizeCredentialSidebarPreferences(JSON.parse(raw));
} catch {
return null;
}
}
function writeCache(preferences: CredentialSidebarPreferences) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
} catch {
/* ignore */
}
}
/**
* Unified credential sidebar preferences (sort, filters, open folders,
* density, tag visibility, tray trigger), cached in localStorage for instant
* paint and synced to the backend so they follow the user across devices
* when storageMode is "cloud" -- same cache-then-fetch-then-debounced-save
* shape as useHostSidebarPreferences, but a fully independent parallel
* system (separate table, separate localStorage key, separate sync event),
* not a shared blob with hosts.
*
* Multiple components (CredentialsPanel, CustomizeCredentialsSidebarPanel)
* can call this hook at the same time. Each holds its own React state, so a
* write in one instance broadcasts a same-tab custom event that every other
* instance listens for to stay in sync.
*/
export function useCredentialSidebarPreferences() {
const [preferences, setPreferences] = useState<CredentialSidebarPreferences>(
() => readCache() ?? defaultCredentialSidebarPreferences(),
);
const [loaded, setLoaded] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
getCredentialSidebarPreferences()
.then((remote) => {
if (cancelled) return;
setPreferences(remote);
writeCache(remote);
})
.catch(() => {
/* keep cache/default */
})
.finally(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handler = () => {
const cached = readCache();
if (cached) setPreferences(cached);
};
window.addEventListener(SYNC_EVENT, handler);
return () => window.removeEventListener(SYNC_EVENT, handler);
}, []);
const update = useCallback(
(
patch:
| Partial<CredentialSidebarPreferences>
| ((
prev: CredentialSidebarPreferences,
) => CredentialSidebarPreferences),
) => {
setPreferences((prev) => {
const next = sanitizeCredentialSidebarPreferences(
typeof patch === "function" ? patch(prev) : { ...prev, ...patch },
);
writeCache(next);
window.dispatchEvent(new Event(SYNC_EVENT));
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
getUserPreferences()
.then((prefs) => {
if (prefs.storageMode !== "cloud") return;
return saveCredentialSidebarPreferences(next);
})
.catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
return next;
});
},
[],
);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
return { preferences, update, loaded };
}
-2
View File
@@ -1,2 +0,0 @@
export { CredentialSidebarTree } from "./CredentialSidebarTree";
export { type CredentialFolder } from "./visible-rows";
@@ -1,66 +0,0 @@
import type { Credential } from "@/types/ui-types";
/**
* Credential folders are explicitly flat (no nesting), unlike HostFolder --
* so this has no depth/path-string tree logic, just a name and a list of
* credentials. `children` matches HostFolder's shape for isFolder()
* structural parity, but never contains another CredentialFolder.
*/
export interface CredentialFolder {
name: string;
children: Credential[];
}
export function isFolder(
item: Credential | CredentialFolder,
): item is CredentialFolder {
return "children" in item;
}
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))
);
}
export type VirtualRow = {
item: Credential | CredentialFolder;
depth: number;
};
/**
* Builds the flattened, currently-visible row list for the virtualizer.
* Folders are always depth 0, credentials always depth 1 -- there is no
* deeper nesting to walk, unlike hosts' recursive collectVisibleRows.
*/
export function collectVisibleRows(
folders: CredentialFolder[],
query: string,
openSet: Set<string>,
): VirtualRow[] {
const out: VirtualRow[] = [];
const q = query.trim().toLowerCase();
for (const folder of folders) {
const matchingCreds = q
? folder.children.filter((c) => credentialMatchesQuery(c, q))
: folder.children;
if (matchingCreds.length === 0) continue;
out.push({ item: folder, depth: 0 });
const isOpen = q ? true : openSet.has(folder.name);
if (isOpen) {
for (const cred of matchingCreds) {
out.push({ item: cred, depth: 1 });
}
}
}
return out;
}
export function collectAllFolderNames(folders: CredentialFolder[]): string[] {
return folders.map((f) => f.name);
}
-59
View File
@@ -1,59 +0,0 @@
import { useEffect, useState } from "react";
import { getUserPreferences, saveUserPreferences } from "@/main-axios";
export const HIDDEN_RAIL_TABS_EVENT = "hiddenRailTabsChanged";
export function readHiddenRailTabs(): Set<string> {
try {
const raw = localStorage.getItem("hiddenRailTabs");
if (!raw) return new Set();
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set();
} catch {
return new Set();
}
}
/**
* The one writer for hidden rail items: the rail's restore list, the
* Navigation toggles in settings and presets all go through here, so they
* never disagree. Saved to the account when settings sync to the cloud.
*/
export async function writeHiddenRailTabs(
next: Iterable<string>,
): Promise<void> {
const serialized = JSON.stringify([...next]);
try {
localStorage.setItem("hiddenRailTabs", serialized);
} catch {
// A private window refuses the write; the change still applies this session.
}
window.dispatchEvent(new Event(HIDDEN_RAIL_TABS_EVENT));
try {
const prefs = await getUserPreferences();
if (prefs?.storageMode === "cloud") {
await saveUserPreferences({ hiddenRailTabs: serialized });
}
} catch {
// Offline; the local copy is already updated.
}
}
/** Hides one item if it is shown, shows it if hidden. */
export function toggleHiddenRailTab(id: string): Promise<void> {
const hidden = readHiddenRailTabs();
if (hidden.has(id)) hidden.delete(id);
else hidden.add(id);
return writeHiddenRailTabs(hidden);
}
/** The hidden rail items, kept current as anything changes them. */
export function useHiddenRailTabs(): Set<string> {
const [hidden, setHidden] = useState(readHiddenRailTabs);
useEffect(() => {
const handler = () => setHidden(readHiddenRailTabs());
window.addEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
return () => window.removeEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
}, []);
return hidden;
}
+29
View File
@@ -45,6 +45,12 @@ export interface HostBadgeDef {
order?: number;
}
/** The dot or stripe on a host row that shows its status. */
export type HostStatusIndicatorDef = HostBadgeDef;
/** A button in the host editor's toolbar. */
export type HostEditorToolbarItemDef = HostBadgeDef;
export interface HostContextMenuItemDef {
id: string;
pluginId?: string;
@@ -57,6 +63,9 @@ export interface HostContextMenuItemDef {
const actions = createRegistry<HostActionDef>(byOrderThenId);
const badges = createRegistry<HostBadgeDef>(byOrderThenId);
const statusIndicators = createRegistry<HostStatusIndicatorDef>(byOrderThenId);
const editorToolbarItems =
createRegistry<HostEditorToolbarItemDef>(byOrderThenId);
const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
export const registerHostAction = actions.register;
@@ -69,6 +78,10 @@ export function actionOrder(action: HostActionDef): number {
export const listHostActions = actions.list;
export const registerHostBadge = badges.register;
export const useHostBadges = badges.useList;
export const registerHostStatusIndicator = statusIndicators.register;
export const useHostStatusIndicators = statusIndicators.useList;
export const registerHostEditorToolbarItem = editorToolbarItems.register;
export const useHostEditorToolbarItems = editorToolbarItems.useList;
export const registerHostContextMenuItem = menuItems.register;
export const useHostContextMenuItems = menuItems.useList;
@@ -105,6 +118,20 @@ export function hostBadgesFor(all: HostBadgeDef[], host: Host): HostBadgeDef[] {
return all.filter((badge) => safeWhen(badge.when, host));
}
export function hostStatusIndicatorsFor(
all: HostStatusIndicatorDef[],
host: Host,
): HostStatusIndicatorDef[] {
return all.filter((item) => safeWhen(item.when, host));
}
export function hostEditorToolbarItemsFor(
all: HostEditorToolbarItemDef[],
host: Host,
): HostEditorToolbarItemDef[] {
return all.filter((item) => safeWhen(item.when, host));
}
export function hostMenuItemsFor(
all: HostContextMenuItemDef[],
host: Host,
@@ -125,5 +152,7 @@ export function runHostAction(
export function resetHostContributions(): void {
actions.reset();
badges.reset();
statusIndicators.reset();
editorToolbarItems.reset();
menuItems.reset();
}
@@ -1,79 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Host } from "@/types/ui-types";
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
import { HostEditor } from "../HostEditor";
import { useHostEditorSections } from "../HostManagerTabs";
import { listHostProtocols, type HostProtocols } from "../host-protocols";
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
/** The folder's path, shown as the title. */
folderName?: string;
}
/** The title a level of defaults goes by. */
export function hostDefaultsTitle(
target: HostDefaultsEditorTarget,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (target.level === "admin") return t("hostDefaults.titleAdmin");
if (target.level === "user") return t("hostDefaults.titleUser");
return t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
}
/**
* The host editor as a form for one level of host defaults: the same sections
* and fields as a new host, minus what only one host can have (its name,
* address, secrets, folder).
*/
export function HostDefaultsEditorView({
target,
onClose,
onDirtyChange,
hosts,
credentials,
}: {
target: HostDefaultsEditorTarget;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hosts: Host[];
credentials: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
useHostEditorSections();
// Every protocol counts as on, so every section that has defaults shows.
const protocols = useMemo<HostProtocols>(() => {
const all: HostProtocols = { enableSsh: true };
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
return all;
}, []);
const description =
target.level === "admin"
? t("hostDefaults.descriptionAdmin")
: target.level === "user"
? t("hostDefaults.descriptionUser")
: t("hostDefaults.descriptionFolder");
return (
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
banner={
<p className="border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{description}
</p>
}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
);
}
@@ -1,294 +0,0 @@
/**
* Between the host editor's form and host default keys: what value a key has
* on the form, and how to put a default's value back on it.
*
* Core keys use the API's shape ("core.auth" is an object, "core.socks5"
* too); plugin keys are the plugin's own field values.
*/
import {
CORE_NAMESPACE,
CORE_HOST_DEFAULTS,
defaultValuesEqual,
findCoreHostDefault,
splitDefaultKey,
type DefaultOverrides,
} from "@/types/host-defaults";
import type { HostEditorForm } from "../HostEditorData";
type Form = HostEditorForm;
type PluginValues = Record<string, Record<string, unknown>>;
export const CORE_DEFAULT_KEYS = CORE_HOST_DEFAULTS.map(
(entry) => `${CORE_NAMESPACE}.${entry.key}`,
);
function coreValue(form: Form, key: string): unknown {
switch (key) {
case "username":
return form.username;
case "sshPort":
return form.sshPort;
case "auth":
return {
authType: form.authType,
credentialId: form.credentialId ? Number(form.credentialId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
agentSocketPath: form.agentSocketPath || null,
agentIdentity: form.agentIdentity || null,
};
case "forceKeyboardInteractive":
case "keepaliveInterval":
case "keepaliveCountMax":
case "allowLegacyAlgorithms":
case "agentForwarding":
case "environmentVariables":
case "portKnockSequence":
case "statusCheckEnabled":
case "statusCheckInterval":
return form[key as keyof Form];
case "socks5":
return {
useSocks5: form.useSocks5,
socks5Host:
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
socks5Port:
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
socks5Username:
form.socks5ProxyMode === "single"
? form.socks5Username || null
: null,
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
};
case "jumpHosts":
return form.jumpHosts.map((jump) => ({ hostId: Number(jump.hostId) }));
default:
return undefined;
}
}
/** A key's value as the form holds it, normalized for core keys. */
export function formValueForKey(form: Form, fullKey: string): unknown {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace === CORE_NAMESPACE) {
const entry = findCoreHostDefault(key);
return entry ? entry.normalize(coreValue(form, key)) : undefined;
}
return (form.pluginSettings as PluginValues)?.[namespace]?.[key];
}
/** The form with one key set to a default's value. */
export function applyDefaultToForm(
form: Form,
fullKey: string,
value: unknown,
): Form {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace !== CORE_NAMESPACE) {
const all = (form.pluginSettings ?? {}) as PluginValues;
return {
...form,
pluginSettings: {
...all,
[namespace]: { ...(all[namespace] ?? {}), [key]: value },
},
};
}
const entry = findCoreHostDefault(key);
const normalized = entry ? entry.normalize(value) : value;
switch (key) {
case "username":
return { ...form, username: (normalized as string | null) ?? "" };
case "sshPort":
return { ...form, sshPort: normalized as number };
case "auth": {
const auth = normalized as {
authType: string;
credentialId: number | null;
overrideCredentialUsername: boolean;
agentSocketPath: string | null;
agentIdentity: string | null;
} | null;
if (!auth) return form;
return {
...form,
authType: auth.authType as Form["authType"],
credentialId:
auth.credentialId !== null ? String(auth.credentialId) : "",
overrideCredentialUsername: auth.overrideCredentialUsername,
agentSocketPath: auth.agentSocketPath ?? "",
agentIdentity: auth.agentIdentity ?? "",
};
}
case "socks5": {
const socks = normalized as {
useSocks5: boolean;
socks5Host: string | null;
socks5Port: number | null;
socks5Username: string | null;
socks5ProxyChain: Form["socks5ProxyChain"] | null;
};
const chain = socks.socks5ProxyChain ?? [];
return {
...form,
useSocks5: socks.useSocks5,
socks5Host: socks.socks5Host ?? "",
socks5Port: socks.socks5Port ?? 1080,
socks5Username: socks.socks5Username ?? "",
socks5ProxyMode: chain.length > 0 ? "chain" : "single",
socks5ProxyChain: chain.map((node, index) => ({
...node,
// The host's own proxy passwords stay with it.
...(form.socks5ProxyChain[index]?.password
? { password: form.socks5ProxyChain[index].password }
: {}),
})),
};
}
case "jumpHosts":
return {
...form,
jumpHosts: (normalized as Array<{ hostId: number }>).map((jump) => ({
hostId: String(jump.hostId),
})),
};
default:
return { ...form, [key]: normalized } as Form;
}
}
/** Keys whose value differs between two forms. */
export function changedKeys(
before: Form,
after: Form,
keys: Iterable<string>,
): string[] {
const changed: string[] = [];
for (const fullKey of keys) {
if (
!defaultValuesEqual(
formValueForKey(before, fullKey),
formValueForKey(after, fullKey),
)
) {
changed.push(fullKey);
}
}
return changed;
}
export function withOwnKeys(
overrides: DefaultOverrides,
keys: string[],
): DefaultOverrides {
if (keys.length === 0) return overrides;
const next: DefaultOverrides = { ...overrides };
for (const fullKey of keys) {
const [namespace, key] = splitDefaultKey(fullKey);
const own = new Set(next[namespace] ?? []);
own.add(key);
next[namespace] = [...own].sort();
}
return next;
}
export function withoutOwnKey(
overrides: DefaultOverrides,
fullKey: string,
): DefaultOverrides {
const [namespace, key] = splitDefaultKey(fullKey);
return {
...overrides,
[namespace]: (overrides[namespace] ?? []).filter((own) => own !== key),
};
}
export function isOwnDefault(
overrides: DefaultOverrides | null | undefined,
fullKey: string,
): boolean {
const [namespace, key] = splitDefaultKey(fullKey);
return !!overrides?.[namespace]?.includes(key);
}
/** Every key the overrides mark, as "namespace.key". */
export function ownDefaultKeys(
overrides: DefaultOverrides | null | undefined,
): string[] {
return Object.entries(overrides ?? {}).flatMap(([namespace, keys]) =>
keys.map((key) => `${namespace}.${key}`),
);
}
function isEmpty(value: unknown): boolean {
if (value === undefined || value === null || value === "") return true;
return Array.isArray(value) && value.length === 0;
}
/**
* Which keys a form's values make its own: those that differ from what
* resolves, and those with a value where nothing resolves.
*/
export function classifyForm(
form: Form,
resolved: Record<string, { value: unknown }>,
keys: Iterable<string>,
): DefaultOverrides {
const overrides: DefaultOverrides = { [CORE_NAMESPACE]: [] };
for (const fullKey of keys) {
const [namespace, key] = splitDefaultKey(fullKey);
overrides[namespace] ??= [];
const value = formValueForKey(form, fullKey);
const entry = resolved[fullKey];
const own = entry
? !defaultValuesEqual(value, entry.value)
: !isEmpty(value);
if (own) overrides[namespace].push(key);
}
return overrides;
}
/** Every namespace the editor knows is listed, so none is left to guess. */
export function withAllNamespaces(
overrides: DefaultOverrides,
keys: Map<string, { namespace: string }>,
): DefaultOverrides {
const next: DefaultOverrides = { [CORE_NAMESPACE]: [], ...overrides };
for (const entry of keys.values()) next[entry.namespace] ??= [];
return next;
}
/**
* The plugin values a save sends: a host's own values, and every value no
* default covers (a protocol switch). The server writes the rest.
*/
export function ownPluginValues(
values: PluginValues | undefined,
overrides: DefaultOverrides | null,
keys: Map<string, unknown>,
): PluginValues | undefined {
if (!values || overrides === null) return values;
const result: PluginValues = {};
for (const [pluginId, fields] of Object.entries(values)) {
const kept = Object.fromEntries(
Object.entries(fields ?? {}).filter(([key]) => {
const fullKey = `${pluginId}.${key}`;
return !keys.has(fullKey) || isOwnDefault(overrides, fullKey);
}),
);
if (Object.keys(kept).length > 0) result[pluginId] = kept;
}
return result;
}
/** A plugin's draft for a new host makes what it filled in the host's own. */
export function draftOwnKeys(
draft: { port?: unknown; username?: unknown; authType?: unknown } | undefined,
): DefaultOverrides {
const own: string[] = [];
if (draft?.port !== undefined) own.push("sshPort");
if (draft?.username !== undefined) own.push("username");
if (draft?.authType !== undefined) own.push("auth");
return { [CORE_NAMESPACE]: own };
}
@@ -1,34 +0,0 @@
import {
CORE_NAMESPACE,
splitDefaultKey,
type ResolvedHostDefault,
} from "@/types/host-defaults";
import type { Host } from "@/types/ui-types";
type PluginValues = Record<string, Record<string, unknown>>;
/** The plugin keys of a resolved set, in the host payload's shape. */
export function pluginSettingsFrom(
resolved: Record<string, ResolvedHostDefault>,
): PluginValues {
const result: PluginValues = {};
for (const [fullKey, entry] of Object.entries(resolved)) {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace === CORE_NAMESPACE) continue;
result[namespace] = { ...(result[namespace] ?? {}), [key]: entry.value };
}
return result;
}
/** A quick connect host with the defaults under whatever it set itself. */
export function withDefaultPluginSettings(
host: Host,
defaults: PluginValues,
): Host {
const own = host.pluginSettings ?? {};
const merged: PluginValues = { ...defaults };
for (const [pluginId, values] of Object.entries(own)) {
merged[pluginId] = { ...(defaults[pluginId] ?? {}), ...values };
}
return { ...host, pluginSettings: merged };
}
-225
View File
@@ -1,225 +0,0 @@
export type FieldGroup =
| "connection"
| "notes"
| "tags"
| "proxy"
| "jumpHosts"
| "featureFlags"
| "advanced";
export interface ExportPayload {
version?: string;
exportedAt?: string;
credentials?: Record<string, unknown>[];
hosts: Record<string, unknown>[];
}
const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
connection: [
"connectionType",
"name",
"ip",
"port",
"username",
"folder",
"domain",
"security",
"ignoreCert",
],
notes: ["notes"],
tags: ["tags", "pin"],
proxy: [
"useSocks5",
"socks5Host",
"socks5Port",
"socks5Username",
"socks5ProxyChain",
],
jumpHosts: ["jumpHosts"],
// Every plugin's host settings, enable switches included.
featureFlags: ["pluginSettings", "forceKeyboardInteractive"],
advanced: [
"statusCheckEnabled",
"statusCheckInterval",
"terminalConfig",
"sshOptions",
],
};
export const SECRET_KEYS = [
"password",
"key",
"keyPassword",
"sudoPassword",
"socks5Password",
];
const CREDENTIAL_KEYS = [
...SECRET_KEYS,
"authType",
"keyType",
"credentialAlias",
"credentialId",
"overrideCredentialUsername",
];
const TUPLE_KEYS = ["name", "ip", "port", "username", "connectionType"];
/**
* Keys inside a plugin's JSON host settings that hold secrets, per plugin and
* field, from each manifest's `secretKeys`.
*/
export type PluginSecretKeys = Record<string, Record<string, string[]>>;
/** Reads the secret keys the plugins declare in their host settings. */
export function pluginSecretKeys(
summaries: Array<{
id: string;
contributes?: {
settings?: {
host?: { fields?: Array<{ key: string; secretKeys?: string[] }> };
};
} | null;
}>,
): PluginSecretKeys {
const result: PluginSecretKeys = {};
for (const summary of summaries) {
for (const field of summary.contributes?.settings?.host?.fields ?? []) {
if (!field.secretKeys?.length) continue;
(result[summary.id] ??= {})[field.key] = field.secretKeys;
}
}
return result;
}
const NESTED_SECRET_ARRAYS: { container: string; field: string }[] = [
{ container: "socks5ProxyChain", field: "password" },
];
/** Rewrites each declared secret inside a plugin's JSON host settings. */
function mapPluginSecrets(
pluginSettings: unknown,
secrets: PluginSecretKeys,
replace: (value: unknown) => unknown,
): unknown {
if (!pluginSettings || typeof pluginSettings !== "object") {
return pluginSettings;
}
const perPlugin: Record<string, unknown> = {};
for (const [pluginId, values] of Object.entries(
pluginSettings as Record<string, unknown>,
)) {
const copy = { ...(values as Record<string, unknown>) };
for (const [field, keys] of Object.entries(secrets[pluginId] ?? {})) {
const blob = copy[field];
if (!blob || typeof blob !== "object") continue;
const record = { ...(blob as Record<string, unknown>) };
for (const key of keys) {
if (key in record) record[key] = replace(record[key]);
}
copy[field] = record;
}
perPlugin[pluginId] = copy;
}
return perPlugin;
}
export function hostKey(host: Record<string, unknown>): string {
return JSON.stringify(TUPLE_KEYS.map((k) => String(host[k] ?? "")));
}
export function buildExportPayload(
raw: ExportPayload,
selected: Set<string> | null,
groups: Set<FieldGroup>,
withCredentials: boolean,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
const allowed = new Set<string>([
"exportId",
...CREDENTIAL_KEYS,
...FIELD_GROUP_KEYS.connection,
]);
for (const group of groups) {
for (const key of FIELD_GROUP_KEYS[group]) allowed.add(key);
}
const hosts = (raw.hosts ?? [])
.filter((host) => selected === null || selected.has(hostKey(host)))
.map((host) => {
const shaped: Record<string, unknown> = {};
for (const [key, value] of Object.entries(host)) {
if (allowed.has(key)) shaped[key] = value;
}
if (!withCredentials) {
shaped.pluginSettings = mapPluginSecrets(
shaped.pluginSettings,
pluginSecrets,
() => null,
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = shaped[container];
if (Array.isArray(arr)) {
shaped[container] = arr.map((entry) =>
entry && typeof entry === "object"
? { ...(entry as Record<string, unknown>), [field]: null }
: entry,
);
}
}
}
return shaped;
});
const result: ExportPayload = { ...raw, hosts };
if (raw.credentials) {
const used = new Set(
hosts.map((host) => host.credentialAlias).filter(Boolean),
);
result.credentials = raw.credentials.filter((entry) =>
used.has(entry.alias),
);
}
return result;
}
export function maskSecrets(
payload: ExportPayload,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
return {
...payload,
hosts: payload.hosts.map((host) => {
const masked = { ...host };
for (const key of SECRET_KEYS) {
if (
masked[key] !== undefined &&
masked[key] !== null &&
masked[key] !== ""
) {
masked[key] = "<included>";
}
}
masked.pluginSettings = mapPluginSecrets(
masked.pluginSettings,
pluginSecrets,
(value) => (value ? "<included>" : value),
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = masked[container];
if (Array.isArray(arr)) {
masked[container] = arr.map((entry) => {
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
if (record[field]) return { ...record, [field]: "<included>" };
}
return entry;
});
}
}
return masked;
}),
};
}
-66
View File
@@ -1,66 +0,0 @@
import type { Host, SharePermissionLevel } from "@/types/ui-types";
import {
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import {
listHostProtocols,
protocolEnabled,
type HostProtocolDef,
} from "./host-protocols";
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
connect: 1,
view: 2,
edit: 3,
manage: 4,
};
function sharedLevelRank(host: Host): number {
return LEVEL_RANK[host.permissionLevel ?? "connect"] ?? 1;
}
export function canViewHostConfig(host: Host): boolean {
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.view;
}
export function canEditHost(host: Host): boolean {
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.edit;
}
export function canShareHost(host: Host): boolean {
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.manage;
}
export function canDeleteHost(host: Host): boolean {
return !host.isShared;
}
/** SSH, then every protocol a running plugin registered. */
export function authOverrideProtocols(
list: HostProtocolDef[] = listHostProtocols(),
): AuthOverrideProtocol[] {
return [SSH_AUTH_PROTOCOL, ...list.map((protocol) => protocol.id)];
}
/** How a protocol is named in the override menu and dialog. */
export function authProtocolLabel(
protocol: AuthOverrideProtocol,
t: (key: string) => string,
list: HostProtocolDef[] = listHostProtocols(),
): string {
if (protocol === SSH_AUTH_PROTOCOL) return "SSH";
const def = list.find((entry) => entry.id === protocol);
return def ? t(def.titleKey) : protocol;
}
export function canOverrideHostAuth(
host: Host,
protocol: AuthOverrideProtocol,
): boolean {
if (!host.isShared) return false;
if (protocol === SSH_AUTH_PROTOCOL) return !!host.enableSsh;
// Other protocols are switched on in their plugin's host settings.
const plugin = listHostProtocols().find((entry) => entry.id === protocol);
return !!plugin && protocolEnabled(host.pluginSettings, plugin);
}
-102
View File
@@ -1,102 +0,0 @@
import type { Host, HostFolder } from "@/types/ui-types";
import type { SortKey } from "@/types/host-sidebar-preferences";
export type { SortKey };
const SORT_KEYS = new Set<SortKey>([
"default",
"name-asc",
"name-desc",
"ip-asc",
"ip-desc",
"status-online",
"status-offline",
"manual",
]);
export function resolveHostSortPreferences(
savedSortKey: string | null,
savedPinnedFirst: string | null,
): { sortKey: SortKey; pinnedFirst: boolean } {
const legacyPinned = savedSortKey === "pinned";
return {
sortKey: SORT_KEYS.has(savedSortKey as SortKey)
? (savedSortKey as SortKey)
: "default",
pinnedFirst:
savedPinnedFirst === null ? legacyPinned : savedPinnedFirst === "true",
};
}
function isFolder(item: Host | HostFolder): item is HostFolder {
return "children" in item;
}
export function sortHostTree(
folder: HostFolder,
key: SortKey,
pinnedFirst = false,
): HostFolder {
if (key === "default" && !pinnedFirst) return folder;
const comparator = (a: Host | HostFolder, b: Host | HostFolder): number => {
const aIsFolder = isFolder(a);
const bIsFolder = isFolder(b);
if (aIsFolder && !bIsFolder) return -1;
if (!aIsFolder && bIsFolder) return 1;
if (aIsFolder && bIsFolder) {
if (key === "manual") {
const aOrder = a.sortOrder;
const bOrder = b.sortOrder;
if (aOrder == null && bOrder == null)
return a.name.localeCompare(b.name);
if (aOrder == null) return 1;
if (bOrder == null) return -1;
return aOrder - bOrder;
}
return a.name.localeCompare(b.name);
}
// The three branches above return for every combination involving a
// folder, so both sides are hosts from here on.
const hostA = a as Host;
const hostB = b as Host;
if (pinnedFirst && !!hostA.pin !== !!hostB.pin) return hostB.pin ? 1 : -1;
switch (key) {
case "name-asc":
return hostA.name.localeCompare(hostB.name);
case "name-desc":
return hostB.name.localeCompare(hostA.name);
case "ip-asc":
return hostA.ip.localeCompare(hostB.ip, undefined, { numeric: true });
case "ip-desc":
return hostB.ip.localeCompare(hostA.ip, undefined, { numeric: true });
case "status-online":
return Number(hostB.online) - Number(hostA.online);
case "status-offline":
return Number(hostA.online) - Number(hostB.online);
case "manual": {
const aOrder = hostA.sortOrder;
const bOrder = hostB.sortOrder;
if (aOrder == null && bOrder == null)
return hostA.name.localeCompare(hostB.name);
if (aOrder == null) return 1;
if (bOrder == null) return -1;
return aOrder - bOrder;
}
case "default":
return 0;
}
};
return {
...folder,
children: [...folder.children]
.sort(comparator)
.map((child) =>
isFolder(child) ? sortHostTree(child, key, pinnedFirst) : child,
),
};
}
-36
View File
@@ -1,36 +0,0 @@
import type { Host, HostFolder } from "@/types/ui-types";
import type { StatusValue } from "@/lib/server-status-store";
import { isFolder } from "./tree/visible-rows";
/**
* Copies each host's live status onto the tree so sorting, filtering and
* grouping by status read the same value as the row's dot. A host with
* status checks off gets no status.
*/
export function withLiveStatus(
folder: HostFolder,
getStatus: (hostId: number) => StatusValue,
): HostFolder {
return {
...folder,
children: folder.children.map((child) => {
if (isFolder(child)) return withLiveStatus(child, getStatus);
const host = child as Host;
const status =
host.statusCheckEnabled === false
? undefined
: getStatus(Number(host.id));
if (host.status === status) return host;
return { ...host, status, online: status === "online" };
}),
};
}
/** The flat-list version of withLiveStatus. */
export function withLiveHostStatus(
hosts: Host[],
getStatus: (hostId: number) => StatusValue,
): Host[] {
return withLiveStatus({ name: "", children: hosts }, getStatus)
.children as Host[];
}
@@ -1,23 +0,0 @@
export interface CredentialOption {
id: string;
name: string;
username: string;
}
export function toCredentialOption(
credential: Record<string, unknown>,
): CredentialOption | null {
if (
(typeof credential.id !== "number" && typeof credential.id !== "string") ||
typeof credential.name !== "string"
) {
return null;
}
return {
id: String(credential.id),
name: credential.name,
username:
typeof credential.username === "string" ? credential.username : "",
};
}
-11
View File
@@ -1,5 +1,4 @@
import {
Cloud,
KeyRound,
Plug,
Server,
@@ -12,7 +11,6 @@ import {
import { useMemo, useSyncExternalStore } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
import { getPanel } from "@/shell/panel-registry";
@@ -111,15 +109,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
mobilePrimary: true,
group: "tools",
},
{
id: "sync",
icon: Cloud,
labelKey: "nav.sync",
electronOnly: true,
placement: "footer",
group: "system",
useBadge: useSyncAttentionCount,
},
{
id: "plugins",
icon: Puzzle,
-48
View File
@@ -1,48 +0,0 @@
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
export type RailPreference =
| "pinAppRail"
| "expandAppRailOnHover"
| "showPinAppRailButton"
| "appRailHoverPushesContent";
const CHANGE_EVENT: Record<RailPreference, string> = {
pinAppRail: "pinAppRailChanged",
expandAppRailOnHover: "expandAppRailOnHoverChanged",
showPinAppRailButton: "showPinAppRailButtonChanged",
appRailHoverPushesContent: "appRailHoverPushesContentChanged",
};
/** Kept on this device only; the server has no column for it. */
const LOCAL_ONLY: ReadonlySet<RailPreference> = new Set([
"appRailHoverPushesContent",
]);
// pinAppRail and showPinAppRailButton default off, expandAppRailOnHover
// defaults on, so each key needs its own read rather than a shared
// === "true" check.
export function readRailPreference(key: RailPreference): boolean {
const stored = localStorage.getItem(key);
return key === "expandAppRailOnHover"
? stored !== "false"
: stored === "true";
}
/**
* Single write path for the app rail display preferences. Persists to
* localStorage, notifies every mounted listener so the rail and the settings
* panel stay in step, then mirrors to the server when the user is on cloud
* storage.
*/
export function setRailPreference(key: RailPreference, value: boolean): void {
localStorage.setItem(key, String(value));
window.dispatchEvent(new Event(CHANGE_EVENT[key]));
if (LOCAL_ONLY.has(key)) return;
void getUserPreferences()
.then((preferences) => {
if (preferences.storageMode === "cloud") {
return saveUserPreferences({ [key]: value });
}
})
.catch(() => {});
}
-140
View File
@@ -1,140 +0,0 @@
/**
* Manual drag-to-reorder position math, shared by host and folder reordering.
* Sparse integers (gaps of 1000) mean most drops only rewrite the moved
* item's own sortOrder -- inserting between two neighbors is just the
* midpoint between their values. Renumbering the whole sibling group only
* happens when a gap runs out (two adjacent siblings differ by 1).
*/
const GAP = 1000;
export interface Orderable {
sortOrder?: number | null;
}
/**
* Computes the sortOrder for an item dropped between `before` and `after`
* (either may be absent for a drop at the start/end of the list). Returns
* null when the gap between neighbors has been exhausted and the caller
* should renumber the whole sibling group instead via renumberSiblings.
*/
export function computeDropSortOrder(
before: Orderable | null,
after: Orderable | null,
): number | null {
const beforeOrder = before?.sortOrder ?? null;
const afterOrder = after?.sortOrder ?? null;
if (beforeOrder == null && afterOrder == null) return GAP;
if (beforeOrder == null) return (afterOrder as number) - GAP;
if (afterOrder == null) return beforeOrder + GAP;
const mid = Math.floor((beforeOrder + afterOrder) / 2);
if (mid <= beforeOrder || mid >= afterOrder) return null;
return mid;
}
/**
* Assigns fresh, evenly-spaced sortOrder values (0, GAP, 2*GAP, ...) to an
* ordered list of sibling ids, for when computeDropSortOrder signals the
* available gap is exhausted.
*/
export function renumberSiblings<T extends { id: string | number }>(
orderedIds: T[],
): { id: string | number; sortOrder: number }[] {
return orderedIds.map((item, index) => ({
id: item.id,
sortOrder: index * GAP,
}));
}
export type ReorderKind = "host" | "folder" | "cred";
export interface ReorderRow {
/** Stable key, e.g. "host:12" or "folder:Prod / Web". */
key: string;
/** Identifies the row's parent so siblings can be scoped to one group. */
parentKey: string;
sortOrder?: number | null;
}
export interface ReorderPlan {
/** Rows to write, already carrying their new sortOrder. */
positions: { key: string; sortOrder: number }[];
/** New parent for the dragged row, when the drop crossed groups. */
movedTo: string | null;
}
/**
* Resolves a drop into the exact writes it implies.
*
* Siblings are scoped to the drop target's parent, not to every row of the
* same type -- dropping onto a row in another folder has to compare against
* that folder's own neighbors, and reports the parent change so the caller
* can persist the move alongside the new position.
*
* Returns null when the drop is a no-op (unknown rows, or dropping an item
* back onto the slot it already occupies).
*/
export function planReorder(
rows: ReorderRow[],
draggedKey: string,
targetKey: string,
position: "before" | "after",
): ReorderPlan | null {
if (draggedKey === targetKey) return null;
const dragged = rows.find((r) => r.key === draggedKey);
const target = rows.find((r) => r.key === targetKey);
if (!dragged || !target) return null;
const siblings = rows.filter((r) => r.parentKey === target.parentKey);
const targetIndex = siblings.findIndex((r) => r.key === targetKey);
if (targetIndex === -1) return null;
const before =
position === "before" ? (siblings[targetIndex - 1] ?? null) : target;
const after =
position === "before" ? target : (siblings[targetIndex + 1] ?? null);
// Already sitting exactly where the drop would put it.
if (before?.key === draggedKey || after?.key === draggedKey) return null;
const movedTo =
dragged.parentKey === target.parentKey ? null : target.parentKey;
// Hosts start life with sortOrder null, and null sorts last in manual
// mode. Writing a single value for the moved row against null neighbours
// therefore says nothing about where it landed -- the group has to be
// numbered as a whole before any position is meaningful. A cross-folder
// drop renumbers for the same reason: the dragged row arrives carrying a
// sortOrder from its old folder, which means nothing in this one.
const needsFullRenumber =
movedTo !== null ||
siblings.some((r) => r.key !== draggedKey && r.sortOrder == null);
const sortOrder = needsFullRenumber
? null
: computeDropSortOrder(
before && before.key !== draggedKey ? before : null,
after && after.key !== draggedKey ? after : null,
);
if (sortOrder !== null) {
return { positions: [{ key: draggedKey, sortOrder }], movedTo };
}
// Gap exhausted: renumber the whole destination group with the dragged row
// spliced into its new slot.
const ordered = siblings.filter((r) => r.key !== draggedKey);
const targetPos = ordered.findIndex((r) => r.key === targetKey);
const insertAt = position === "before" ? targetPos : targetPos + 1;
ordered.splice(insertAt, 0, dragged);
return {
positions: renumberSiblings(ordered.map((r) => ({ id: r.key }))).map(
(r) => ({ key: String(r.id), sortOrder: r.sortOrder }),
),
movedTo,
};
}
@@ -1,89 +0,0 @@
import { useTranslation } from "react-i18next";
import { FolderOpen, Pencil, Settings2, Share2, Trash2 } from "lucide-react";
import type { HostFolder } from "@/types/ui-types";
export function FolderActions({
folder,
onOpenAllSessions,
onShareFolder,
onManageFolder,
onDeleteFolder,
}: {
folder: HostFolder;
onOpenAllSessions: (folder: HostFolder) => void;
onShareFolder?: (folder: HostFolder) => void;
onManageFolder: (folder: HostFolder) => void;
onDeleteFolder: (folder: HostFolder) => void;
}) {
const { t } = useTranslation();
const actionButtonClass =
"flex items-center justify-center size-6 text-muted-foreground/60 hover:text-foreground hover:bg-background/80 transition-colors";
return (
<span className="hidden items-center gap-0.5 ml-0.5 group-hover/folder:flex group-focus-within/folder:flex [@media(hover:none)]:flex">
<button
type="button"
title={t("hosts.openAllSessions")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onOpenAllSessions(folder);
}}
>
<FolderOpen className="size-3" />
</button>
{onShareFolder && (
<button
type="button"
title={t("hosts.shareFolder")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<Share2 className="size-3" />
</button>
)}
<button
type="button"
title={t("hostDefaults.folderDefaultsAction")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(
new CustomEvent("host-manager:edit-defaults", {
detail: {
level: "folder",
folderName: folder.path ?? folder.name,
},
}),
);
}}
>
<Settings2 className="size-3" />
</button>
<button
type="button"
title={t("hosts.editFolder")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onManageFolder(folder);
}}
>
<Pencil className="size-3" />
</button>
<button
type="button"
title={t("hosts.deleteFolder")}
className={`${actionButtonClass} hover:text-destructive`}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
>
<Trash2 className="size-3" />
</button>
</span>
);
}
@@ -1,396 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import { rem } from "@/lib/rem";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Check, GripVertical } from "lucide-react";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type {
HostDensity,
HostClickBehavior,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { FolderIconEl } from "@/components/folder-style";
import { useServerStatus } from "@/lib/ServerStatusContext";
import { HostItem, statusCheckEnabled } from "../HostItem/HostItem";
import { isFolder, folderHasMatch, collectAllHosts } from "../visible-rows";
import { FolderActions } from "./FolderActions";
export function folderHostCount(folder: HostFolder): number {
let total = 0;
for (const child of folder.children) {
total += isFolder(child) ? folderHostCount(child) : 1;
}
return total;
}
export function FolderItem({
folder,
depth = 0,
onOpenTab,
onEditHost,
onShareHost,
onDeleteHost,
onDuplicateHost,
query = "",
stripeMap,
openFolders,
onToggleFolder,
selectionMode,
selectedHostIds,
onToggleSelect,
onToggleSelectFolder,
openMenuHostId,
onMenuOpenChange,
openTrayHostId,
onTrayOpenChange,
onManageFolder,
onDeleteFolder,
onOpenAllSessions,
onShareFolder,
onMoveHostsToFolder,
draggedHostIds,
onDragHostStart,
onDragEnd,
/** When true, only render the folder header (children come from the virtual list). */
flat = false,
stripeIndex: stripeIndexProp,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
openOnDoubleClick = false,
showFolderPaths = true,
hostClickBehavior = "newTab",
arrangeMode = false,
isDragging = false,
onReorderDrop,
onFolderDragStart,
onFolderDragEnd,
isReorderHovered = false,
reorderHoverEdge = null,
onReorderHoverChange,
}: {
folder: HostFolder;
depth?: number;
onOpenTab: (
host: Host,
type: TabType,
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) => void;
onEditHost?: (host: Host) => void;
onShareHost?: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
query?: string;
stripeMap?: Map<Host | HostFolder, number>;
openFolders: Set<string>;
onToggleFolder: (name: string) => void;
selectionMode: boolean;
selectedHostIds: Set<string>;
onToggleSelect: (id: string) => void;
onToggleSelectFolder: (folder: HostFolder) => void;
openMenuHostId: string | null;
onMenuOpenChange: (hostId: string | null) => void;
openTrayHostId: string | null;
onTrayOpenChange: (hostId: string | null) => void;
onManageFolder: (folder: HostFolder) => void;
onDeleteFolder: (folder: HostFolder) => void;
onOpenAllSessions: (folder: HostFolder) => void;
onShareFolder?: (folder: HostFolder) => void;
onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void;
draggedHostIds: string[] | null;
onDragHostStart: (hostId: string) => void;
onDragEnd: () => void;
flat?: boolean;
stripeIndex?: number;
density?: HostDensity;
trayTrigger?: HostTrayTrigger;
showTags?: boolean;
openOnDoubleClick?: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean;
hostClickBehavior?: HostClickBehavior;
/** When true (rearranging unlocked), the header can be dragged and its
* top/bottom edges become reorder drop zones. The middle still accepts
* hosts dropped into the folder. */
arrangeMode?: boolean;
/** True while this folder is the one being dragged. */
isDragging?: boolean;
onReorderDrop?: (targetKey: string, position: "before" | "after") => void;
onFolderDragStart?: (folderPath: string) => void;
onFolderDragEnd?: () => void;
/** Whether THIS folder header is the current reorder drop target -- see
* HostItem's identical prop for why this is lifted rather than local. */
isReorderHovered?: boolean;
reorderHoverEdge?: "before" | "after" | null;
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
}) {
const { t } = useTranslation();
const { getStatus } = useServerStatus();
const total = folderHostCount(folder);
const online = collectAllHosts(folder.children).filter(
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
).length;
const [dragOver, setDragOver] = useState(false);
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
if (query && !folderHasMatch(folder, query)) return null;
const folderPath = folder.path ?? folder.name;
const isOpen = query ? true : openFolders.has(folderPath);
const stripeIndex = stripeIndexProp ?? stripeMap?.get(folder) ?? 0;
// Synthetic group headers (group-by tag/status/etc.) are not real folders, so
// they can't be edited, deleted, or used as drop targets.
const isGroup = folderPath.startsWith("__group__:");
// Nested folders show their parent path as a muted breadcrumb so depth stays
// legible even when a folder is reached via search auto-expand rather than
// by manually opening every ancestor.
const pathSegments =
isGroup || !showFolderPaths ? [] : folderPath.split(" / ");
const breadcrumb =
pathSegments.length > 1 ? pathSegments.slice(0, -1).join(" / ") : null;
const folderHosts = collectAllHosts(folder.children);
const folderSelected =
folderHosts.length > 0 &&
folderHosts.every((h) => selectedHostIds.has(h.id));
const canDragFolder = arrangeMode && !isGroup;
// Hosts land IN the folder; the header's own top/bottom edges reorder it
// among its siblings.
const acceptsHostDrop = !!draggedHostIds && !isGroup;
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
>
<div className="relative">
{/* A div, not a button: the folder actions inside are buttons. */}
<div
role="button"
tabIndex={0}
aria-expanded={isOpen}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!query) onToggleFolder(folderPath);
}
}}
draggable={canDragFolder}
onDragStart={(e) => {
if (!canDragFolder) return;
e.dataTransfer.effectAllowed = "move";
onFolderDragStart?.(folderPath);
}}
onDragEnd={() => {
setDragOver(false);
onReorderHoverChange?.(null);
onFolderDragEnd?.();
}}
// The drop zones live on the header, not on the wrapper around the
// folder's whole subtree -- measuring the edges against the full
// expanded subtree made the before/after split land hundreds of
// pixels away from the header the user was actually pointing at.
onDragOver={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const offset = e.clientY - rect.top;
const band = Math.min(8, rect.height * 0.3);
const wantsInto =
acceptsHostDrop && offset > band && offset < rect.height - band;
if (wantsInto) {
e.preventDefault();
e.stopPropagation();
onReorderHoverChange?.(null);
setDragOver(true);
return;
}
if (arrangeMode && onReorderDrop && !isGroup) {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
onReorderHoverChange?.(
offset < rect.height / 2 ? "before" : "after",
);
}
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (dragOver && acceptsHostDrop && draggedHostIds) {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
onMoveHostsToFolder(draggedHostIds, folderPath);
return;
}
if (arrangeMode && onReorderDrop && !isGroup && reorderEdge) {
e.preventDefault();
e.stopPropagation();
onReorderDrop(`folder:${folderPath}`, reorderEdge);
onReorderHoverChange?.(null);
}
}}
onClick={() => !query && onToggleFolder(folderPath)}
className={`group/folder flex items-center gap-2 w-full pl-2.5 pr-2 py-1.5 transition-colors text-left ${
canDragFolder
? "cursor-grab active:cursor-grabbing"
: "cursor-pointer"
} ${
isOpen ? "bg-muted/40" : "hover:bg-muted/30"
} ${stripeIndex % 2 === 1 && !isOpen ? "bg-muted/[0.08]" : ""} ${dragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""} ${isDragging ? "opacity-40" : ""}`}
>
{canDragFolder && (
<GripVertical className="size-3 shrink-0 text-muted-foreground/35 group-hover/folder:text-muted-foreground/70 transition-colors" />
)}
<ChevronRight
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
{selectionMode && !isGroup && folderHosts.length > 0 && (
<div
role="checkbox"
aria-checked={folderSelected}
title={
folderSelected
? t("hosts.deselectFolder")
: t("hosts.selectFolder")
}
onClick={(e) => {
e.stopPropagation();
onToggleSelectFolder(folder);
}}
className={`size-3.5 border-2 flex items-center justify-center shrink-0 transition-colors ${folderSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{folderSelected && <Check className="size-2 text-background" />}
</div>
)}
<FolderIconEl
icon={folder.icon ?? "folder"}
className={`size-4 shrink-0 ${folder.color ? "" : isOpen ? "text-accent-brand" : "text-muted-foreground/70"}`}
style={folder.color ? { color: folder.color } : undefined}
/>
{
<>
<span className="min-w-0 flex-1 truncate">
{breadcrumb && (
<span className="text-[10px] text-muted-foreground/40 truncate mr-1">
{breadcrumb} /
</span>
)}
<span className="text-[13px] font-bold text-foreground tracking-tight">
{folder.name}
</span>
</span>
<span className="flex items-center gap-1 text-[10px] tabular-nums shrink-0 ml-1 px-1.5 py-[1px] bg-muted/70">
{online > 0 && (
<span className="text-accent-brand font-semibold">
{online}
</span>
)}
<span className="text-muted-foreground/50">/{total}</span>
</span>
{!isGroup && (
<FolderActions
folder={folder}
onOpenAllSessions={onOpenAllSessions}
onShareFolder={onShareFolder}
onManageFolder={onManageFolder}
onDeleteFolder={onDeleteFolder}
/>
)}
</>
}
</div>
</div>
{!flat && isOpen && (
<div className="border-l border-border/50 ml-[27px]">
{folder.children.map((child, i) =>
isFolder(child) ? (
<FolderItem
key={i}
folder={child}
depth={depth + 1}
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={onShareHost}
onDeleteHost={onDeleteHost}
onDuplicateHost={onDuplicateHost}
query={query}
stripeMap={stripeMap}
openFolders={openFolders}
onToggleFolder={onToggleFolder}
selectionMode={selectionMode}
selectedHostIds={selectedHostIds}
onToggleSelect={onToggleSelect}
onToggleSelectFolder={onToggleSelectFolder}
openMenuHostId={openMenuHostId}
onMenuOpenChange={onMenuOpenChange}
openTrayHostId={openTrayHostId}
onTrayOpenChange={onTrayOpenChange}
onManageFolder={onManageFolder}
onDeleteFolder={onDeleteFolder}
onOpenAllSessions={onOpenAllSessions}
onShareFolder={onShareFolder}
onMoveHostsToFolder={onMoveHostsToFolder}
draggedHostIds={draggedHostIds}
onDragHostStart={onDragHostStart}
onDragEnd={onDragEnd}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths}
hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
onReorderDrop={onReorderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
/>
) : (
<HostItem
key={i}
host={child}
onOpenTab={(t, options) => onOpenTab(child, t, options)}
onEditHost={onEditHost ? () => onEditHost(child) : undefined}
onShareHost={onShareHost ? () => onShareHost(child) : undefined}
onDelete={() => onDeleteHost(child)}
onDuplicate={() => onDuplicateHost(child)}
query={query}
stripeIndex={stripeMap?.get(child) ?? 0}
selectionMode={selectionMode}
selected={selectedHostIds.has(child.id)}
onToggleSelect={() => onToggleSelect(child.id)}
isMenuOpen={openMenuHostId === child.id}
onMenuOpenChange={(open) =>
onMenuOpenChange(open ? child.id : null)
}
isTrayOpen={openTrayHostId === child.id}
onTrayOpenChange={(open) =>
onTrayOpenChange(open ? child.id : null)
}
onDragStart={() => onDragHostStart(child.id)}
onDragEnd={onDragEnd}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
onReorderDrop={
onReorderDrop
? (position) => onReorderDrop(`host:${child.id}`, position)
: undefined
}
/>
),
)}
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,125 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getHostSidebarPreferences,
saveHostSidebarPreferences,
getUserPreferences,
} from "@/main-axios";
import {
defaultHostSidebarPreferences,
sanitizeHostSidebarPreferences,
type HostSidebarPreferences,
} from "@/types/host-sidebar-preferences";
const SAVE_DEBOUNCE_MS = 500;
const LS_KEY = "hostSidebarPreferences";
const SYNC_EVENT = "hostSidebarPreferencesChanged";
function readCache(): HostSidebarPreferences | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
return sanitizeHostSidebarPreferences(JSON.parse(raw));
} catch {
return null;
}
}
function writeCache(preferences: HostSidebarPreferences) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
} catch {
/* ignore */
}
}
/**
* Unified host sidebar preferences (sort, group, filters, open folders,
* density, tag visibility, tray trigger, status color scheme), cached in
* localStorage for instant paint and synced to the backend so they follow
* the user across devices when storageMode is "cloud" -- same
* cache-then-fetch-then-debounced-save shape as useHostMetricsPreferences,
* but per-user rather than per-host, and gated on storageMode the way the
* older per-preference localStorage toggles were.
*
* Multiple components (HostsPanel, CustomizeSidebarPanel, UserProfilePanel's
* resetToDefaults) can call this hook at the same time. Each holds its own
* React state, so a write in one instance broadcasts a same-tab custom event
* (mirroring the showHostTagsChanged-style events the older per-preference
* toggles used) that every other instance listens for to stay in sync.
*/
export function useHostSidebarPreferences() {
const [preferences, setPreferences] = useState<HostSidebarPreferences>(
() => readCache() ?? defaultHostSidebarPreferences(),
);
const [loaded, setLoaded] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
getHostSidebarPreferences()
.then((remote) => {
if (cancelled) return;
setPreferences(remote);
writeCache(remote);
})
.catch(() => {
/* keep cache/default */
})
.finally(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handler = () => {
const cached = readCache();
if (cached) setPreferences(cached);
};
window.addEventListener(SYNC_EVENT, handler);
return () => window.removeEventListener(SYNC_EVENT, handler);
}, []);
const update = useCallback(
(
patch:
| Partial<HostSidebarPreferences>
| ((prev: HostSidebarPreferences) => HostSidebarPreferences),
) => {
setPreferences((prev) => {
const next = sanitizeHostSidebarPreferences(
typeof patch === "function" ? patch(prev) : { ...prev, ...patch },
);
writeCache(next);
window.dispatchEvent(new Event(SYNC_EVENT));
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
getUserPreferences()
.then((prefs) => {
if (prefs.storageMode !== "cloud") return;
return saveHostSidebarPreferences(next);
})
.catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
return next;
});
},
[],
);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
return { preferences, update, loaded };
}
@@ -1,63 +0,0 @@
import { useRef, useState, type KeyboardEvent } from "react";
/** Keyboard search belongs to the focused host list, never to an editor or terminal. */
export function useHostSpeedSearch() {
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [index, setIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const close = () => {
setOpen(false);
setText("");
setIndex(0);
};
const change = (value: string) => {
setText(value);
setIndex(0);
};
const onKeyDown = (
event: KeyboardEvent,
count: number,
activate: (index: number) => void,
restoreFocus: () => void,
) => {
const target = event.target as HTMLElement;
const inSearch = target === inputRef.current;
if (
(!inSearch &&
target.closest("input, textarea, select, [contenteditable=true]")) ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.nativeEvent.isComposing ||
event.keyCode === 229
)
return;
const selected = Math.min(index, Math.max(0, count - 1));
if (open && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
restoreFocus();
} else if (open && ["ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
if (count)
setIndex(
(selected + (event.key === "ArrowDown" ? 1 : -1) + count) % count,
);
} else if (open && event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (count) activate(selected);
} else if (!inSearch && event.key.length === 1 && event.key !== " ") {
event.preventDefault();
event.stopPropagation();
setOpen(true);
change(open ? text + event.key : event.key === "/" ? "" : event.key);
}
};
return { open, text, index, inputRef, change, close, onKeyDown };
}
@@ -1,14 +0,0 @@
import { useState } from "react";
/** Drag-in-progress host ids and root drop-zone hover state for the tree. */
export function useSidebarDragState() {
const [draggedHostIds, setDraggedHostIds] = useState<string[] | null>(null);
const [rootDragOver, setRootDragOver] = useState(false);
return {
draggedHostIds,
setDraggedHostIds,
rootDragOver,
setRootDragOver,
};
}
@@ -1,53 +0,0 @@
import { useState } from "react";
/** Selected-hosts and open-menu/tray id state shared across the tree's rows. */
export function useSidebarSelection() {
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(
new Set(),
);
const [openMenuHostId, setOpenMenuHostId] = useState<string | null>(null);
const [openTrayHostId, setOpenTrayHostId] = useState<string | null>(null);
// Hover-mode tray expansion has to live in state, not just CSS: rows are
// absolutely positioned at virtualizer-computed heights, so a row that grows
// on hover would overlap the row below unless the height is reserved too.
const [hoveredHostId, setHoveredHostId] = useState<string | null>(null);
function toggleSelect(id: string) {
setSelectedHostIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
/** Selects every host id, or deselects them if they're all already selected. */
function toggleSelectMany(ids: string[]) {
setSelectedHostIds((prev) => {
const allSelected = ids.every((id) => prev.has(id));
const next = new Set(prev);
if (allSelected) {
for (const id of ids) next.delete(id);
} else {
for (const id of ids) next.add(id);
}
return next;
});
}
return {
selectedHostIds,
setSelectedHostIds,
openMenuHostId,
setOpenMenuHostId,
openTrayHostId,
setOpenTrayHostId,
hoveredHostId,
setHoveredHostId,
toggleSelect,
toggleSelectMany,
};
}
-39
View File
@@ -1,39 +0,0 @@
import { useSyncExternalStore } from "react";
/**
* Which host actions the user put in or took out of the row's connect bar.
* The hosts panel owns the preference and mirrors it here, so every row can
* read it without its own fetch.
*/
let current: Record<string, boolean> = {};
const listeners = new Set<() => void>();
export function setBarActions(next: Record<string, boolean>): void {
const same =
Object.keys(next).length === Object.keys(current).length &&
Object.entries(next).every(([id, value]) => current[id] === value);
if (same) return;
current = next;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useBarActions(): Record<string, boolean> {
return useSyncExternalStore(
subscribe,
() => current,
() => current,
);
}
/** Whether an action shows in the bar: the user's choice, else its own. */
export function showsInBar(
barActions: Record<string, boolean>,
action: { id: string; tray?: boolean },
): boolean {
return barActions[action.id] ?? action.tray !== false;
}
-3
View File
@@ -1,3 +0,0 @@
export { isFolder } from "./visible-rows";
export { SidebarTree } from "./SidebarTree";
-21
View File
@@ -1,21 +0,0 @@
import type { Host } from "@/types/ui-types";
/** The CPU/RAM bar row, including its pt-[5.25px]. */
export const RESOURCE_ROW_EXTRA = 17.25;
/**
* Whether a host row will actually render the resource bars.
*
* Mirrors HostItem's own condition. The virtualizer used to reserve their
* height for every row in "always" mode, which left a gap under each offline
* host -- most obvious down a long list where nothing breaks up the rows.
*/
export function rendersResourceRow(
host: Pick<Host, "online" | "cpu" | "ram">,
showResourceBars: boolean,
isCompactDensity: boolean,
): boolean {
if (!showResourceBars || isCompactDensity) return false;
if (!host.online) return false;
return (host.cpu ?? 0) > 0 || (host.ram ?? 0) > 0;
}
-218
View File
@@ -1,218 +0,0 @@
import type { Host, HostFolder } from "@/types/ui-types";
export function isFolder(item: Host | HostFolder): item is HostFolder {
return "children" in item;
}
export function hostMatchesQuery(host: Host, query: string) {
return (
host.name.toLowerCase().includes(query) ||
host.ip.toLowerCase().includes(query) ||
host.username.toLowerCase().includes(query) ||
host.tags?.some((t) => t.toLowerCase().includes(query))
);
}
/** Key used in the openFolders/openSet expand-state Set for a host's own children row. */
export function hostExpandKey(host: Host): string {
return `host:${host.id}`;
}
function hostHasMatch(host: Host, query: string): boolean {
if (hostMatchesQuery(host, query)) return true;
return (host.childHosts ?? []).some((child) => hostHasMatch(child, query));
}
export function folderHasMatch(folder: HostFolder, query: string): boolean {
for (const child of folder.children) {
if (isFolder(child)) {
if (folderHasMatch(child, query)) return true;
} else {
if (hostHasMatch(child, query)) return true;
}
}
return false;
}
/** Sentinel parent for rows sitting at the tree root (no folder). */
export const ROOT_PARENT = "__root__";
// parentPath identifies which folder/group a row was collected under. Group-by
// views (e.g. group by tags) can list the same host under several synthetic
// group folders, so the host id alone isn't a unique row identity -- the
// virtualizer needs parentPath+id to tell those rows apart.
export type VirtualRow = {
item: Host | HostFolder;
depth: number;
parentPath: string;
};
function collectVisibleHostRows(
host: Host,
query: string,
closedHostParents: Set<string>,
out: VirtualRow[],
depth: number,
parentPath: string,
): void {
if (!query || hostMatchesQuery(host, query) || hostHasMatch(host, query)) {
out.push({ item: host, depth, parentPath });
} else {
return;
}
const childHosts = host.childHosts ?? [];
if (childHosts.length === 0) return;
// Sub-host parents default to expanded (opposite of folders): a host that
// just got reparented shouldn't seem to disappear because its new parent
// row starts closed. closedHostParents tracks the opposite of openFolders
// -- parents the user has explicitly collapsed.
const isOpen = query ? true : !closedHostParents.has(hostExpandKey(host));
if (!isOpen) return;
for (const child of childHosts) {
collectVisibleHostRows(
child,
query,
closedHostParents,
out,
depth + 1,
`${parentPath}>host:${host.id}`,
);
}
}
export function collectVisibleRows(
children: (Host | HostFolder)[],
query: string,
openSet: Set<string>,
out: VirtualRow[] = [],
depth = 0,
closedHostParents: Set<string> = new Set(),
parentPath: string = ROOT_PARENT,
): VirtualRow[] {
for (const child of children) {
if (isFolder(child)) {
const visible = query ? folderHasMatch(child, query) : true;
if (!visible) continue;
const path = child.path ?? child.name;
out.push({ item: child, depth, parentPath });
const childOpen = query ? true : openSet.has(path);
if (childOpen)
collectVisibleRows(
child.children,
query,
openSet,
out,
depth + 1,
closedHostParents,
path,
);
} else {
collectVisibleHostRows(
child,
query,
closedHostParents,
out,
depth,
parentPath,
);
}
}
return out;
}
export function collectAllHosts(children: (Host | HostFolder)[]): Host[] {
const out: Host[] = [];
for (const child of children) {
if (isFolder(child)) {
out.push(...collectAllHosts(child.children));
} else {
out.push(child);
if (child.childHosts && child.childHosts.length > 0) {
out.push(...collectAllHosts(child.childHosts));
}
}
}
return out;
}
// Open/close state and folder assignment are both keyed by the full " / " path,
// so two folders that share a leaf name don't collapse together. Synthetic group
// headers (group-by views) are excluded from the assignable-folder list.
export function collectAllFolderPaths(
children: (Host | HostFolder)[],
): string[] {
const paths = new Set<string>();
for (const child of children) {
if (isFolder(child)) {
const path = child.path ?? child.name;
if (!path.startsWith("__group__:")) paths.add(path);
for (const p of collectAllFolderPaths(child.children)) paths.add(p);
}
}
return Array.from(paths).sort((a, b) => a.localeCompare(b));
}
export function rowKey(item: Host | HostFolder): string {
return isFolder(item)
? `folder:${item.path ?? item.name}`
: `host:${item.id}`;
}
export function rowKind(key: string): string {
return key.slice(0, key.indexOf(":"));
}
/**
* Flattens the visible rows into the parent-scoped shape planReorder needs.
*
* Parent is derived from the row's own data rather than from its position in
* the flattened list: a folder's parent is its path minus the last segment,
* a host's is its folder (or its parent host when nested as a sub-host).
* Scoping by parent is what keeps a drop comparing against the neighbours it
* was actually dropped between.
*/
export function buildReorderRows(
rows: { item: Host | HostFolder }[],
): { key: string; parentKey: string; sortOrder?: number | null }[] {
return rows.map(({ item }) => {
if (isFolder(item)) {
const path = item.path ?? item.name;
const cut = path.lastIndexOf(" / ");
return {
key: rowKey(item),
parentKey: cut === -1 ? ROOT_PARENT : `folder:${path.slice(0, cut)}`,
sortOrder: item.sortOrder,
};
}
const parentKey = item.parentHostId
? `host:${item.parentHostId}`
: item.folder
? `folder:${item.folder}`
: ROOT_PARENT;
return { key: rowKey(item), parentKey, sortOrder: item.sortOrder };
});
}
/**
* Every orderable row in the tree, visible or not, in tree order.
*
* Reorder math must run against the complete sibling set: visibleRows omits
* anything inside a collapsed folder, so planning a drop off it compared the
* dragged row against a partial group and produced a position that only
* settled correctly after a second drop.
*/
export function collectOrderableRows(
children: (Host | HostFolder)[],
out: { item: Host | HostFolder }[] = [],
): { item: Host | HostFolder }[] {
for (const child of children) {
if (isFolder(child)) {
out.push({ item: child });
collectOrderableRows(child.children, out);
} else {
out.push({ item: child });
if (child.childHosts?.length) collectOrderableRows(child.childHosts, out);
}
}
return out;
}
-52
View File
@@ -1,52 +0,0 @@
import { useCallback, useEffect, useState } from "react";
/**
* Drag-to-rearrange lock for a sidebar tree, kept per-device in
* localStorage rather than in the synced sidebar-preferences blob.
*
* It deliberately does NOT live in HostSidebarPreferences /
* CredentialSidebarPreferences: those are fetched from the server on every
* mount and the response replaces local state wholesale, so a value the
* server has never stored (and, outside "cloud" storage mode, never will)
* gets clobbered back to its default the moment the fetch resolves. That
* made the toggle appear to do nothing at all.
*
* Locked is the default, so a fresh device never drags by accident.
*/
export function useArrangeLock(storageKey: string) {
const syncEvent = `${storageKey}:changed`;
const read = useCallback(() => {
try {
return localStorage.getItem(storageKey) === "false";
} catch {
return false;
}
}, [storageKey]);
const [unlocked, setUnlocked] = useState(read);
useEffect(() => {
const handler = () => setUnlocked(read());
window.addEventListener(syncEvent, handler);
window.addEventListener("storage", handler);
return () => {
window.removeEventListener(syncEvent, handler);
window.removeEventListener("storage", handler);
};
}, [syncEvent, read]);
const toggle = useCallback(() => {
const next = !read();
try {
localStorage.setItem(storageKey, next ? "false" : "true");
} catch {
/* ignore */
}
setUnlocked(next);
window.dispatchEvent(new Event(syncEvent));
return next;
}, [storageKey, syncEvent, read]);
return { arrangeLocked: !unlocked, toggleArrangeLock: toggle };
}