mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
feat: refactor rbac/sharing to support new permissions and auth types
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Share2,
|
||||
Trash2,
|
||||
Unlink,
|
||||
@@ -32,6 +33,8 @@ export type AdminUser = {
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
dataUnlocked?: boolean;
|
||||
totpEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
@@ -76,6 +79,7 @@ type UsersSectionProps = {
|
||||
SetStateAction<{ id: string; username: string } | null>
|
||||
>;
|
||||
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onManageUser: (user: AdminUser) => void;
|
||||
};
|
||||
|
||||
export function AdminUsersSection({
|
||||
@@ -91,6 +95,7 @@ export function AdminUsersSection({
|
||||
setLinkAccountOpen,
|
||||
setUnlinkAccountTarget,
|
||||
setUnlinkAccountOpen,
|
||||
onManageUser,
|
||||
}: UsersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -159,6 +164,15 @@ export function AdminUsersSection({
|
||||
</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"
|
||||
|
||||
@@ -87,6 +87,8 @@ import {
|
||||
AdminLinkAccountDialog,
|
||||
AdminUnlinkAccountDialog,
|
||||
} from "./AdminUserDialogs";
|
||||
import { AdminUserManagePanel } from "./AdminUserManagePanel";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
@@ -100,11 +102,18 @@ function apiErrorMessage(error: unknown, fallback: string) {
|
||||
return (error as ApiErrorLike).response?.data?.error || fallback;
|
||||
}
|
||||
|
||||
export function AdminSettingsPanel() {
|
||||
export function AdminSettingsPanel({
|
||||
onEditingChange,
|
||||
onOpenHostTab,
|
||||
}: {
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
onOpenHostTab?: (host: Host) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [openSection, setOpenSection] = useState<AdminSection | null>(
|
||||
"general",
|
||||
);
|
||||
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
|
||||
@@ -206,6 +215,11 @@ export function AdminSettingsPanel() {
|
||||
loadSSOProviders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onEditingChange?.(manageUser !== null);
|
||||
return () => onEditingChange?.(false);
|
||||
}, [manageUser, onEditingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editUserOpen && editUserTarget) {
|
||||
setEditUserRoles([]);
|
||||
@@ -227,6 +241,8 @@ export function AdminSettingsPanel() {
|
||||
isAdmin: user.is_admin,
|
||||
isOidc: user.is_oidc,
|
||||
passwordHash: user.password_hash,
|
||||
dataUnlocked: user.data_unlocked,
|
||||
totpEnabled: user.totp_enabled,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -812,8 +828,31 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}}
|
||||
onTotpDisabled={() => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === manageUser.id ? { ...u, totpEnabled: false } : u,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSection === "general"}
|
||||
onToggle={() => toggle("general")}
|
||||
@@ -881,6 +920,7 @@ export function AdminSettingsPanel() {
|
||||
setLinkAccountOpen={setLinkAccountOpen}
|
||||
setUnlinkAccountTarget={setUnlinkAccountTarget}
|
||||
setUnlinkAccountOpen={setUnlinkAccountOpen}
|
||||
onManageUser={setManageUser}
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
|
||||
@@ -34,7 +34,7 @@ export function AccordionSection({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-border bg-card overflow-hidden">
|
||||
<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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -137,8 +137,8 @@ function buildRailButtons(
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"relative flex items-center gap-2.5 h-7 rounded shrink-0 transition-colors";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 6px" };
|
||||
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 8px" };
|
||||
|
||||
export function AppRail({
|
||||
railView,
|
||||
@@ -279,8 +279,8 @@ export function AppRail({
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
@@ -304,8 +304,8 @@ export function AppRail({
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
@@ -362,7 +362,7 @@ export function AppRail({
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
@@ -381,7 +381,7 @@ export function AppRail({
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{t("common.logout")}
|
||||
</span>
|
||||
@@ -391,7 +391,7 @@ export function AppRail({
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<button
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 6px" }}
|
||||
style={{ padding: "0 8px" }}
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
generateKeyPair,
|
||||
generatePublicKeyFromPrivate,
|
||||
updateCredential,
|
||||
adminCreateUserCredential,
|
||||
adminUpdateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
|
||||
@@ -23,11 +25,15 @@ export function CredentialEditorView({
|
||||
activeTab,
|
||||
onBack,
|
||||
onSave,
|
||||
adminTargetUserId,
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
activeTab: string;
|
||||
onBack: () => void;
|
||||
onSave: (saved: Record<string, unknown>) => void;
|
||||
// When set, saves go to another user's credentials via the admin
|
||||
// impersonation endpoints.
|
||||
adminTargetUserId?: string;
|
||||
}) {
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
@@ -92,15 +98,28 @@ export function CredentialEditorView({
|
||||
: credForm.passphrase || null
|
||||
: null,
|
||||
};
|
||||
const saved = credential
|
||||
? await updateCredential(Number(credential.id), data)
|
||||
: await createCredential(data);
|
||||
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"),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
if (!adminTargetUserId) {
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
}
|
||||
onSave(saved);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePath, splitPath } from "./FolderPathPicker.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on canonical separator", () => {
|
||||
expect(splitPath("Homelab / Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash without spaces", () => {
|
||||
expect(splitPath("Homelab/Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash with only leading space", () => {
|
||||
expect(splitPath("Homelab /Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash with only trailing space", () => {
|
||||
expect(splitPath("Homelab/ Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("handles three levels", () => {
|
||||
expect(splitPath("A/B/C")).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("filters empty segments from leading/trailing slashes", () => {
|
||||
expect(splitPath("/Debian/")).toEqual(["Debian"]);
|
||||
});
|
||||
|
||||
it("returns single segment for non-slash path", () => {
|
||||
expect(splitPath("Homelab")).toEqual(["Homelab"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("normalizes slash-without-spaces to canonical form", () => {
|
||||
expect(normalizePath("Homelab/Debian")).toBe("Homelab / Debian");
|
||||
});
|
||||
|
||||
it("is idempotent on already-canonical input", () => {
|
||||
expect(normalizePath("Homelab / Debian")).toBe("Homelab / Debian");
|
||||
});
|
||||
|
||||
it("normalizes mixed spacing around slash", () => {
|
||||
expect(normalizePath("Homelab /Debian/ Servers")).toBe(
|
||||
"Homelab / Debian / Servers",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizePath("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
getUserInfo,
|
||||
getVaultProfiles,
|
||||
getHostPassword,
|
||||
adminCreateUserHost,
|
||||
adminUpdateUserHost,
|
||||
adminGetHostPassword,
|
||||
adminGetUserSnippets,
|
||||
} from "@/main-axios";
|
||||
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||
@@ -77,6 +81,7 @@ export function HostEditor({
|
||||
onTabChange,
|
||||
hosts,
|
||||
credentials,
|
||||
adminTargetUserId,
|
||||
}: {
|
||||
host: Host | null;
|
||||
activeTab: string;
|
||||
@@ -87,6 +92,9 @@ export function HostEditor({
|
||||
onTabChange: (tab: string) => void;
|
||||
hosts: Host[];
|
||||
credentials: { id: string; name: string; username: string }[];
|
||||
// When set, the editor works on another user's host through the admin
|
||||
// impersonation endpoints instead of the signed-in user's own data.
|
||||
adminTargetUserId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setPreviewTerminalTheme } = useTabsSafe();
|
||||
@@ -133,11 +141,14 @@ export function HostEditor({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSnippets()
|
||||
const loadSnippets = adminTargetUserId
|
||||
? adminGetUserSnippets(adminTargetUserId)
|
||||
: getSnippets();
|
||||
loadSnippets
|
||||
.then((res) => setSnippets(mapSnippetResponse(res)))
|
||||
.catch(() => {});
|
||||
reloadVaultProfiles();
|
||||
}, []);
|
||||
}, [adminTargetUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (host) return;
|
||||
@@ -150,7 +161,10 @@ export function HostEditor({
|
||||
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
|
||||
|
||||
let cancelled = false;
|
||||
getHostPassword(Number(host.id), "vncPassword").then((password) => {
|
||||
const loadVncPassword = adminTargetUserId
|
||||
? adminGetHostPassword(adminTargetUserId, Number(host.id), "vncPassword")
|
||||
: getHostPassword(Number(host.id), "vncPassword");
|
||||
loadVncPassword.then((password) => {
|
||||
if (cancelled || !password) return;
|
||||
setForm((prev) =>
|
||||
prev.vncPassword ? prev : { ...prev, vncPassword: password },
|
||||
@@ -160,7 +174,7 @@ export function HostEditor({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.vncAuthType, form.vncPassword, host?.id]);
|
||||
}, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "tunnels") return;
|
||||
@@ -184,9 +198,16 @@ export function HostEditor({
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = buildHostEditorPayload(form, protocols);
|
||||
const saved = host
|
||||
? await updateSSHHost(Number(host.id), data)
|
||||
: await createSSHHost(data);
|
||||
let saved: SSHHost;
|
||||
if (adminTargetUserId) {
|
||||
saved = host
|
||||
? await adminUpdateUserHost(adminTargetUserId, Number(host.id), data)
|
||||
: await adminCreateUserHost(adminTargetUserId, data);
|
||||
} else {
|
||||
saved = host
|
||||
? await updateSSHHost(Number(host.id), data)
|
||||
: await createSSHHost(data);
|
||||
}
|
||||
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
|
||||
setPreviewTerminalTheme(null);
|
||||
onSave(saved);
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createHostEditorForm,
|
||||
buildHostEditorPayload,
|
||||
type HostProtocols,
|
||||
} from "./HostEditorData";
|
||||
|
||||
const sshOnly: HostProtocols = {
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
};
|
||||
|
||||
describe("buildHostEditorPayload auth field isolation", () => {
|
||||
it("only sends the password when authType is password", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "hunter2",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "kp",
|
||||
credentialId: "5",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.password).toBe("hunter2");
|
||||
expect(payload.key).toBeNull();
|
||||
expect(payload.keyPassword).toBeNull();
|
||||
expect(payload.credentialId).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the credentialId when switching a cloned host away from credential auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "newpass",
|
||||
credentialId: "12",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.credentialId).toBeNull();
|
||||
expect(payload.password).toBe("newpass");
|
||||
});
|
||||
|
||||
it("sends credentialId and optional password when authType is credential", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "credential" as const,
|
||||
credentialId: "7",
|
||||
password: "host-specific-password",
|
||||
key: "leftover-key",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.credentialId).toBe(7);
|
||||
expect(payload.password).toBe("host-specific-password");
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("sends key fields and optional password when authType is key", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "key" as const,
|
||||
key: "MY KEY",
|
||||
keyType: "ssh-ed25519",
|
||||
password: "leftover",
|
||||
credentialId: "3",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.key).toBe("MY KEY");
|
||||
expect(payload.keyType).toBe("ssh-ed25519");
|
||||
expect(payload.password).toBe("leftover");
|
||||
expect(payload.credentialId).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves agentSocketPath in terminalConfig when authType is agent", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBe("/run/user/1000/gnupg/S.gpg-agent.ssh");
|
||||
expect(payload.password).toBeNull();
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("sets agentSocketPath to null in payload when authType is agent but path is empty", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
|
||||
it("nulls out agentSocketPath when switching away from agent auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "mypass",
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves sudo password autofill settings", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
sudoPasswordAutoFill: true,
|
||||
sudoPassword: "sudo-secret",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.sudoPasswordAutoFill).toBe(true);
|
||||
expect(tc?.sudoPassword).toBe("sudo-secret");
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
import { resolveHostSortPreferences, sortHostTree } from "./host-sort";
|
||||
|
||||
function host(name: string, pin = false): Host {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
ip: `10.0.0.${name.length}`,
|
||||
online: true,
|
||||
pin,
|
||||
} as Host;
|
||||
}
|
||||
|
||||
function names(folder: HostFolder): string[] {
|
||||
return folder.children.map((child) => child.name);
|
||||
}
|
||||
|
||||
describe("sortHostTree", () => {
|
||||
it("sorts pinned and unpinned hosts by name within their groups", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [
|
||||
host("gamma"),
|
||||
host("zeta", true),
|
||||
host("beta"),
|
||||
host("alpha", true),
|
||||
],
|
||||
};
|
||||
|
||||
expect(names(sortHostTree(tree, "name-asc", true))).toEqual([
|
||||
"alpha",
|
||||
"zeta",
|
||||
"beta",
|
||||
"gamma",
|
||||
]);
|
||||
expect(names(tree)).toEqual(["gamma", "zeta", "beta", "alpha"]);
|
||||
});
|
||||
|
||||
it("keeps pinned-first independent from the selected base sort", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [host("alpha"), host("zeta", true), host("beta")],
|
||||
};
|
||||
|
||||
expect(names(sortHostTree(tree, "name-asc"))).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"zeta",
|
||||
]);
|
||||
expect(names(sortHostTree(tree, "default", true))).toEqual([
|
||||
"zeta",
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies combined sorting inside folders", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [
|
||||
{
|
||||
name: "production",
|
||||
children: [host("beta"), host("alpha", true)],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sorted = sortHostTree(tree, "name-asc", true);
|
||||
expect(names(sorted.children[0] as HostFolder)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHostSortPreferences", () => {
|
||||
it("migrates the legacy pinned sort without losing the preference", () => {
|
||||
expect(resolveHostSortPreferences("pinned", null)).toEqual({
|
||||
sortKey: "default",
|
||||
pinnedFirst: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the saved pinned modifier independent from base sorting", () => {
|
||||
expect(resolveHostSortPreferences("name-desc", "true")).toEqual({
|
||||
sortKey: "name-desc",
|
||||
pinnedFirst: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createQuickConnectHost,
|
||||
quickConnectHostToPayload,
|
||||
} from "./quick-connect-host";
|
||||
|
||||
describe("quick connect host", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("preserves password authentication when saving the connection", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1234);
|
||||
|
||||
const host = createQuickConnectHost({
|
||||
ip: "server.example.com",
|
||||
port: 2222,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
expect(host.id).toBe("quick-connect-1234");
|
||||
expect(quickConnectHostToPayload(host)).toMatchObject({
|
||||
name: "root@server.example.com",
|
||||
ip: "server.example.com",
|
||||
port: 2222,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
connectionType: "ssh",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only the selected credential authentication data", () => {
|
||||
const host = createQuickConnectHost({
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "deploy",
|
||||
authType: "credential",
|
||||
credentialId: "42",
|
||||
password: "ignored",
|
||||
key: "ignored",
|
||||
});
|
||||
const payload = quickConnectHostToPayload(host);
|
||||
|
||||
expect(payload.credentialId).toBe(42);
|
||||
expect(payload.password).toBeUndefined();
|
||||
expect(payload.key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
|
||||
// Mirror of SidebarTree collectVisibleRows for unit coverage without exporting
|
||||
// the full React module graph.
|
||||
function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
function hostMatchesQuery(host: Host, query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
host.name.toLowerCase().includes(q) ||
|
||||
host.ip.toLowerCase().includes(q) ||
|
||||
host.username.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function folderHasMatch(folder: HostFolder, query: string): boolean {
|
||||
if (folder.name.toLowerCase().includes(query.toLowerCase())) return true;
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
if (folderHasMatch(child, query)) return true;
|
||||
} else if (hostMatchesQuery(child, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type VirtualRow = { item: Host | HostFolder; depth: number };
|
||||
|
||||
function collectVisibleRows(
|
||||
children: (Host | HostFolder)[],
|
||||
query: string,
|
||||
openSet: Set<string>,
|
||||
out: VirtualRow[] = [],
|
||||
depth = 0,
|
||||
): VirtualRow[] {
|
||||
for (const child of children) {
|
||||
if (isFolder(child)) {
|
||||
const visible = query ? folderHasMatch(child, query) : true;
|
||||
if (!visible) continue;
|
||||
out.push({ item: child, depth });
|
||||
const childOpen = query ? true : openSet.has(child.path ?? child.name);
|
||||
if (childOpen)
|
||||
collectVisibleRows(child.children, query, openSet, out, depth + 1);
|
||||
} else {
|
||||
if (!query || hostMatchesQuery(child, query))
|
||||
out.push({ item: child, depth });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function host(id: string, name: string): Host {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
username: "u",
|
||||
ip: "10.0.0." + id,
|
||||
port: 22,
|
||||
folder: "",
|
||||
online: true,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
tags: [],
|
||||
authType: "password",
|
||||
pin: false,
|
||||
enableSsh: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
enableDocker: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
quickActions: [],
|
||||
} as Host;
|
||||
}
|
||||
|
||||
describe("collectVisibleRows", () => {
|
||||
const tree: (Host | HostFolder)[] = [
|
||||
host("1", "root-host"),
|
||||
{
|
||||
name: "prod",
|
||||
path: "prod",
|
||||
children: [
|
||||
host("2", "web"),
|
||||
{
|
||||
name: "db",
|
||||
path: "prod / db",
|
||||
children: [host("3", "postgres")],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("collapses closed folders", () => {
|
||||
const rows = collectVisibleRows(tree, "", new Set());
|
||||
expect(
|
||||
rows.map((r) => (isFolder(r.item) ? r.item.name : r.item.name)),
|
||||
).toEqual(["root-host", "prod"]);
|
||||
});
|
||||
|
||||
it("expands open folders with depth", () => {
|
||||
const rows = collectVisibleRows(tree, "", new Set(["prod", "prod / db"]));
|
||||
expect(
|
||||
rows.map((r) => ({
|
||||
name: isFolder(r.item) ? r.item.name : r.item.name,
|
||||
depth: r.depth,
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: "root-host", depth: 0 },
|
||||
{ name: "prod", depth: 0 },
|
||||
{ name: "web", depth: 1 },
|
||||
{ name: "db", depth: 1 },
|
||||
{ name: "postgres", depth: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens all matching folders when searching", () => {
|
||||
const rows = collectVisibleRows(tree, "postgres", new Set());
|
||||
expect(
|
||||
rows.map((r) => (isFolder(r.item) ? r.item.name : r.item.name)),
|
||||
).toEqual(["prod", "db", "postgres"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user