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
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "2.5.0", "version": "2.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "2.5.0", "version": "2.5.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "private": true,
"version": "2.5.0", "version": "2.5.1",
"description": "Self-hosted SSH and remote desktop management.", "description": "Self-hosted SSH and remote desktop management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -6,6 +6,7 @@ import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { AuthManager } from "../../utils/auth-manager.js"; import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { import {
createCurrentRoleRepository, createCurrentRoleRepository,
createCurrentUserRepository, createCurrentUserRepository,
@@ -51,7 +52,11 @@ export function registerUserAdminRoutes(
*/ */
router.get("/list", authenticateJWT, async (req, res) => { router.get("/list", authenticateJWT, async (req, res) => {
try { try {
const allUsers = await createCurrentUserRepository().listAll(); const userRepository = createCurrentUserRepository();
const requester = await userRepository.findById(
(req as AuthenticatedRequest).userId,
);
const allUsers = await userRepository.listAll();
res.json({ res.json({
users: allUsers.map((u) => ({ users: allUsers.map((u) => ({
@@ -60,6 +65,14 @@ export function registerUserAdminRoutes(
is_admin: u.isAdmin, is_admin: u.isAdmin,
is_oidc: u.isOidc, is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null, password_hash: u.passwordHash ? "set" : null,
// Management-only details stay admin-eyes-only; regular users hit
// this route to pick sharing targets.
...(requester?.isAdmin
? {
data_unlocked: DataCrypto.canUserAccessData(u.id),
totp_enabled: !!u.totpEnabled,
}
: {}),
})), })),
}); });
} catch (err) { } catch (err) {
@@ -606,4 +619,176 @@ export function registerUserAdminRoutes(
res.status(500).json({ error: "Failed to reset password" }); res.status(500).json({ error: "Failed to reset password" });
} }
}); });
/**
* @openapi
* /users/admin/totp/disable:
* post:
* summary: Disable a user's TOTP (admin only)
* description: Clears another user's TOTP secret, enabled flag and backup codes so they can log in without 2FA.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: TOTP disabled for the user.
* 400:
* description: User ID is required or TOTP is not enabled.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to disable TOTP.
*/
router.post("/admin/totp/disable", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const { userId: targetUserId } = req.body;
if (!isNonEmptyString(targetUserId)) {
return res.status(400).json({ error: "User ID is required" });
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(targetUserId.trim());
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!targetUser.totpEnabled) {
return res
.status(400)
.json({ error: "TOTP is not enabled for this user" });
}
await userRepository.update(targetUser.id, {
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
try {
await DatabaseSaveTrigger.forceSave("admin_disable_totp");
} catch (saveError) {
authLogger.error("Failed to persist TOTP disable to disk", saveError, {
operation: "admin_disable_totp_save_failed",
userId: targetUser.id,
});
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_disable_totp",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
res.json({ message: "TOTP disabled" });
} catch (err) {
authLogger.error("Failed to disable TOTP for user", err);
res.status(500).json({ error: "Failed to disable TOTP" });
}
});
/**
* @openapi
* /users/admin/export/{userId}:
* get:
* summary: Export a user's data (admin only)
* description: Downloads a JSON export of another user's data (hosts, credentials, file manager bookmarks). Secrets are decrypted server-side, so handle the file carefully.
* tags:
* - Users
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: JSON export of the user's data.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 423:
* description: The user's data stays locked until their next login.
* 500:
* description: Failed to export user data.
*/
router.get("/admin/export/:userId", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const targetUserId = Array.isArray(req.params.userId)
? req.params.userId[0]
: req.params.userId;
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!DataCrypto.canUserAccessData(targetUser.id)) {
return res.status(423).json({
error: "Target user's data stays locked until their next login",
code: "TARGET_DATA_LOCKED",
});
}
const { UserDataExport } =
await import("../../utils/user-data-export.js");
const exportData = await UserDataExport.exportUserData(targetUser.id, {
format: "plaintext",
includeCredentials: true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_export_user_data",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
`attachment; filename="termix-user-${targetUser.username}-export.json"`,
);
res.json(exportData);
} catch (err) {
authLogger.error("Failed to export user data", err);
res.status(500).json({ error: "Failed to export user data" });
}
});
} }
+26 -3
View File
@@ -1,7 +1,9 @@
import { import {
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentVaultProfileRepository, createCurrentVaultProfileRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js"; } from "../database/repositories/factory.js";
import { logAudit } from "../utils/audit-logger.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import { import {
pickResolvedPassword, pickResolvedPassword,
@@ -39,7 +41,28 @@ export async function resolveHostById(
const host = resolvedHost as Record<string, unknown>; const host = resolvedHost as Record<string, unknown>;
if (userId !== ownerId) { // Admin bypass resolves like the owner would; every such access is audited.
const ownerEquivalent = userId === ownerId || access.isAdminBypass === true;
if (access.isAdminBypass && userId !== ownerId) {
try {
const admin = await createCurrentUserRepository().findById(userId);
void logAudit({
userId,
username: admin?.username ?? "unknown",
action: "admin_connect_host",
resourceType: "host",
resourceId: String(hostId),
resourceName: (host.name as string) || (host.ip as string) || "",
details: JSON.stringify({ ownerId }),
success: true,
});
} catch {
// never block resolution on audit bookkeeping
}
}
if (!ownerEquivalent) {
// Owner-only operational secrets are never shared. // Owner-only operational secrets are never shared.
host.sudoPassword = null; host.sudoPassword = null;
host.autostartPassword = null; host.autostartPassword = null;
@@ -91,7 +114,7 @@ export async function resolveHostById(
} }
} }
if (userId !== ownerId) { if (!ownerEquivalent) {
const resolved = await resolveSharedSshSecrets( const resolved = await resolveSharedSshSecrets(
host, host,
hostId, hostId,
@@ -133,7 +156,7 @@ export async function resolveHostById(
host.username = await expandOidcUsername( host.username = await expandOidcUsername(
host.username as string | undefined, host.username as string | undefined,
userId, ownerEquivalent ? ownerId : userId,
); );
// Resolve a Vault SSH signer profile (shared settings, no secrets). The // Resolve a Vault SSH signer profile (shared settings, no secrets). The
@@ -0,0 +1,275 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, RequestHandler, Response } from "express";
const state = vi.hoisted(() => ({
currentUserId: "admin1",
users: new Map<
string,
{
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash: string | null;
totpEnabled: boolean;
}
>(),
unlockedUsers: new Set<string>(),
updates: [] as { id: string; changes: Record<string, unknown> }[],
auditCalls: [] as Record<string, unknown>[],
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params);
},
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
canUserAccessData: (userId: string) => state.unlockedUsers.has(userId),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({}) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
listAll: async () => [...state.users.values()],
findById: async (id: string) => state.users.get(id) ?? null,
findByUsername: async (username: string) =>
[...state.users.values()].find((u) => u.username === username) ?? null,
update: async (id: string, changes: Record<string, unknown>) => {
state.updates.push({ id, changes });
const user = state.users.get(id);
if (user) Object.assign(user, changes);
},
}),
createCurrentRoleRepository: () => ({
switchUserRoleName: async () => {},
assignRoleNameToUser: async () => {},
}),
}));
const { registerUserAdminRoutes } =
await import("../../../database/routes/user-admin-routes.js");
// Capture the handlers registered on the router so we can invoke them directly
// without spinning up an HTTP server.
type Registered = { method: string; path: string; handler: RequestHandler };
const registered: Registered[] = [];
function fakeRouter() {
const record =
(method: string) =>
(path: string, ...handlers: RequestHandler[]) => {
registered.push({ method, path, handler: handlers[handlers.length - 1] });
};
return {
get: record("get"),
post: record("post"),
put: record("put"),
delete: record("delete"),
} as unknown as import("express").Router;
}
registerUserAdminRoutes(fakeRouter(), (_req, _res, next) => next());
function findHandler(method: string, path: string): RequestHandler {
const match = registered.find((r) => r.method === method && r.path === path);
if (!match) throw new Error(`No handler for ${method} ${path}`);
return match.handler;
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
headers: {} as Record<string, string>,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
setHeader(key: string, value: string) {
(this as unknown as { headers: Record<string, string> }).headers[key] =
value;
return this;
},
} as unknown as Response & {
statusCode: number;
jsonBody: unknown;
headers: Record<string, string>;
};
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
} = {},
) {
const handler = findHandler(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res as unknown as Response, () => {});
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "admin1";
state.users = new Map([
[
"admin1",
{
id: "admin1",
username: "admin",
isAdmin: true,
isOidc: false,
passwordHash: "hash",
totpEnabled: false,
},
],
[
"target1",
{
id: "target1",
username: "target",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
totpEnabled: true,
},
],
[
"locked1",
{
id: "locked1",
username: "locked",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
totpEnabled: false,
},
],
]);
state.unlockedUsers = new Set(["admin1", "target1"]);
state.updates = [];
state.auditCalls = [];
});
describe("GET /list", () => {
it("includes data_unlocked and totp_enabled for admin callers", async () => {
const res = await invoke("get", "/list");
expect(res.statusCode).toBe(200);
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
const target = users.find((u) => u.userId === "target1")!;
expect(target.data_unlocked).toBe(true);
expect(target.totp_enabled).toBe(true);
const locked = users.find((u) => u.userId === "locked1")!;
expect(locked.data_unlocked).toBe(false);
});
it("omits management fields for non-admin callers", async () => {
state.currentUserId = "target1";
const res = await invoke("get", "/list");
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
expect(users[0].data_unlocked).toBeUndefined();
expect(users[0].totp_enabled).toBeUndefined();
});
});
describe("POST /admin/totp/disable", () => {
it("clears TOTP fields for the target and audits", async () => {
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "target1" },
});
expect(res.statusCode).toBe(200);
const update = state.updates.find((u) => u.id === "target1");
expect(update?.changes).toMatchObject({
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
expect(
state.auditCalls.some((c) => c.action === "admin_disable_totp"),
).toBe(true);
});
it("403s when the caller is not an admin", async () => {
state.currentUserId = "target1";
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "locked1" },
});
expect(res.statusCode).toBe(403);
});
it("400s when TOTP is not enabled for the target", async () => {
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "locked1" },
});
expect(res.statusCode).toBe(400);
});
it("404s for an unknown target", async () => {
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "ghost" },
});
expect(res.statusCode).toBe(404);
});
});
describe("GET /admin/export/:userId", () => {
it("423s when the target's data is locked", async () => {
const res = await invoke("get", "/admin/export/:userId", {
params: { userId: "locked1" },
});
expect(res.statusCode).toBe(423);
expect((res.jsonBody as { code?: string }).code).toBe("TARGET_DATA_LOCKED");
});
it("403s when the caller is not an admin", async () => {
state.currentUserId = "target1";
const res = await invoke("get", "/admin/export/:userId", {
params: { userId: "admin1" },
});
expect(res.statusCode).toBe(403);
});
});
+66 -1
View File
@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
host: null as Record<string, unknown> | null, host: null as Record<string, unknown> | null,
hasAccess: true, hasAccess: true,
isAdminBypass: false,
overrideCredentialId: null as number | null, overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(), credentials: new Map<string, Record<string, unknown>>(),
sharedSecret: null as Record<string, unknown> | null, sharedSecret: null as Record<string, unknown> | null,
auditCalls: [] as Record<string, unknown>[],
})); }));
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
@@ -19,12 +21,24 @@ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentVaultProfileRepository: () => ({ createCurrentVaultProfileRepository: () => ({
findById: async () => null, findById: async () => null,
}), }),
createCurrentUserRepository: () => ({
findById: async (userId: string) => ({ id: userId, username: userId }),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params);
},
})); }));
vi.mock("../../utils/permission-manager.js", () => ({ vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: { PermissionManager: {
getInstance: () => ({ getInstance: () => ({
canAccessHost: async () => ({ hasAccess: state.hasAccess }), canAccessHost: async () => ({
hasAccess: state.hasAccess,
isAdminBypass: state.isAdminBypass,
}),
}), }),
}, },
})); }));
@@ -82,9 +96,11 @@ function baseHost(overrides: Record<string, unknown> = {}) {
beforeEach(() => { beforeEach(() => {
state.host = baseHost(); state.host = baseHost();
state.hasAccess = true; state.hasAccess = true;
state.isAdminBypass = false;
state.overrideCredentialId = null; state.overrideCredentialId = null;
state.credentials.clear(); state.credentials.clear();
state.sharedSecret = null; state.sharedSecret = null;
state.auditCalls = [];
}); });
describe("resolveHostById", () => { describe("resolveHostById", () => {
@@ -176,4 +192,53 @@ describe("resolveHostById", () => {
const host = await resolveHostById(42, "recipient"); const host = await resolveHostById(42, "recipient");
expect(host).not.toBeNull(); expect(host).not.toBeNull();
}); });
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
state.isAdminBypass = true;
state.host = baseHost({
authType: "credential",
credentialId: 9,
username: "",
password: null,
});
state.credentials.set("9:owner", {
id: 9,
username: "cred-user",
authType: "key",
password: null,
privateKey: "OWNER-PRIVATE-KEY",
key: null,
keyPassword: "kp",
keyType: "ssh-ed25519",
certPublicKey: null,
});
const host = (await resolveHostById(42, "adminUser")) as Record<
string,
unknown
>;
// Owner credential resolved (not the share snapshot path).
expect(host.key).toBe("OWNER-PRIVATE-KEY");
expect(host.username).toBe("cred-user");
// Owner-only operational secrets are NOT stripped for the admin.
expect(host.sudoPassword).toBe("owner-sudo");
expect(host.autostartPassword).toBe("auto-pass");
});
it("audits every admin-bypass host resolution", async () => {
state.isAdminBypass = true;
await resolveHostById(42, "adminUser");
expect(state.auditCalls).toHaveLength(1);
expect(state.auditCalls[0]).toMatchObject({
action: "admin_connect_host",
resourceType: "host",
resourceId: "42",
userId: "adminUser",
});
});
it("does not audit an ordinary owner resolution", async () => {
await resolveHostById(42, "owner");
expect(state.auditCalls).toHaveLength(0);
});
}); });
@@ -0,0 +1,219 @@
import crypto from "crypto";
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, it, vi } from "vitest";
const jwtSecret = "a".repeat(64);
const encryptionKey = crypto.randomBytes(32);
const state = vi.hoisted(() => ({
users: new Map<string, { id: string; isAdmin: boolean; username: string }>(),
unlockedUsers: new Set<string>(),
auditCalls: [] as Record<string, unknown>[],
}));
vi.mock("../../database/db/index.js", () => ({
db: {},
getDb: () => ({}),
saveMemoryDatabaseToFile: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
// No sessionId in our tokens, so the session branch is skipped.
createCurrentSessionRepository: () => ({ findById: async () => null }),
createCurrentUserRepository: () => ({
findById: async (userId: string) => state.users.get(userId) ?? null,
}),
createCurrentApiKeyRepository: () => ({}),
createCurrentTrustedDeviceRepository: () => ({}),
getCurrentSettingValue: () => null,
}));
vi.mock("../../utils/user-keys.js", () => ({
UserKeyManager: {
getInstance: () => ({
hasUserDEK: vi.fn(() => true),
tryGetUserDEK: vi.fn(() => null),
invalidate: vi.fn(),
}),
},
}));
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
adoptRecoveredDEK: vi.fn(async () => {}),
migratePasswordUserAtLogin: vi.fn(async () => true),
}));
vi.mock("../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({
getJWTSecret: async () => jwtSecret,
getEncryptionKey: async () => encryptionKey,
}),
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
canUserAccessData: (userId: string) => state.unlockedUsers.has(userId),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params);
},
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
const { AuthManager } = await import("../../utils/auth-manager.js");
const authManager = AuthManager.getInstance();
const middleware = authManager.createAuthMiddleware();
type MockRes = {
statusCode: number | null;
body: unknown;
status: (code: number) => MockRes;
json: (payload: unknown) => MockRes;
clearCookie: () => MockRes;
};
function makeRes(): MockRes {
const res: MockRes = {
statusCode: null,
body: null,
status(code: number) {
res.statusCode = code;
return res;
},
json(payload: unknown) {
res.body = payload;
return res;
},
clearCookie() {
return res;
},
};
return res;
}
function runMiddleware(token: string, headers: Record<string, string> = {}) {
const req = {
cookies: { jwt: token },
headers,
method: "GET",
originalUrl: headers["__url"] ?? "/host/db/host",
url: headers["__url"] ?? "/host/db/host",
secure: false,
} as unknown as Parameters<typeof middleware>[0];
const res = makeRes();
let nexted = false;
return new Promise<{ req: typeof req; res: MockRes; nexted: boolean }>(
(resolve) => {
const next = () => {
nexted = true;
resolve({ req, res, nexted });
};
const maybe = middleware(
req,
res as unknown as Parameters<typeof middleware>[1],
next,
);
Promise.resolve(maybe).then(() => {
if (!nexted) resolve({ req, res, nexted });
});
},
);
}
function token(userId: string) {
return jwt.sign({ userId }, jwtSecret, { expiresIn: "1h" });
}
beforeEach(() => {
state.users = new Map([
["admin1", { id: "admin1", isAdmin: true, username: "admin" }],
["target1", { id: "target1", isAdmin: false, username: "target" }],
["regular1", { id: "regular1", isAdmin: false, username: "regular" }],
]);
state.unlockedUsers = new Set(["admin1", "target1", "regular1"]);
state.auditCalls = [];
});
describe("AuthManager admin impersonation", () => {
it("swaps req.userId to the target for an admin on an allowlisted path", async () => {
const { req, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "target1",
__url: "/host/db/host",
});
expect(nexted).toBe(true);
expect((req as unknown as { userId: string }).userId).toBe("target1");
expect(
(req as unknown as { actingAdminUserId?: string }).actingAdminUserId,
).toBe("admin1");
expect(state.auditCalls).toHaveLength(1);
expect(state.auditCalls[0]).toMatchObject({
action: "admin_impersonated_request",
resourceId: "target1",
userId: "admin1",
});
});
it("rejects impersonation by a non-admin", async () => {
const { res, nexted } = await runMiddleware(token("regular1"), {
"x-admin-target-user": "target1",
__url: "/host/db/host",
});
expect(nexted).toBe(false);
expect(res.statusCode).toBe(403);
expect((res.body as { code?: string }).code).toBe("IMPERSONATION_DENIED");
});
it("rejects impersonation on a non-allowlisted path", async () => {
const { res, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "target1",
__url: "/users/sessions",
});
expect(nexted).toBe(false);
expect(res.statusCode).toBe(403);
expect((res.body as { code?: string }).code).toBe(
"IMPERSONATION_NOT_ALLOWED",
);
});
it("returns 423 when the target's data is locked", async () => {
state.unlockedUsers = new Set(["admin1"]);
const { res, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "target1",
__url: "/host/db/host",
});
expect(nexted).toBe(false);
expect(res.statusCode).toBe(423);
expect((res.body as { code?: string }).code).toBe("TARGET_DATA_LOCKED");
});
it("404s when the target user does not exist", async () => {
const { res, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "ghost",
__url: "/host/db/host",
});
expect(nexted).toBe(false);
expect(res.statusCode).toBe(404);
});
it("ignores the header when the target equals the admin", async () => {
const { req, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "admin1",
__url: "/host/db/host",
});
expect(nexted).toBe(true);
expect((req as unknown as { userId: string }).userId).toBe("admin1");
expect(state.auditCalls).toHaveLength(0);
});
it("passes through normally when no header is present", async () => {
const { req, nexted } = await runMiddleware(token("regular1"));
expect(nexted).toBe(true);
expect((req as unknown as { userId: string }).userId).toBe("regular1");
});
});
@@ -23,6 +23,7 @@ const accessState = vi.hoisted(() => ({
expiresAt: string | null; expiresAt: string | null;
} | null, } | null,
touched: [] as number[], touched: [] as number[],
adminIds: new Set<string>(),
})); }));
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
@@ -44,7 +45,8 @@ vi.mock("../../database/repositories/factory.js", () => ({
userHasAnyRoleName: async () => false, userHasAnyRoleName: async () => false,
}), }),
createCurrentUserRepository: () => ({ createCurrentUserRepository: () => ({
findById: async () => null, findById: async (userId: string) =>
accessState.adminIds.has(userId) ? { id: userId, isAdmin: true } : null,
}), }),
})); }));
@@ -116,6 +118,7 @@ describe("PermissionManager.canAccessHost level hierarchy", () => {
accessState.ownerId = "owner"; accessState.ownerId = "owner";
accessState.grant = null; accessState.grant = null;
accessState.touched = []; accessState.touched = [];
accessState.adminIds = new Set();
}); });
it("grants the owner every action including delete", async () => { it("grants the owner every action including delete", async () => {
@@ -165,4 +168,30 @@ describe("PermissionManager.canAccessHost level hierarchy", () => {
await manager.canAccessHost("recipient", 42, "connect"); await manager.canAccessHost("recipient", 42, "connect");
expect(accessState.touched).toEqual([5]); expect(accessState.touched).toEqual([5]);
}); });
it("grants admins owner-equivalent access to any host via bypass", async () => {
accessState.adminIds = new Set(["adminUser"]);
for (const action of actions) {
const info = await manager.canAccessHost("adminUser", 42, action);
expect(info).toMatchObject({
hasAccess: true,
isOwner: false,
isAdminBypass: true,
permissionLevel: "manage",
});
}
});
it("upgrades an under-privileged admin's share access via bypass", async () => {
accessState.adminIds = new Set(["adminUser"]);
accessState.grant = { id: 7, permissionLevel: "connect", expiresAt: null };
const info = await manager.canAccessHost("adminUser", 42, "manage");
expect(info).toMatchObject({ hasAccess: true, isAdminBypass: true });
});
it("does not grant a non-admin stranger admin bypass", async () => {
const info = await manager.canAccessHost("stranger", 42, "manage");
expect(info.hasAccess).toBe(false);
expect(info.isAdminBypass).toBeUndefined();
});
}); });
+112
View File
@@ -8,6 +8,7 @@ import {
import { SystemCrypto } from "./system-crypto.js"; import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js"; import { DataCrypto } from "./data-crypto.js";
import { databaseLogger, authLogger } from "./logger.js"; import { databaseLogger, authLogger } from "./logger.js";
import { logAudit, getRequestMeta } from "./audit-logger.js";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -57,6 +58,7 @@ interface AuthenticatedRequest extends Request {
apiKeyId?: string; apiKeyId?: string;
pendingTOTP?: boolean; pendingTOTP?: boolean;
dataKey?: Buffer; dataKey?: Buffer;
actingAdminUserId?: string;
} }
interface RequestWithHeaders extends Request { interface RequestWithHeaders extends Request {
@@ -65,6 +67,16 @@ interface RequestWithHeaders extends Request {
}; };
} }
const ADMIN_TARGET_USER_HEADER = "x-admin-target-user";
// Data-plane routes an admin may hit on behalf of another user. Everything
// else (TOTP, sessions, tunnels, file manager, ...) rejects the header.
const IMPERSONATION_PATH_ALLOWLIST = [
/^\/host\/db\//,
/^\/credentials(\/|$)/,
/^\/snippets(\/|$)/,
];
class AuthManager { class AuthManager {
private static instance: AuthManager; private static instance: AuthManager;
private systemCrypto: SystemCrypto; private systemCrypto: SystemCrypto;
@@ -593,6 +605,13 @@ class AuthManager {
token: string, token: string,
requireAdmin = false, requireAdmin = false,
): Promise<void> { ): Promise<void> {
if (req.headers[ADMIN_TARGET_USER_HEADER]) {
res.status(403).json({
error: "Impersonation is not allowed with API key authentication",
code: "IMPERSONATION_NOT_ALLOWED",
});
return;
}
try { try {
const tokenPrefix = token.substring(0, 12); const tokenPrefix = token.substring(0, 12);
const apiKeyRepository = createCurrentApiKeyRepository(); const apiKeyRepository = createCurrentApiKeyRepository();
@@ -763,10 +782,103 @@ class AuthManager {
authReq.userId = payload.userId; authReq.userId = payload.userId;
authReq.sessionId = payload.sessionId; authReq.sessionId = payload.sessionId;
authReq.pendingTOTP = payload.pendingTOTP; authReq.pendingTOTP = payload.pendingTOTP;
if (authReq.headers[ADMIN_TARGET_USER_HEADER]) {
const handled = await this.applyAdminImpersonation(
authReq,
res,
payload.userId,
);
if (handled) return;
}
next(); next();
}; };
} }
/**
* Handle the X-Admin-Target-User header: admins may act on another user's
* data for an allowlisted set of data-plane routes. On success req.userId
* becomes the target user and actingAdminUserId records the real caller.
* Returns true when a response was already sent (request must stop).
*/
private async applyAdminImpersonation(
req: AuthenticatedRequest,
res: Response,
adminUserId: string,
): Promise<boolean> {
const rawHeader = req.headers[ADMIN_TARGET_USER_HEADER];
const targetUserId = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
if (!targetUserId || targetUserId === adminUserId) {
return false;
}
const userRepository = createCurrentUserRepository();
const admin = await userRepository.findById(adminUserId);
if (!admin?.isAdmin) {
databaseLogger.warn("Impersonation attempt by non-admin", {
operation: "admin_impersonation_denied",
userId: adminUserId,
targetUserId,
path: req.originalUrl,
});
res.status(403).json({
error: "Admin access required",
code: "IMPERSONATION_DENIED",
});
return true;
}
const path = (req.originalUrl || req.url || "").split("?")[0];
const allowed = IMPERSONATION_PATH_ALLOWLIST.some((pattern) =>
pattern.test(path),
);
if (!allowed) {
res.status(403).json({
error: "Impersonation is not allowed for this route",
code: "IMPERSONATION_NOT_ALLOWED",
});
return true;
}
const target = await userRepository.findById(targetUserId);
if (!target) {
res.status(404).json({
error: "Target user not found",
code: "TARGET_USER_NOT_FOUND",
});
return true;
}
if (!DataCrypto.canUserAccessData(targetUserId)) {
res.status(423).json({
error: "Target user's data stays locked until their next login",
code: "TARGET_DATA_LOCKED",
});
return true;
}
req.userId = targetUserId;
req.actingAdminUserId = adminUserId;
const { ipAddress, userAgent } = getRequestMeta(req);
void logAudit({
userId: adminUserId,
username: admin.username,
action: "admin_impersonated_request",
resourceType: "user",
resourceId: targetUserId,
resourceName: target.username,
details: JSON.stringify({ method: req.method, path }),
ipAddress,
userAgent,
success: true,
});
return false;
}
createDataAccessMiddleware() { createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const authReq = req as AuthenticatedRequest; const authReq = req as AuthenticatedRequest;
+1
View File
@@ -36,6 +36,7 @@ export function createCorsMiddleware(
"User-Agent", "User-Agent",
"X-Electron-App", "X-Electron-App",
"Cache-Control", "Cache-Control",
"x-admin-target-user",
...extraHeaders, ...extraHeaders,
]; ];
+20
View File
@@ -37,6 +37,7 @@ interface HostAccessInfo {
hasAccess: boolean; hasAccess: boolean;
isOwner: boolean; isOwner: boolean;
isShared: boolean; isShared: boolean;
isAdminBypass?: boolean;
permissionLevel?: SharePermissionLevel; permissionLevel?: SharePermissionLevel;
expiresAt?: string | null; expiresAt?: string | null;
} }
@@ -209,6 +210,9 @@ class PermissionManager {
action === "delete" || action === "delete" ||
LEVEL_RANK[grantedLevel] < LEVEL_RANK[action] LEVEL_RANK[grantedLevel] < LEVEL_RANK[action]
) { ) {
if (await this.isAdmin(userId)) {
return this.adminBypassAccess();
}
return { return {
hasAccess: false, hasAccess: false,
isOwner: false, isOwner: false,
@@ -240,6 +244,10 @@ class PermissionManager {
}; };
} }
if (await this.isAdmin(userId)) {
return this.adminBypassAccess();
}
return { return {
hasAccess: false, hasAccess: false,
isOwner: false, isOwner: false,
@@ -260,6 +268,18 @@ class PermissionManager {
} }
} }
// Admins get owner-equivalent access to every host; each connect is
// audit-logged in the host resolver.
private adminBypassAccess(): HostAccessInfo {
return {
hasAccess: true,
isOwner: false,
isShared: false,
isAdminBypass: true,
permissionLevel: "manage",
};
}
async isAdmin(userId: string): Promise<boolean> { async isAdmin(userId: string): Promise<boolean> {
try { try {
const user = await createCurrentUserRepository().findById(userId); const user = await createCurrentUserRepository().findById(userId);
+1
View File
@@ -971,6 +971,7 @@ export interface AuthenticatedRequest extends Request {
userId: string; userId: string;
sessionId?: string; sessionId?: string;
apiKeyId?: string; apiKeyId?: string;
actingAdminUserId?: string;
user?: { user?: {
id: string; id: string;
username: string; username: string;
+10 -4
View File
@@ -1691,8 +1691,14 @@ export function AppShell({
)} )}
{railView === "admin-settings" && isAdmin && ( {railView === "admin-settings" && isAdmin && (
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel /> <AdminSettingsPanel
onEditingChange={setSidebarEditing}
onOpenHostTab={(host) => {
connectHost(host);
if (isMobile) setSidebarOpen(false);
}}
/>
</div> </div>
)} )}
@@ -1755,7 +1761,7 @@ export function AppShell({
{/* Desktop: inline resizable sidebar */} {/* Desktop: inline resizable sidebar */}
{!isMobile && ( {!isMobile && (
<div <div
className={`relative flex flex-col bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`} className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
style={{ style={{
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0, width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
transition: sidebarDragging ? "none" : "width 0.2s", transition: sidebarDragging ? "none" : "width 0.2s",
@@ -1779,7 +1785,7 @@ export function AppShell({
<SheetContent <SheetContent
side="left" side="left"
showCloseButton={false} showCloseButton={false}
className="p-0 flex flex-col w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0" className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
style={{ height: "100dvh" }} style={{ height: "100dvh" }}
> >
{sidebarHeader} {sidebarHeader}
+241
View File
@@ -0,0 +1,241 @@
import { authApi, handleApiError, sshHostApi } from "@/main-axios";
import type { SSHHost, SSHHostData } from "@/types/index";
// ADMIN USER DATA MANAGEMENT
// ============================================================================
// Wrappers over the regular data-plane endpoints that act on another user's
// data via the X-Admin-Target-User header (admin only, audited server-side).
// They intentionally bypass the host request caches: the data belongs to the
// target user, not the signed-in admin.
const ADMIN_TARGET_USER_HEADER = "X-Admin-Target-User";
function adminHeaders(targetUserId: string): Record<string, string> {
return { [ADMIN_TARGET_USER_HEADER]: targetUserId };
}
export async function adminGetUserHosts(
targetUserId: string,
): Promise<SSHHost[]> {
try {
const response = await sshHostApi.get("/db/host", {
headers: adminHeaders(targetUserId),
});
return Array.isArray(response.data) ? response.data : [];
} catch (error) {
throw handleApiError(error, "fetch user's hosts");
}
}
export async function adminCreateUserHost(
targetUserId: string,
hostData: SSHHostData,
): Promise<SSHHost> {
try {
if (hostData.authType === "key" && hostData.key instanceof File) {
const formData = new FormData();
formData.append("key", hostData.key);
const dataWithoutFile = { ...hostData, key: undefined };
formData.append("data", JSON.stringify(dataWithoutFile));
const response = await sshHostApi.post("/db/host", formData, {
headers: {
"Content-Type": "multipart/form-data",
...adminHeaders(targetUserId),
},
});
return response.data;
}
const response = await sshHostApi.post("/db/host", hostData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "create host for user");
}
}
export async function adminUpdateUserHost(
targetUserId: string,
hostId: number,
hostData: SSHHostData,
): Promise<SSHHost> {
try {
if (hostData.authType === "key" && hostData.key instanceof File) {
const formData = new FormData();
formData.append("key", hostData.key);
const dataWithoutFile = { ...hostData, key: undefined };
formData.append("data", JSON.stringify(dataWithoutFile));
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
...adminHeaders(targetUserId),
},
});
return response.data;
}
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "update user's host");
}
}
export async function adminDeleteUserHost(
targetUserId: string,
hostId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete(`/db/host/${hostId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete user's host");
}
}
export async function adminGetHostPassword(
targetUserId: string,
hostId: number,
field: "password" | "sudoPassword" | "vncPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
`/db/host/${hostId}/password?field=${field}`,
{ headers: adminHeaders(targetUserId) },
);
return response.data?.value || null;
} catch {
return null;
}
}
export async function adminGetUserCredentials(
targetUserId: string,
): Promise<Record<string, unknown>[] | Record<string, unknown>> {
try {
const response = await authApi.get("/credentials", {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user's credentials");
}
}
export async function adminGetUserCredentialDetails(
targetUserId: string,
credentialId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get(`/credentials/${credentialId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user's credential details");
}
}
export async function adminCreateUserCredential(
targetUserId: string,
credentialData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/credentials", credentialData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "create credential for user");
}
}
export async function adminUpdateUserCredential(
targetUserId: string,
credentialId: number,
credentialData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(
`/credentials/${credentialId}`,
credentialData,
{ headers: adminHeaders(targetUserId) },
);
return response.data;
} catch (error) {
throw handleApiError(error, "update user's credential");
}
}
export async function adminDeleteUserCredential(
targetUserId: string,
credentialId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/credentials/${credentialId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete user's credential");
}
}
export async function adminGetUserSnippets(
targetUserId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets", {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user's snippets");
}
}
export async function adminCreateUserSnippet(
targetUserId: string,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/snippets", snippetData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet for user");
}
}
export async function adminUpdateUserSnippet(
targetUserId: string,
snippetId: number,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(`/snippets/${snippetId}`, snippetData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "update user's snippet");
}
}
export async function adminDeleteUserSnippet(
targetUserId: string,
snippetId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/snippets/${snippetId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete user's snippet");
}
}
+42
View File
@@ -161,6 +161,48 @@ export async function deleteAccount(
} }
} }
// Raw axios errors propagate here so callers can detect the 409
// DATA_WIPE_REQUIRED code and re-submit with confirmDataWipe.
export async function adminResetUserPassword(
userId: string,
newPassword: string,
confirmDataWipe = false,
): Promise<{ message: string; dataWiped?: boolean }> {
const response = await authApi.post("/users/admin/reset-password", {
userId,
newPassword,
confirmDataWipe,
});
return response.data;
}
export async function adminDisableUserTotp(
userId: string,
): Promise<{ message: string }> {
try {
const response = await authApi.post("/users/admin/totp/disable", {
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "disable user TOTP");
}
}
export async function adminExportUserData(
userId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get(
`/users/admin/export/${encodeURIComponent(userId)}`,
{ timeout: 120000 },
);
return response.data;
} catch (error) {
handleApiError(error, "export user data");
}
}
export async function updateRegistrationAllowed( export async function updateRegistrationAllowed(
allowed: boolean, allowed: boolean,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
+65
View File
@@ -2838,6 +2838,71 @@
"updateAdminStatusFailed": "Failed to update admin status", "updateAdminStatusFailed": "Failed to update admin status",
"allSessionsRevoked": "All sessions revoked", "allSessionsRevoked": "All sessions revoked",
"revokeSessionsFailed": "Failed to revoke sessions", "revokeSessionsFailed": "Failed to revoke sessions",
"manageUserData": "Manage user data",
"backToUsers": "Back to users",
"manageTabAccount": "Account",
"manageTabHosts": "Hosts",
"manageTabCredentials": "Credentials",
"manageTabSnippets": "Snippets",
"manageTabSessions": "Sessions",
"manageTabDanger": "Danger",
"manageEditorBack": "Back to {{username}}'s data",
"dataLockedBadge": "LOCKED",
"dataLockedNotice": "{{username}}'s data stays locked until they next log in. Their hosts, credentials and snippets cannot be viewed or edited until then.",
"resetPasswordTitle": "Reset Password",
"resetPasswordOidcOnly": "This user signs in through an external provider and has no password.",
"resetPasswordPlaceholder": "New password",
"resetPasswordBtn": "Reset",
"resetPasswordWorking": "Resetting...",
"resetPasswordSuccess": "Password reset",
"resetPasswordSuccessWiped": "Password reset. The user's encrypted data was wiped.",
"resetPasswordFailed": "Failed to reset password",
"resetPasswordConfirmWipe": "{{username}} has not logged in since the encryption upgrade, so their data cannot be recovered. Resetting now will delete their hosts, credentials and snippets. Continue?",
"totpSectionTitle": "Two-Factor Authentication",
"totpStatusEnabled": "TOTP is enabled for this user",
"totpStatusDisabled": "TOTP is not enabled for this user",
"disableTotp": "Disable",
"disableTotpConfirm": "Disable two-factor authentication for {{username}}? They will be able to log in with only their password.",
"totpDisabledSuccess": "TOTP disabled",
"totpDisableFailed": "Failed to disable TOTP",
"manageApiKeys": "API Keys",
"apiKeyNamePlaceholder": "Key name",
"apiKeyCopyNotice": "Copy this key now, it won't be shown again.",
"noApiKeysForUser": "No API keys",
"apiKeyDeleteFailed": "Failed to delete API key",
"exportUserData": "Data Export",
"exportUserDataDesc": "Download this user's hosts, credentials and file manager data as JSON. Secrets are decrypted.",
"exportUserDataSuccess": "User data exported",
"exportUserDataFailed": "Failed to export user data",
"hostsCount": "{{count}} hosts",
"addHostForUser": "Add Host",
"noHostsForUser": "This user has no hosts",
"connectToHost": "Connect",
"deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?",
"hostDeletedSuccess": "Host deleted",
"hostDeleteFailed": "Failed to delete host",
"credentialsCount": "{{count}} credentials",
"addCredentialForUser": "Add Credential",
"noCredentialsForUser": "This user has no credentials",
"deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?",
"credentialDeletedSuccess": "Credential deleted",
"credentialDeleteFailed": "Failed to delete credential",
"snippetsCount": "{{count}} snippets",
"addSnippetForUser": "Add Snippet",
"noSnippetsForUser": "This user has no snippets",
"snippetRequiredFields": "Snippet name and content are required",
"snippetNamePlaceholder": "Snippet name",
"snippetContentPlaceholder": "Command content",
"snippetFolderPlaceholder": "Folder (optional)",
"snippetSaved": "Snippet saved",
"snippetSaveFailed": "Failed to save snippet",
"deleteSnippetConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?",
"snippetDeletedSuccess": "Snippet deleted",
"snippetDeleteFailed": "Failed to delete snippet",
"noSessionsForUser": "No active sessions",
"deleteUserDangerDesc": "Permanently delete {{username}} and all of their data (hosts, credentials, snippets, history). This cannot be undone.",
"deleteUserConfirm": "Permanently delete {{username}} and all of their data?",
"deleteUserAdminBlocked": "Remove admin status before deleting this user.",
"createRoleRequired": "Name and display name are required", "createRoleRequired": "Name and display name are required",
"createRoleSuccess": "Role \"{{name}}\" created", "createRoleSuccess": "Role \"{{name}}\" created",
"createRoleFailed": "Failed to create role", "createRoleFailed": "Failed to create role",
+24
View File
@@ -201,6 +201,7 @@ export interface UserInfo {
is_admin: boolean; is_admin: boolean;
is_oidc: boolean; is_oidc: boolean;
password_hash?: string; password_hash?: string;
data_unlocked?: boolean;
} }
interface UserCount { interface UserCount {
@@ -1953,10 +1954,33 @@ export {
disableOIDCConfig, disableOIDCConfig,
getCommandHistoryEnabled, getCommandHistoryEnabled,
updateCommandHistoryEnabled, updateCommandHistoryEnabled,
adminResetUserPassword,
adminDisableUserTotp,
adminExportUserData,
type ApiKey, type ApiKey,
type CreatedApiKey, type CreatedApiKey,
} from "@/api/user-management-api"; } from "@/api/user-management-api";
// ADMIN USER DATA MANAGEMENT
// ============================================================================
export {
adminGetUserHosts,
adminCreateUserHost,
adminUpdateUserHost,
adminDeleteUserHost,
adminGetHostPassword,
adminGetUserCredentials,
adminGetUserCredentialDetails,
adminCreateUserCredential,
adminUpdateUserCredential,
adminDeleteUserCredential,
adminGetUserSnippets,
adminCreateUserSnippet,
adminUpdateUserSnippet,
adminDeleteUserSnippet,
} from "@/api/admin-user-data-api";
export { export {
setupTOTP, setupTOTP,
enableTOTP, enableTOTP,
@@ -18,6 +18,7 @@ import {
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Settings2,
Share2, Share2,
Trash2, Trash2,
Unlink, Unlink,
@@ -32,6 +33,8 @@ export type AdminUser = {
isAdmin: boolean; isAdmin: boolean;
isOidc: boolean; isOidc: boolean;
passwordHash?: string; passwordHash?: string;
dataUnlocked?: boolean;
totpEnabled?: boolean;
}; };
export type AdminSession = { export type AdminSession = {
@@ -76,6 +79,7 @@ type UsersSectionProps = {
SetStateAction<{ id: string; username: string } | null> SetStateAction<{ id: string; username: string } | null>
>; >;
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>; setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
onManageUser: (user: AdminUser) => void;
}; };
export function AdminUsersSection({ export function AdminUsersSection({
@@ -91,6 +95,7 @@ export function AdminUsersSection({
setLinkAccountOpen, setLinkAccountOpen,
setUnlinkAccountTarget, setUnlinkAccountTarget,
setUnlinkAccountOpen, setUnlinkAccountOpen,
onManageUser,
}: UsersSectionProps) { }: UsersSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -159,6 +164,15 @@ export function AdminUsersSection({
</div> </div>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
+42 -2
View File
@@ -87,6 +87,8 @@ import {
AdminLinkAccountDialog, AdminLinkAccountDialog,
AdminUnlinkAccountDialog, AdminUnlinkAccountDialog,
} from "./AdminUserDialogs"; } from "./AdminUserDialogs";
import { AdminUserManagePanel } from "./AdminUserManagePanel";
import type { Host } from "@/types/ui-types";
type ApiErrorLike = { type ApiErrorLike = {
response?: { response?: {
@@ -100,11 +102,18 @@ function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback; 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 { t } = useTranslation();
const [openSection, setOpenSection] = useState<AdminSection | null>( const [openSection, setOpenSection] = useState<AdminSection | null>(
"general", "general",
); );
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true); const [allowRegistration, setAllowRegistration] = useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true); const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
const [allowPasswordReset, setAllowPasswordReset] = useState(true); const [allowPasswordReset, setAllowPasswordReset] = useState(true);
@@ -206,6 +215,11 @@ export function AdminSettingsPanel() {
loadSSOProviders(); loadSSOProviders();
}, []); }, []);
useEffect(() => {
onEditingChange?.(manageUser !== null);
return () => onEditingChange?.(false);
}, [manageUser, onEditingChange]);
useEffect(() => { useEffect(() => {
if (editUserOpen && editUserTarget) { if (editUserOpen && editUserTarget) {
setEditUserRoles([]); setEditUserRoles([]);
@@ -227,6 +241,8 @@ export function AdminSettingsPanel() {
isAdmin: user.is_admin, isAdmin: user.is_admin,
isOidc: user.is_oidc, isOidc: user.is_oidc,
passwordHash: user.password_hash, 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 ( 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 <AdminGeneralSettingsSection
open={openSection === "general"} open={openSection === "general"}
onToggle={() => toggle("general")} onToggle={() => toggle("general")}
@@ -881,6 +920,7 @@ export function AdminSettingsPanel() {
setLinkAccountOpen={setLinkAccountOpen} setLinkAccountOpen={setLinkAccountOpen}
setUnlinkAccountTarget={setUnlinkAccountTarget} setUnlinkAccountTarget={setUnlinkAccountTarget}
setUnlinkAccountOpen={setUnlinkAccountOpen} setUnlinkAccountOpen={setUnlinkAccountOpen}
onManageUser={setManageUser}
/> />
<AdminSessionsSection <AdminSessionsSection
+1 -1
View File
@@ -34,7 +34,7 @@ export function AccordionSection({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="border border-border bg-card overflow-hidden"> <div className="border border-border bg-card overflow-hidden shrink-0">
<button <button
onClick={onToggle} onClick={onToggle}
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors" 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 = const btnBase =
"relative flex items-center gap-2.5 h-7 rounded shrink-0 transition-colors"; "relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
const btnStyle = { margin: "0 4px", padding: "0 6px" }; const btnStyle = { margin: "0 4px", padding: "0 8px" };
export function AppRail({ export function AppRail({
railView, railView,
@@ -279,8 +279,8 @@ export function AppRail({
{item.icon} {item.icon}
</span> </span>
<span <span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${ className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0" railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`} }`}
> >
{item.title} {item.title}
@@ -304,8 +304,8 @@ export function AppRail({
{item.icon} {item.icon}
</span> </span>
<span <span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${ className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0" railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`} }`}
> >
{item.title} {item.title}
@@ -362,7 +362,7 @@ export function AppRail({
)} )}
</span> </span>
<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} {item.title}
</span> </span>
@@ -381,7 +381,7 @@ export function AppRail({
<LogOut size={16} /> <LogOut size={16} />
</span> </span>
<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")} {t("common.logout")}
</span> </span>
@@ -391,7 +391,7 @@ export function AppRail({
<div className="shrink-0 border-t border-border"> <div className="shrink-0 border-t border-border">
<button <button
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors" 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 <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" 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, generateKeyPair,
generatePublicKeyFromPrivate, generatePublicKeyFromPrivate,
updateCredential, updateCredential,
adminCreateUserCredential,
adminUpdateUserCredential,
} from "@/main-axios"; } from "@/main-axios";
import type { Credential } from "@/types/ui-types"; import type { Credential } from "@/types/ui-types";
@@ -23,11 +25,15 @@ export function CredentialEditorView({
activeTab, activeTab,
onBack, onBack,
onSave, onSave,
adminTargetUserId,
}: { }: {
credential: Credential | null; credential: Credential | null;
activeTab: string; activeTab: string;
onBack: () => void; onBack: () => void;
onSave: (saved: Record<string, unknown>) => 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(() => ({ const [credForm, setCredForm] = useState(() => ({
name: credential?.name ?? "", name: credential?.name ?? "",
@@ -92,15 +98,28 @@ export function CredentialEditorView({
: credForm.passphrase || null : credForm.passphrase || null
: null, : null,
}; };
const saved = credential let saved: Record<string, unknown>;
? await updateCredential(Number(credential.id), data) if (adminTargetUserId) {
: await createCredential(data); 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( toast.success(
credential credential
? t("hosts.credentialUpdated") ? t("hosts.credentialUpdated")
: t("hosts.credentialCreated"), : t("hosts.credentialCreated"),
); );
window.dispatchEvent(new CustomEvent("termix:credentials-changed")); if (!adminTargetUserId) {
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
}
onSave(saved); onSave(saved);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : null; const msg = err instanceof Error ? err.message : null;
+28 -7
View File
@@ -35,6 +35,10 @@ import {
getUserInfo, getUserInfo,
getVaultProfiles, getVaultProfiles,
getHostPassword, getHostPassword,
adminCreateUserHost,
adminUpdateUserHost,
adminGetHostPassword,
adminGetUserSnippets,
} from "@/main-axios"; } from "@/main-axios";
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api"; import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
import type { Host, VaultProfile } from "@/types/ui-types"; import type { Host, VaultProfile } from "@/types/ui-types";
@@ -77,6 +81,7 @@ export function HostEditor({
onTabChange, onTabChange,
hosts, hosts,
credentials, credentials,
adminTargetUserId,
}: { }: {
host: Host | null; host: Host | null;
activeTab: string; activeTab: string;
@@ -87,6 +92,9 @@ export function HostEditor({
onTabChange: (tab: string) => void; onTabChange: (tab: string) => void;
hosts: Host[]; hosts: Host[];
credentials: { id: string; name: string; username: string }[]; 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 { t } = useTranslation();
const { setPreviewTerminalTheme } = useTabsSafe(); const { setPreviewTerminalTheme } = useTabsSafe();
@@ -133,11 +141,14 @@ export function HostEditor({
}, []); }, []);
useEffect(() => { useEffect(() => {
getSnippets() const loadSnippets = adminTargetUserId
? adminGetUserSnippets(adminTargetUserId)
: getSnippets();
loadSnippets
.then((res) => setSnippets(mapSnippetResponse(res))) .then((res) => setSnippets(mapSnippetResponse(res)))
.catch(() => {}); .catch(() => {});
reloadVaultProfiles(); reloadVaultProfiles();
}, []); }, [adminTargetUserId]);
useEffect(() => { useEffect(() => {
if (host) return; if (host) return;
@@ -150,7 +161,10 @@ export function HostEditor({
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return; if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
let cancelled = false; 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; if (cancelled || !password) return;
setForm((prev) => setForm((prev) =>
prev.vncPassword ? prev : { ...prev, vncPassword: password }, prev.vncPassword ? prev : { ...prev, vncPassword: password },
@@ -160,7 +174,7 @@ export function HostEditor({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [form.vncAuthType, form.vncPassword, host?.id]); }, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]);
useEffect(() => { useEffect(() => {
if (activeTab !== "tunnels") return; if (activeTab !== "tunnels") return;
@@ -184,9 +198,16 @@ export function HostEditor({
setSaving(true); setSaving(true);
try { try {
const data = buildHostEditorPayload(form, protocols); const data = buildHostEditorPayload(form, protocols);
const saved = host let saved: SSHHost;
? await updateSSHHost(Number(host.id), data) if (adminTargetUserId) {
: await createSSHHost(data); 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")); toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
setPreviewTerminalTheme(null); setPreviewTerminalTheme(null);
onSave(saved); onSave(saved);
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { mapAlertFiring } from "./alerts-api"; import { mapAlertFiring } from "../../api/alerts-api";
describe("mapAlertFiring", () => { describe("mapAlertFiring", () => {
it("normalizes sqlite snake_case rows for the alerts UI", () => { it("normalizes sqlite snake_case rows for the alerts UI", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { mapAuditLog } from "./audit-log-api"; import { mapAuditLog } from "../../api/audit-log-api";
describe("mapAuditLog", () => { describe("mapAuditLog", () => {
it("normalizes sqlite snake_case rows for the audit log UI", () => { it("normalizes sqlite snake_case rows for the audit log UI", () => {
@@ -10,8 +10,8 @@ import {
setHeight, setHeight,
heightToRowSpan, heightToRowSpan,
MIN_TILE_HEIGHT, MIN_TILE_HEIGHT,
} from "./layout-utils"; } from "../../../components/card-grid/layout-utils";
import type { GridSlot } from "./types"; import type { GridSlot } from "../../../components/card-grid/types";
function slot(id: string, order: number, colSpan: 1 | 2 | 3 = 1): GridSlot { function slot(id: string, order: number, colSpan: 1 | 2 | 3 = 1): GridSlot {
return { id, order, colSpan, height: null }; return { id, order, colSpan, height: null };
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { sparklineGeometry, gaugeArc, SPARKLINE_VIEW_W } from "./geometry"; import {
sparklineGeometry,
gaugeArc,
SPARKLINE_VIEW_W,
} from "../../../components/charts/geometry";
describe("sparklineGeometry", () => { describe("sparklineGeometry", () => {
it("returns no data for fewer than 2 points", () => { it("returns no data for fewer than 2 points", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth"; import { resolveProxmoxImportAuth } from "../../../components/proxmox/proxmox-import-auth";
describe("resolveProxmoxImportAuth", () => { describe("resolveProxmoxImportAuth", () => {
it("uses credential auth when a credential is available", () => { it("uses credential auth when a credential is available", () => {
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { formatFileSize } from "./file-manager-utils.js"; import { formatFileSize } from "../../../features/file-manager/file-manager-utils.js";
describe("formatFileSize", () => { describe("formatFileSize", () => {
it("returns a dash for undefined or null", () => { it("returns a dash for undefined or null", () => {
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { useFileSelection } from "./useFileSelection.js"; import { useFileSelection } from "../../../../features/file-manager/hooks/useFileSelection.js";
type FileItem = { type FileItem = {
name: string; name: string;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js"; import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
const httpsLocation = { const httpsLocation = {
protocol: "https:", protocol: "https:",
@@ -4,7 +4,7 @@ import {
isPasteShortcut, isPasteShortcut,
pasteTextToRemote, pasteTextToRemote,
type GuacamoleClipboardClient, type GuacamoleClipboardClient,
} from "./guacamole-clipboard.js"; } from "../../../features/guacamole/guacamole-clipboard.js";
describe("Guacamole Firefox clipboard fallback", () => { describe("Guacamole Firefox clipboard fallback", () => {
it("only enables the native paste path for Firefox", () => { it("only enables the native paste path for Firefox", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getGuacamoleDisplaySize } from "./guacamole-display-size"; import { getGuacamoleDisplaySize } from "../../../features/guacamole/guacamole-display-size";
describe("getGuacamoleDisplaySize", () => { describe("getGuacamoleDisplaySize", () => {
it("requests native pixels and matching DPI for HiDPI RDP", () => { it("requests native pixels and matching DPI for HiDPI RDP", () => {
@@ -3,7 +3,7 @@ import {
screenToCanvas, screenToCanvas,
canvasToScreen, canvasToScreen,
zoomAroundPoint, zoomAroundPoint,
} from "./canvasGeometry"; } from "../../../../features/homepage/canvas/canvasGeometry";
const PAN = { x: 100, y: 200 }; const PAN = { x: 100, y: 200 };
const ZOOM = 2; const ZOOM = 2;
@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { snapToGrid, snapToGridFloor } from "./snapToGrid"; import {
snapToGrid,
snapToGridFloor,
} from "../../../../features/homepage/canvas/snapToGrid";
import { GRID_SIZE } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types";
describe("snapToGrid", () => { describe("snapToGrid", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseAsciicast } from "./asciicast"; import { parseAsciicast } from "../../../features/session-recording/asciicast";
describe("parseAsciicast", () => { describe("parseAsciicast", () => {
it("parses terminal dimensions and timed input/output", () => { it("parses terminal dimensions and timed input/output", () => {
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
getNextTerminalFontSize, getNextTerminalFontSize,
getTerminalFontZoomDirection, getTerminalFontZoomDirection,
} from "./terminal-font-zoom"; } from "../../../features/terminal/terminal-font-zoom";
function keyEvent( function keyEvent(
overrides: Partial<KeyboardEvent>, overrides: Partial<KeyboardEvent>,
@@ -3,7 +3,7 @@ import {
getTunnelTypeForMode, getTunnelTypeForMode,
getTunnelPortLabels, getTunnelPortLabels,
getTunnelModeDescription, getTunnelModeDescription,
} from "./tunnel-form-utils.js"; } from "../../../features/tunnel/tunnel-form-utils.js";
// A fake translate that echoes the key plus any interpolation args so we can // A fake translate that echoes the key plus any interpolation args so we can
// assert which translation key + payload the helpers chose. // assert which translation key + payload the helpers chose.
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react"; import { renderHook } from "@testing-library/react";
import { useIsMobile } from "./use-mobile.js"; import { useIsMobile } from "../../hooks/use-mobile.js";
function setViewport(width: number) { function setViewport(width: number) {
Object.defineProperty(window, "innerWidth", { Object.defineProperty(window, "innerWidth", {
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach } from "vitest"; import { describe, expect, it, beforeEach } from "vitest";
import { changeAppLanguage, normalizeLanguageCode } from "./i18n"; import { changeAppLanguage, normalizeLanguageCode } from "../../i18n/i18n";
describe("i18n language handling", () => { describe("i18n language handling", () => {
beforeEach(() => { beforeEach(() => {
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from "vitest"; import { describe, it, expect, afterEach } from "vitest";
import { getBasePath } from "./base-path.js"; import { getBasePath } from "../../lib/base-path.js";
const win = window as unknown as Record<string, unknown>; const win = window as unknown as Record<string, unknown>;
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { copyToClipboard } from "./clipboard"; import { copyToClipboard } from "../../lib/clipboard";
describe("copyToClipboard", () => { describe("copyToClipboard", () => {
const originalClipboard = navigator.clipboard; const originalClipboard = navigator.clipboard;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getDatabaseTransferUrl } from "./database-transfer-url"; import { getDatabaseTransferUrl } from "../../lib/database-transfer-url";
const productionLocation = { const productionLocation = {
protocol: "https:", protocol: "https:",
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { installElectronWheelZoomGuard } from "./electron-wheel-zoom"; import { installElectronWheelZoomGuard } from "../../lib/electron-wheel-zoom";
const win = window as unknown as Record<string, unknown>; const win = window as unknown as Record<string, unknown>;
let cleanup: (() => void) | undefined; let cleanup: (() => void) | undefined;
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from "vitest"; import { describe, it, expect, afterEach } from "vitest";
import { isElectron } from "./electron.js"; import { isElectron } from "../../lib/electron.js";
const win = window as unknown as Record<string, unknown>; const win = window as unknown as Record<string, unknown>;
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { apiLogger } from "./frontend-logger.js"; import { apiLogger } from "../../lib/frontend-logger.js";
// The formatting helpers (status/performance icons, URL sanitization) are // The formatting helpers (status/performance icons, URL sanitization) are
// private, so we exercise them through the public request* methods and assert // private, so we exercise them through the public request* methods and assert
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ServerStatusStore } from "./server-status-store"; import { ServerStatusStore } from "../../lib/server-status-store";
describe("ServerStatusStore", () => { describe("ServerStatusStore", () => {
it("notifies only listeners for hosts whose status value changed", () => { it("notifies only listeners for hosts whose status value changed", () => {
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { highlightTerminalOutput } from "./terminal-syntax-highlighter.js"; import { highlightTerminalOutput } from "../../lib/terminal-syntax-highlighter.js";
const ESC = "\x1b"; const ESC = "\x1b";
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createTtlRequestCache } from "./ttl-request-cache"; import { createTtlRequestCache } from "../../lib/ttl-request-cache";
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { cn } from "./utils.js"; import { cn } from "../../lib/utils.js";
describe("cn", () => { describe("cn", () => {
it("joins class names", () => { it("joins class names", () => {
@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AdminUserManagePanel } from "../../sidebar/AdminUserManagePanel";
import type { AdminUser } from "../../sidebar/AdminManagementSections";
const api = vi.hoisted(() => ({
adminGetUserHosts: vi.fn(async () => [] as unknown[]),
adminDeleteUserHost: vi.fn(async () => ({})),
adminGetUserCredentials: vi.fn(async () => [] as unknown[]),
adminDeleteUserCredential: vi.fn(async () => ({})),
adminGetUserSnippets: vi.fn(async () => [] as unknown[]),
adminCreateUserSnippet: vi.fn(async () => ({})),
adminUpdateUserSnippet: vi.fn(async () => ({})),
adminDeleteUserSnippet: vi.fn(async () => ({})),
adminResetUserPassword: vi.fn(async () => ({ dataWiped: false })),
adminDisableUserTotp: vi.fn(async () => ({})),
adminExportUserData: vi.fn(async () => ({})),
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
revokeSession: vi.fn(async () => ({})),
revokeAllUserSessions: vi.fn(async () => ({})),
getApiKeys: vi.fn(async () => ({ apiKeys: [] as unknown[] })),
createApiKey: vi.fn(async () => ({})),
deleteApiKey: vi.fn(async () => ({})),
deleteUser: vi.fn(async () => ({})),
getUserRoles: vi.fn(async () => ({ roles: [] as unknown[] })),
assignRoleToUser: vi.fn(async () => ({})),
removeRoleFromUser: vi.fn(async () => ({})),
}));
vi.mock("@/main-axios", () => api);
vi.mock("../../sidebar/HostEditor", () => ({
HostEditor: () => <div data-testid="host-editor" />,
}));
vi.mock("../../sidebar/CredentialEditorView", () => ({
CredentialEditorView: () => <div data-testid="credential-editor" />,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
function makeUser(overrides: Partial<AdminUser> = {}): AdminUser {
return {
id: "u2",
username: "bob",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
dataUnlocked: true,
totpEnabled: false,
...overrides,
};
}
function renderPanel(
user: AdminUser,
callbacks: Partial<Record<string, () => void>> = {},
) {
return render(
<AdminUserManagePanel
user={user}
roles={[]}
onBack={callbacks.onBack ?? vi.fn()}
onOpenHostTab={callbacks.onOpenHostTab as never}
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
onTotpDisabled={callbacks.onTotpDisabled ?? vi.fn()}
/>,
);
}
beforeEach(() => {
for (const fn of Object.values(api)) fn.mockClear();
});
describe("AdminUserManagePanel", () => {
it("renders all manage tabs and loads the target user's data", async () => {
renderPanel(makeUser());
for (const tab of [
"admin.manageTabAccount",
"admin.manageTabHosts",
"admin.manageTabCredentials",
"admin.manageTabSnippets",
"admin.manageTabSessions",
"admin.manageTabDanger",
]) {
expect(screen.getByText(tab)).toBeTruthy();
}
await waitFor(() => {
expect(api.adminGetUserHosts).toHaveBeenCalledWith("u2");
expect(api.adminGetUserCredentials).toHaveBeenCalledWith("u2");
expect(api.adminGetUserSnippets).toHaveBeenCalledWith("u2");
expect(api.getUserRoles).toHaveBeenCalledWith("u2");
});
});
it("skips data fetches and shows the locked notice when data is locked", async () => {
renderPanel(makeUser({ dataUnlocked: false }));
expect(screen.getByText("admin.dataLockedBadge")).toBeTruthy();
await userEvent.click(screen.getByText("admin.manageTabHosts"));
expect(screen.getByText("admin.dataLockedNotice")).toBeTruthy();
expect(screen.queryByText("admin.addHostForUser")).toBeNull();
expect(api.adminGetUserHosts).not.toHaveBeenCalled();
expect(api.adminGetUserCredentials).not.toHaveBeenCalled();
expect(api.adminGetUserSnippets).not.toHaveBeenCalled();
});
it("lists the user's hosts and opens a tab via the connect button", async () => {
api.adminGetUserHosts.mockResolvedValueOnce([
{
id: 7,
name: "web-01",
username: "root",
ip: "10.0.0.5",
port: 22,
authType: "password",
connectionType: "ssh",
},
]);
const onOpenHostTab = vi.fn();
renderPanel(makeUser(), { onOpenHostTab });
await userEvent.click(screen.getByText("admin.manageTabHosts"));
await screen.findByText("web-01");
await userEvent.click(screen.getByTitle("admin.connectToHost"));
expect(onOpenHostTab).toHaveBeenCalledTimes(1);
expect(onOpenHostTab.mock.calls[0][0]).toMatchObject({
id: "7",
ip: "10.0.0.5",
});
});
it("disables TOTP through the confirm dialog", async () => {
const onTotpDisabled = vi.fn();
renderPanel(makeUser({ totpEnabled: true }), { onTotpDisabled });
expect(screen.getByText("admin.totpStatusEnabled")).toBeTruthy();
await userEvent.click(screen.getByText("admin.disableTotp"));
await userEvent.click(screen.getByText("common.confirm"));
await waitFor(() => {
expect(api.adminDisableUserTotp).toHaveBeenCalledWith("u2");
expect(onTotpDisabled).toHaveBeenCalled();
});
expect(screen.getByText("admin.totpStatusDisabled")).toBeTruthy();
});
it("creates a snippet for the target user", async () => {
renderPanel(makeUser());
await userEvent.click(screen.getByText("admin.manageTabSnippets"));
await userEvent.click(screen.getByText("admin.addSnippetForUser"));
await userEvent.type(
screen.getByPlaceholderText("admin.snippetNamePlaceholder"),
"restart",
);
await userEvent.type(
screen.getByPlaceholderText("admin.snippetContentPlaceholder"),
"systemctl restart nginx",
);
await userEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(api.adminCreateUserSnippet).toHaveBeenCalledWith("u2", {
name: "restart",
content: "systemctl restart nginx",
folder: null,
});
});
});
it("deletes the user from the danger tab after confirmation", async () => {
const onUserDeleted = vi.fn();
renderPanel(makeUser(), { onUserDeleted });
await userEvent.click(screen.getByText("admin.manageTabDanger"));
await userEvent.click(screen.getByText("admin.deleteUser"));
await userEvent.click(screen.getByText("common.confirm"));
await waitFor(() => {
expect(api.deleteUser).toHaveBeenCalledWith("bob");
expect(onUserDeleted).toHaveBeenCalled();
});
});
it("blocks deleting admin accounts", async () => {
renderPanel(makeUser({ isAdmin: true }));
await userEvent.click(screen.getByText("admin.manageTabDanger"));
const deleteBtn = screen
.getByText("admin.deleteUser")
.closest("button") as HTMLButtonElement;
expect(deleteBtn.disabled).toBe(true);
expect(screen.getByText("admin.deleteUserAdminBlocked")).toBeTruthy();
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { normalizePath, splitPath } from "./FolderPathPicker.js"; import { normalizePath, splitPath } from "../../sidebar/FolderPathPicker.js";
describe("splitPath", () => { describe("splitPath", () => {
it("splits on canonical separator", () => { it("splits on canonical separator", () => {
@@ -3,7 +3,7 @@ import {
createHostEditorForm, createHostEditorForm,
buildHostEditorPayload, buildHostEditorPayload,
type HostProtocols, type HostProtocols,
} from "./HostEditorData"; } from "../../sidebar/HostEditorData";
const sshOnly: HostProtocols = { const sshOnly: HostProtocols = {
enableSsh: true, enableSsh: true,
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Host, HostFolder } from "@/types/ui-types"; import type { Host, HostFolder } from "@/types/ui-types";
import { resolveHostSortPreferences, sortHostTree } from "./host-sort"; import {
resolveHostSortPreferences,
sortHostTree,
} from "../../sidebar/host-sort";
function host(name: string, pin = false): Host { function host(name: string, pin = false): Host {
return { return {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
createQuickConnectHost, createQuickConnectHost,
quickConnectHostToPayload, quickConnectHostToPayload,
} from "./quick-connect-host"; } from "../../sidebar/quick-connect-host";
describe("quick connect host", () => { describe("quick connect host", () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());