mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
feat: refactor rbac/sharing to support new permissions and auth types
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentUserRepository,
|
||||
@@ -51,7 +52,11 @@ export function registerUserAdminRoutes(
|
||||
*/
|
||||
router.get("/list", authenticateJWT, async (req, res) => {
|
||||
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({
|
||||
users: allUsers.map((u) => ({
|
||||
@@ -60,6 +65,14 @@ export function registerUserAdminRoutes(
|
||||
is_admin: u.isAdmin,
|
||||
is_oidc: u.isOidc,
|
||||
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) {
|
||||
@@ -606,4 +619,176 @@ export function registerUserAdminRoutes(
|
||||
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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { logAudit } from "../utils/audit-logger.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
@@ -39,7 +41,28 @@ export async function resolveHostById(
|
||||
|
||||
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.
|
||||
host.sudoPassword = null;
|
||||
host.autostartPassword = null;
|
||||
@@ -91,7 +114,7 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
|
||||
if (userId !== ownerId) {
|
||||
if (!ownerEquivalent) {
|
||||
const resolved = await resolveSharedSshSecrets(
|
||||
host,
|
||||
hostId,
|
||||
@@ -133,7 +156,7 @@ export async function resolveHostById(
|
||||
|
||||
host.username = await expandOidcUsername(
|
||||
host.username as string | undefined,
|
||||
userId,
|
||||
ownerEquivalent ? ownerId : userId,
|
||||
);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const state = vi.hoisted(() => ({
|
||||
host: null as Record<string, unknown> | null,
|
||||
hasAccess: true,
|
||||
isAdminBypass: false,
|
||||
overrideCredentialId: null as number | null,
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -19,12 +21,24 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentVaultProfileRepository: () => ({
|
||||
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", () => ({
|
||||
PermissionManager: {
|
||||
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(() => {
|
||||
state.host = baseHost();
|
||||
state.hasAccess = true;
|
||||
state.isAdminBypass = false;
|
||||
state.overrideCredentialId = null;
|
||||
state.credentials.clear();
|
||||
state.sharedSecret = null;
|
||||
state.auditCalls = [];
|
||||
});
|
||||
|
||||
describe("resolveHostById", () => {
|
||||
@@ -176,4 +192,53 @@ describe("resolveHostById", () => {
|
||||
const host = await resolveHostById(42, "recipient");
|
||||
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;
|
||||
} | null,
|
||||
touched: [] as number[],
|
||||
adminIds: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -44,7 +45,8 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
userHasAnyRoleName: async () => false,
|
||||
}),
|
||||
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.grant = null;
|
||||
accessState.touched = [];
|
||||
accessState.adminIds = new Set();
|
||||
});
|
||||
|
||||
it("grants the owner every action including delete", async () => {
|
||||
@@ -165,4 +168,30 @@ describe("PermissionManager.canAccessHost level hierarchy", () => {
|
||||
await manager.canAccessHost("recipient", 42, "connect");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger, authLogger } from "./logger.js";
|
||||
import { logAudit, getRequestMeta } from "./audit-logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -57,6 +58,7 @@ interface AuthenticatedRequest extends Request {
|
||||
apiKeyId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
dataKey?: Buffer;
|
||||
actingAdminUserId?: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
private static instance: AuthManager;
|
||||
private systemCrypto: SystemCrypto;
|
||||
@@ -593,6 +605,13 @@ class AuthManager {
|
||||
token: string,
|
||||
requireAdmin = false,
|
||||
): 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 {
|
||||
const tokenPrefix = token.substring(0, 12);
|
||||
const apiKeyRepository = createCurrentApiKeyRepository();
|
||||
@@ -763,10 +782,103 @@ class AuthManager {
|
||||
authReq.userId = payload.userId;
|
||||
authReq.sessionId = payload.sessionId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
|
||||
if (authReq.headers[ADMIN_TARGET_USER_HEADER]) {
|
||||
const handled = await this.applyAdminImpersonation(
|
||||
authReq,
|
||||
res,
|
||||
payload.userId,
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
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() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
||||
@@ -36,6 +36,7 @@ export function createCorsMiddleware(
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
"Cache-Control",
|
||||
"x-admin-target-user",
|
||||
...extraHeaders,
|
||||
];
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ interface HostAccessInfo {
|
||||
hasAccess: boolean;
|
||||
isOwner: boolean;
|
||||
isShared: boolean;
|
||||
isAdminBypass?: boolean;
|
||||
permissionLevel?: SharePermissionLevel;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
@@ -209,6 +210,9 @@ class PermissionManager {
|
||||
action === "delete" ||
|
||||
LEVEL_RANK[grantedLevel] < LEVEL_RANK[action]
|
||||
) {
|
||||
if (await this.isAdmin(userId)) {
|
||||
return this.adminBypassAccess();
|
||||
}
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
@@ -240,6 +244,10 @@ class PermissionManager {
|
||||
};
|
||||
}
|
||||
|
||||
if (await this.isAdmin(userId)) {
|
||||
return this.adminBypassAccess();
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: 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> {
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
|
||||
@@ -971,6 +971,7 @@ export interface AuthenticatedRequest extends Request {
|
||||
userId: string;
|
||||
sessionId?: string;
|
||||
apiKeyId?: string;
|
||||
actingAdminUserId?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
|
||||
+10
-4
@@ -1691,8 +1691,14 @@ export function AppShell({
|
||||
)}
|
||||
|
||||
{railView === "admin-settings" && isAdmin && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminSettingsPanel />
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminSettingsPanel
|
||||
onEditingChange={setSidebarEditing}
|
||||
onOpenHostTab={(host) => {
|
||||
connectHost(host);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1755,7 +1761,7 @@ export function AppShell({
|
||||
{/* Desktop: inline resizable sidebar */}
|
||||
{!isMobile && (
|
||||
<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={{
|
||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||
@@ -1779,7 +1785,7 @@ export function AppShell({
|
||||
<SheetContent
|
||||
side="left"
|
||||
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" }}
|
||||
>
|
||||
{sidebarHeader}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
allowed: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
|
||||
@@ -2838,6 +2838,71 @@
|
||||
"updateAdminStatusFailed": "Failed to update admin status",
|
||||
"allSessionsRevoked": "All sessions revoked",
|
||||
"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",
|
||||
"createRoleSuccess": "Role \"{{name}}\" created",
|
||||
"createRoleFailed": "Failed to create role",
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface UserInfo {
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
data_unlocked?: boolean;
|
||||
}
|
||||
|
||||
interface UserCount {
|
||||
@@ -1953,10 +1954,33 @@ export {
|
||||
disableOIDCConfig,
|
||||
getCommandHistoryEnabled,
|
||||
updateCommandHistoryEnabled,
|
||||
adminResetUserPassword,
|
||||
adminDisableUserTotp,
|
||||
adminExportUserData,
|
||||
type ApiKey,
|
||||
type CreatedApiKey,
|
||||
} 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 {
|
||||
setupTOTP,
|
||||
enableTOTP,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Share2,
|
||||
Trash2,
|
||||
Unlink,
|
||||
@@ -32,6 +33,8 @@ export type AdminUser = {
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
dataUnlocked?: boolean;
|
||||
totpEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
@@ -76,6 +79,7 @@ type UsersSectionProps = {
|
||||
SetStateAction<{ id: string; username: string } | null>
|
||||
>;
|
||||
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onManageUser: (user: AdminUser) => void;
|
||||
};
|
||||
|
||||
export function AdminUsersSection({
|
||||
@@ -91,6 +95,7 @@ export function AdminUsersSection({
|
||||
setLinkAccountOpen,
|
||||
setUnlinkAccountTarget,
|
||||
setUnlinkAccountOpen,
|
||||
onManageUser,
|
||||
}: UsersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -159,6 +164,15 @@ export function AdminUsersSection({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-accent-brand"
|
||||
title={t("admin.manageUserData")}
|
||||
onClick={() => onManageUser(user)}
|
||||
>
|
||||
<Settings2 className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -87,6 +87,8 @@ import {
|
||||
AdminLinkAccountDialog,
|
||||
AdminUnlinkAccountDialog,
|
||||
} from "./AdminUserDialogs";
|
||||
import { AdminUserManagePanel } from "./AdminUserManagePanel";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
@@ -100,11 +102,18 @@ function apiErrorMessage(error: unknown, fallback: string) {
|
||||
return (error as ApiErrorLike).response?.data?.error || fallback;
|
||||
}
|
||||
|
||||
export function AdminSettingsPanel() {
|
||||
export function AdminSettingsPanel({
|
||||
onEditingChange,
|
||||
onOpenHostTab,
|
||||
}: {
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
onOpenHostTab?: (host: Host) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [openSection, setOpenSection] = useState<AdminSection | null>(
|
||||
"general",
|
||||
);
|
||||
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
|
||||
@@ -206,6 +215,11 @@ export function AdminSettingsPanel() {
|
||||
loadSSOProviders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onEditingChange?.(manageUser !== null);
|
||||
return () => onEditingChange?.(false);
|
||||
}, [manageUser, onEditingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editUserOpen && editUserTarget) {
|
||||
setEditUserRoles([]);
|
||||
@@ -227,6 +241,8 @@ export function AdminSettingsPanel() {
|
||||
isAdmin: user.is_admin,
|
||||
isOidc: user.is_oidc,
|
||||
passwordHash: user.password_hash,
|
||||
dataUnlocked: user.data_unlocked,
|
||||
totpEnabled: user.totp_enabled,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -812,8 +828,31 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
if (manageUser) {
|
||||
return (
|
||||
<AdminUserManagePanel
|
||||
key={manageUser.id}
|
||||
user={manageUser}
|
||||
roles={roles}
|
||||
onBack={() => setManageUser(null)}
|
||||
onOpenHostTab={onOpenHostTab}
|
||||
onUserDeleted={() => {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== manageUser.id));
|
||||
setManageUser(null);
|
||||
}}
|
||||
onTotpDisabled={() => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === manageUser.id ? { ...u, totpEnabled: false } : u,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSection === "general"}
|
||||
onToggle={() => toggle("general")}
|
||||
@@ -881,6 +920,7 @@ export function AdminSettingsPanel() {
|
||||
setLinkAccountOpen={setLinkAccountOpen}
|
||||
setUnlinkAccountTarget={setUnlinkAccountTarget}
|
||||
setUnlinkAccountOpen={setUnlinkAccountOpen}
|
||||
onManageUser={setManageUser}
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
|
||||
@@ -34,7 +34,7 @@ export function AccordionSection({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-border bg-card overflow-hidden">
|
||||
<div className="border border-border bg-card overflow-hidden shrink-0">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -137,8 +137,8 @@ function buildRailButtons(
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"relative flex items-center gap-2.5 h-7 rounded shrink-0 transition-colors";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 6px" };
|
||||
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 8px" };
|
||||
|
||||
export function AppRail({
|
||||
railView,
|
||||
@@ -279,8 +279,8 @@ export function AppRail({
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
@@ -304,8 +304,8 @@ export function AppRail({
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
@@ -362,7 +362,7 @@ export function AppRail({
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
@@ -381,7 +381,7 @@ export function AppRail({
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{t("common.logout")}
|
||||
</span>
|
||||
@@ -391,7 +391,7 @@ export function AppRail({
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<button
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 6px" }}
|
||||
style={{ padding: "0 8px" }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
generateKeyPair,
|
||||
generatePublicKeyFromPrivate,
|
||||
updateCredential,
|
||||
adminCreateUserCredential,
|
||||
adminUpdateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
|
||||
@@ -23,11 +25,15 @@ export function CredentialEditorView({
|
||||
activeTab,
|
||||
onBack,
|
||||
onSave,
|
||||
adminTargetUserId,
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
activeTab: string;
|
||||
onBack: () => void;
|
||||
onSave: (saved: Record<string, unknown>) => void;
|
||||
// When set, saves go to another user's credentials via the admin
|
||||
// impersonation endpoints.
|
||||
adminTargetUserId?: string;
|
||||
}) {
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
@@ -92,15 +98,28 @@ export function CredentialEditorView({
|
||||
: credForm.passphrase || null
|
||||
: null,
|
||||
};
|
||||
const saved = credential
|
||||
? await updateCredential(Number(credential.id), data)
|
||||
: await createCredential(data);
|
||||
let saved: Record<string, unknown>;
|
||||
if (adminTargetUserId) {
|
||||
saved = credential
|
||||
? await adminUpdateUserCredential(
|
||||
adminTargetUserId,
|
||||
Number(credential.id),
|
||||
data,
|
||||
)
|
||||
: await adminCreateUserCredential(adminTargetUserId, data);
|
||||
} else {
|
||||
saved = credential
|
||||
? await updateCredential(Number(credential.id), data)
|
||||
: await createCredential(data);
|
||||
}
|
||||
toast.success(
|
||||
credential
|
||||
? t("hosts.credentialUpdated")
|
||||
: t("hosts.credentialCreated"),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
if (!adminTargetUserId) {
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
}
|
||||
onSave(saved);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
getUserInfo,
|
||||
getVaultProfiles,
|
||||
getHostPassword,
|
||||
adminCreateUserHost,
|
||||
adminUpdateUserHost,
|
||||
adminGetHostPassword,
|
||||
adminGetUserSnippets,
|
||||
} from "@/main-axios";
|
||||
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||
@@ -77,6 +81,7 @@ export function HostEditor({
|
||||
onTabChange,
|
||||
hosts,
|
||||
credentials,
|
||||
adminTargetUserId,
|
||||
}: {
|
||||
host: Host | null;
|
||||
activeTab: string;
|
||||
@@ -87,6 +92,9 @@ export function HostEditor({
|
||||
onTabChange: (tab: string) => void;
|
||||
hosts: Host[];
|
||||
credentials: { id: string; name: string; username: string }[];
|
||||
// When set, the editor works on another user's host through the admin
|
||||
// impersonation endpoints instead of the signed-in user's own data.
|
||||
adminTargetUserId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setPreviewTerminalTheme } = useTabsSafe();
|
||||
@@ -133,11 +141,14 @@ export function HostEditor({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSnippets()
|
||||
const loadSnippets = adminTargetUserId
|
||||
? adminGetUserSnippets(adminTargetUserId)
|
||||
: getSnippets();
|
||||
loadSnippets
|
||||
.then((res) => setSnippets(mapSnippetResponse(res)))
|
||||
.catch(() => {});
|
||||
reloadVaultProfiles();
|
||||
}, []);
|
||||
}, [adminTargetUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (host) return;
|
||||
@@ -150,7 +161,10 @@ export function HostEditor({
|
||||
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
|
||||
|
||||
let cancelled = false;
|
||||
getHostPassword(Number(host.id), "vncPassword").then((password) => {
|
||||
const loadVncPassword = adminTargetUserId
|
||||
? adminGetHostPassword(adminTargetUserId, Number(host.id), "vncPassword")
|
||||
: getHostPassword(Number(host.id), "vncPassword");
|
||||
loadVncPassword.then((password) => {
|
||||
if (cancelled || !password) return;
|
||||
setForm((prev) =>
|
||||
prev.vncPassword ? prev : { ...prev, vncPassword: password },
|
||||
@@ -160,7 +174,7 @@ export function HostEditor({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.vncAuthType, form.vncPassword, host?.id]);
|
||||
}, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "tunnels") return;
|
||||
@@ -184,9 +198,16 @@ export function HostEditor({
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = buildHostEditorPayload(form, protocols);
|
||||
const saved = host
|
||||
? await updateSSHHost(Number(host.id), data)
|
||||
: await createSSHHost(data);
|
||||
let saved: SSHHost;
|
||||
if (adminTargetUserId) {
|
||||
saved = host
|
||||
? await adminUpdateUserHost(adminTargetUserId, Number(host.id), data)
|
||||
: await adminCreateUserHost(adminTargetUserId, data);
|
||||
} else {
|
||||
saved = host
|
||||
? await updateSSHHost(Number(host.id), data)
|
||||
: await createSSHHost(data);
|
||||
}
|
||||
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
|
||||
setPreviewTerminalTheme(null);
|
||||
onSave(saved);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapAlertFiring } from "./alerts-api";
|
||||
import { mapAlertFiring } from "../../api/alerts-api";
|
||||
|
||||
describe("mapAlertFiring", () => {
|
||||
it("normalizes sqlite snake_case rows for the alerts UI", () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapAuditLog } from "./audit-log-api";
|
||||
import { mapAuditLog } from "../../api/audit-log-api";
|
||||
|
||||
describe("mapAuditLog", () => {
|
||||
it("normalizes sqlite snake_case rows for the audit log UI", () => {
|
||||
+2
-2
@@ -10,8 +10,8 @@ import {
|
||||
setHeight,
|
||||
heightToRowSpan,
|
||||
MIN_TILE_HEIGHT,
|
||||
} from "./layout-utils";
|
||||
import type { GridSlot } from "./types";
|
||||
} from "../../../components/card-grid/layout-utils";
|
||||
import type { GridSlot } from "../../../components/card-grid/types";
|
||||
|
||||
function slot(id: string, order: number, colSpan: 1 | 2 | 3 = 1): GridSlot {
|
||||
return { id, order, colSpan, height: null };
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
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", () => {
|
||||
it("returns no data for fewer than 2 points", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
|
||||
import { resolveProxmoxImportAuth } from "../../../components/proxmox/proxmox-import-auth";
|
||||
|
||||
describe("resolveProxmoxImportAuth", () => {
|
||||
it("uses credential auth when a credential is available", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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", () => {
|
||||
it("returns a dash for undefined or null", () => {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useFileSelection } from "./useFileSelection.js";
|
||||
import { useFileSelection } from "../../../../features/file-manager/hooks/useFileSelection.js";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
|
||||
|
||||
const httpsLocation = {
|
||||
protocol: "https:",
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "./guacamole-clipboard.js";
|
||||
} from "../../../features/guacamole/guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole Firefox clipboard fallback", () => {
|
||||
it("only enables the native paste path for Firefox", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size";
|
||||
import { getGuacamoleDisplaySize } from "../../../features/guacamole/guacamole-display-size";
|
||||
|
||||
describe("getGuacamoleDisplaySize", () => {
|
||||
it("requests native pixels and matching DPI for HiDPI RDP", () => {
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
screenToCanvas,
|
||||
canvasToScreen,
|
||||
zoomAroundPoint,
|
||||
} from "./canvasGeometry";
|
||||
} from "../../../../features/homepage/canvas/canvasGeometry";
|
||||
|
||||
const PAN = { x: 100, y: 200 };
|
||||
const ZOOM = 2;
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
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";
|
||||
|
||||
describe("snapToGrid", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAsciicast } from "./asciicast";
|
||||
import { parseAsciicast } from "../../../features/session-recording/asciicast";
|
||||
|
||||
describe("parseAsciicast", () => {
|
||||
it("parses terminal dimensions and timed input/output", () => {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNextTerminalFontSize,
|
||||
getTerminalFontZoomDirection,
|
||||
} from "./terminal-font-zoom";
|
||||
} from "../../../features/terminal/terminal-font-zoom";
|
||||
|
||||
function keyEvent(
|
||||
overrides: Partial<KeyboardEvent>,
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
getTunnelTypeForMode,
|
||||
getTunnelPortLabels,
|
||||
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
|
||||
// assert which translation key + payload the helpers chose.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useIsMobile } from "./use-mobile.js";
|
||||
import { useIsMobile } from "../../hooks/use-mobile.js";
|
||||
|
||||
function setViewport(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "./i18n";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "../../i18n/i18n";
|
||||
|
||||
describe("i18n language handling", () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,5 +1,5 @@
|
||||
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>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { copyToClipboard } from "./clipboard";
|
||||
import { copyToClipboard } from "../../lib/clipboard";
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDatabaseTransferUrl } from "./database-transfer-url";
|
||||
import { getDatabaseTransferUrl } from "../../lib/database-transfer-url";
|
||||
|
||||
const productionLocation = {
|
||||
protocol: "https:",
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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>;
|
||||
let cleanup: (() => void) | undefined;
|
||||
@@ -1,5 +1,5 @@
|
||||
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>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
// private, so we exercise them through the public request* methods and assert
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ServerStatusStore } from "./server-status-store";
|
||||
import { ServerStatusStore } from "../../lib/server-status-store";
|
||||
|
||||
describe("ServerStatusStore", () => {
|
||||
it("notifies only listeners for hosts whose status value changed", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { highlightTerminalOutput } from "./terminal-syntax-highlighter.js";
|
||||
import { highlightTerminalOutput } from "../../lib/terminal-syntax-highlighter.js";
|
||||
|
||||
const ESC = "\x1b";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTtlRequestCache } from "./ttl-request-cache";
|
||||
import { createTtlRequestCache } from "../../lib/ttl-request-cache";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cn } from "./utils.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
describe("cn", () => {
|
||||
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
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePath, splitPath } from "./FolderPathPicker.js";
|
||||
import { normalizePath, splitPath } from "../../sidebar/FolderPathPicker.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on canonical separator", () => {
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createHostEditorForm,
|
||||
buildHostEditorPayload,
|
||||
type HostProtocols,
|
||||
} from "./HostEditorData";
|
||||
} from "../../sidebar/HostEditorData";
|
||||
|
||||
const sshOnly: HostProtocols = {
|
||||
enableSsh: true,
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 {
|
||||
return {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createQuickConnectHost,
|
||||
quickConnectHostToPayload,
|
||||
} from "./quick-connect-host";
|
||||
} from "../../sidebar/quick-connect-host";
|
||||
|
||||
describe("quick connect host", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
Reference in New Issue
Block a user