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",
|
"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
@@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
export async function updateRegistrationAllowed(
|
||||||
allowed: boolean,
|
allowed: boolean,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<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 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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
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 updateCredential(Number(credential.id), data)
|
||||||
: await createCredential(data);
|
: await createCredential(data);
|
||||||
|
}
|
||||||
toast.success(
|
toast.success(
|
||||||
credential
|
credential
|
||||||
? t("hosts.credentialUpdated")
|
? t("hosts.credentialUpdated")
|
||||||
: t("hosts.credentialCreated"),
|
: t("hosts.credentialCreated"),
|
||||||
);
|
);
|
||||||
|
if (!adminTargetUserId) {
|
||||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
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 updateSSHHost(Number(host.id), data)
|
||||||
: await createSSHHost(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", () => {
|
||||||
+2
-2
@@ -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 };
|
||||||
+5
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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:",
|
||||||
+1
-1
@@ -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
-1
@@ -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", () => {
|
||||||
+1
-1
@@ -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;
|
||||||
+4
-1
@@ -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
-1
@@ -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", () => {
|
||||||
+1
-1
@@ -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>,
|
||||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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 {
|
||||||
+1
-1
@@ -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());
|
||||||
Reference in New Issue
Block a user