feat: refactor rbac/sharing to support new permissions and auth types

This commit is contained in:
LukeGus
2026-07-16 22:05:17 -05:00
parent 3cc51fe920
commit f27b4e6b23
58 changed files with 2949 additions and 68 deletions
@@ -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"
+42 -2
View File
@@ -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
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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"
+23 -4
View File
@@ -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;
-52
View File
@@ -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("");
});
});
+28 -7
View File
@@ -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);
-137
View File
@@ -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");
});
});
-88
View File
@@ -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,
});
});
});
-49
View File
@@ -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"]);
});
});