mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 02:25:24 +00:00
v26.10.3 (#1692)
* 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:
577 files changed
+76479
-101831
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
export * from "./tree";
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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 : "",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { isFolder } from "./visible-rows";
|
||||
|
||||
export { SidebarTree } from "./SidebarTree";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user