diff --git a/package-lock.json b/package-lock.json index 7914caeb..4e9b87b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "2.5.0", + "version": "2.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.5.0", + "version": "2.5.1", "hasInstallScript": true, "dependencies": { "@simplewebauthn/browser": "^13.3.0", diff --git a/package.json b/package.json index 594277e7..72160103 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.5.0", + "version": "2.5.1", "description": "Self-hosted SSH and remote desktop management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index dd58bac5..083c37c6 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -6,6 +6,7 @@ import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; import { createCurrentRoleRepository, createCurrentUserRepository, @@ -51,7 +52,11 @@ export function registerUserAdminRoutes( */ router.get("/list", authenticateJWT, async (req, res) => { try { - const allUsers = await createCurrentUserRepository().listAll(); + const userRepository = createCurrentUserRepository(); + const requester = await userRepository.findById( + (req as AuthenticatedRequest).userId, + ); + const allUsers = await userRepository.listAll(); res.json({ users: allUsers.map((u) => ({ @@ -60,6 +65,14 @@ export function registerUserAdminRoutes( is_admin: u.isAdmin, is_oidc: u.isOidc, password_hash: u.passwordHash ? "set" : null, + // Management-only details stay admin-eyes-only; regular users hit + // this route to pick sharing targets. + ...(requester?.isAdmin + ? { + data_unlocked: DataCrypto.canUserAccessData(u.id), + totp_enabled: !!u.totpEnabled, + } + : {}), })), }); } catch (err) { @@ -606,4 +619,176 @@ export function registerUserAdminRoutes( res.status(500).json({ error: "Failed to reset password" }); } }); + + /** + * @openapi + * /users/admin/totp/disable: + * post: + * summary: Disable a user's TOTP (admin only) + * description: Clears another user's TOTP secret, enabled flag and backup codes so they can log in without 2FA. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * responses: + * 200: + * description: TOTP disabled for the user. + * 400: + * description: User ID is required or TOTP is not enabled. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + * 500: + * description: Failed to disable TOTP. + */ + router.post("/admin/totp/disable", authenticateJWT, async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + const { userId: targetUserId } = req.body; + + if (!isNonEmptyString(targetUserId)) { + return res.status(400).json({ error: "User ID is required" }); + } + + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const targetUser = await userRepository.findById(targetUserId.trim()); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + if (!targetUser.totpEnabled) { + return res + .status(400) + .json({ error: "TOTP is not enabled for this user" }); + } + + await userRepository.update(targetUser.id, { + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); + + try { + await DatabaseSaveTrigger.forceSave("admin_disable_totp"); + } catch (saveError) { + authLogger.error("Failed to persist TOTP disable to disk", saveError, { + operation: "admin_disable_totp_save_failed", + userId: targetUser.id, + }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: adminId, + username: adminUser.username ?? adminId, + action: "admin_disable_totp", + resourceType: "user", + resourceId: targetUser.id, + resourceName: targetUser.username, + ipAddress, + userAgent, + success: true, + }); + + res.json({ message: "TOTP disabled" }); + } catch (err) { + authLogger.error("Failed to disable TOTP for user", err); + res.status(500).json({ error: "Failed to disable TOTP" }); + } + }); + + /** + * @openapi + * /users/admin/export/{userId}: + * get: + * summary: Export a user's data (admin only) + * description: Downloads a JSON export of another user's data (hosts, credentials, file manager bookmarks). Secrets are decrypted server-side, so handle the file carefully. + * tags: + * - Users + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: JSON export of the user's data. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + * 423: + * description: The user's data stays locked until their next login. + * 500: + * description: Failed to export user data. + */ + router.get("/admin/export/:userId", authenticateJWT, async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + const targetUserId = Array.isArray(req.params.userId) + ? req.params.userId[0] + : req.params.userId; + + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const targetUser = await userRepository.findById(targetUserId); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + + if (!DataCrypto.canUserAccessData(targetUser.id)) { + return res.status(423).json({ + error: "Target user's data stays locked until their next login", + code: "TARGET_DATA_LOCKED", + }); + } + + const { UserDataExport } = + await import("../../utils/user-data-export.js"); + const exportData = await UserDataExport.exportUserData(targetUser.id, { + format: "plaintext", + includeCredentials: true, + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: adminId, + username: adminUser.username ?? adminId, + action: "admin_export_user_data", + resourceType: "user", + resourceId: targetUser.id, + resourceName: targetUser.username, + ipAddress, + userAgent, + success: true, + }); + + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + `attachment; filename="termix-user-${targetUser.username}-export.json"`, + ); + res.json(exportData); + } catch (err) { + authLogger.error("Failed to export user data", err); + res.status(500).json({ error: "Failed to export user data" }); + } + }); } diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts index edbff318..859d8e09 100644 --- a/src/backend/hosts/host-resolver.ts +++ b/src/backend/hosts/host-resolver.ts @@ -1,7 +1,9 @@ import { createCurrentHostResolutionRepository, createCurrentVaultProfileRepository, + createCurrentUserRepository, } from "../database/repositories/factory.js"; +import { logAudit } from "../utils/audit-logger.js"; import { logger } from "../utils/logger.js"; import { pickResolvedPassword, @@ -39,7 +41,28 @@ export async function resolveHostById( const host = resolvedHost as Record; - if (userId !== ownerId) { + // Admin bypass resolves like the owner would; every such access is audited. + const ownerEquivalent = userId === ownerId || access.isAdminBypass === true; + + if (access.isAdminBypass && userId !== ownerId) { + try { + const admin = await createCurrentUserRepository().findById(userId); + void logAudit({ + userId, + username: admin?.username ?? "unknown", + action: "admin_connect_host", + resourceType: "host", + resourceId: String(hostId), + resourceName: (host.name as string) || (host.ip as string) || "", + details: JSON.stringify({ ownerId }), + success: true, + }); + } catch { + // never block resolution on audit bookkeeping + } + } + + if (!ownerEquivalent) { // Owner-only operational secrets are never shared. host.sudoPassword = null; host.autostartPassword = null; @@ -91,7 +114,7 @@ export async function resolveHostById( } } - if (userId !== ownerId) { + if (!ownerEquivalent) { const resolved = await resolveSharedSshSecrets( host, hostId, @@ -133,7 +156,7 @@ export async function resolveHostById( host.username = await expandOidcUsername( host.username as string | undefined, - userId, + ownerEquivalent ? ownerId : userId, ); // Resolve a Vault SSH signer profile (shared settings, no secrets). The diff --git a/src/backend/tests/database/routes/user-admin-routes.test.ts b/src/backend/tests/database/routes/user-admin-routes.test.ts new file mode 100644 index 00000000..22efb678 --- /dev/null +++ b/src/backend/tests/database/routes/user-admin-routes.test.ts @@ -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(), + updates: [] as { id: string; changes: Record }[], + auditCalls: [] as Record[], +})); + +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) => { + 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) => { + 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; + params?: Record; +}) { + 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, + 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 }).headers[key] = + value; + return this; + }, + } as unknown as Response & { + statusCode: number; + jsonBody: unknown; + headers: Record; + }; + + return { req, res }; +} + +async function invoke( + method: string, + path: string, + overrides: { + body?: Record; + params?: Record; + } = {}, +) { + 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 | 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[] }).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[] }).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); + }); +}); diff --git a/src/backend/tests/hosts/host-resolver.test.ts b/src/backend/tests/hosts/host-resolver.test.ts index 5955d7d9..9729fd97 100644 --- a/src/backend/tests/hosts/host-resolver.test.ts +++ b/src/backend/tests/hosts/host-resolver.test.ts @@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ host: null as Record | null, hasAccess: true, + isAdminBypass: false, overrideCredentialId: null as number | null, credentials: new Map>(), sharedSecret: null as Record | null, + auditCalls: [] as Record[], })); vi.mock("../../database/repositories/factory.js", () => ({ @@ -19,12 +21,24 @@ vi.mock("../../database/repositories/factory.js", () => ({ createCurrentVaultProfileRepository: () => ({ findById: async () => null, }), + createCurrentUserRepository: () => ({ + findById: async (userId: string) => ({ id: userId, username: userId }), + }), +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (params: Record) => { + state.auditCalls.push(params); + }, })); vi.mock("../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ - canAccessHost: async () => ({ hasAccess: state.hasAccess }), + canAccessHost: async () => ({ + hasAccess: state.hasAccess, + isAdminBypass: state.isAdminBypass, + }), }), }, })); @@ -82,9 +96,11 @@ function baseHost(overrides: Record = {}) { beforeEach(() => { state.host = baseHost(); state.hasAccess = true; + state.isAdminBypass = false; state.overrideCredentialId = null; state.credentials.clear(); state.sharedSecret = null; + state.auditCalls = []; }); describe("resolveHostById", () => { @@ -176,4 +192,53 @@ describe("resolveHostById", () => { const host = await resolveHostById(42, "recipient"); expect(host).not.toBeNull(); }); + + it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => { + state.isAdminBypass = true; + state.host = baseHost({ + authType: "credential", + credentialId: 9, + username: "", + password: null, + }); + state.credentials.set("9:owner", { + id: 9, + username: "cred-user", + authType: "key", + password: null, + privateKey: "OWNER-PRIVATE-KEY", + key: null, + keyPassword: "kp", + keyType: "ssh-ed25519", + certPublicKey: null, + }); + + const host = (await resolveHostById(42, "adminUser")) as Record< + string, + unknown + >; + // Owner credential resolved (not the share snapshot path). + expect(host.key).toBe("OWNER-PRIVATE-KEY"); + expect(host.username).toBe("cred-user"); + // Owner-only operational secrets are NOT stripped for the admin. + expect(host.sudoPassword).toBe("owner-sudo"); + expect(host.autostartPassword).toBe("auto-pass"); + }); + + it("audits every admin-bypass host resolution", async () => { + state.isAdminBypass = true; + await resolveHostById(42, "adminUser"); + expect(state.auditCalls).toHaveLength(1); + expect(state.auditCalls[0]).toMatchObject({ + action: "admin_connect_host", + resourceType: "host", + resourceId: "42", + userId: "adminUser", + }); + }); + + it("does not audit an ordinary owner resolution", async () => { + await resolveHostById(42, "owner"); + expect(state.auditCalls).toHaveLength(0); + }); }); diff --git a/src/backend/tests/utils/auth-manager-impersonation.test.ts b/src/backend/tests/utils/auth-manager-impersonation.test.ts new file mode 100644 index 00000000..a18bb1a6 --- /dev/null +++ b/src/backend/tests/utils/auth-manager-impersonation.test.ts @@ -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(), + unlockedUsers: new Set(), + auditCalls: [] as Record[], +})); + +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) => { + 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 = {}) { + 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[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[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"); + }); +}); diff --git a/src/backend/tests/utils/permission-manager.test.ts b/src/backend/tests/utils/permission-manager.test.ts index 249bf9b4..ae705d03 100644 --- a/src/backend/tests/utils/permission-manager.test.ts +++ b/src/backend/tests/utils/permission-manager.test.ts @@ -23,6 +23,7 @@ const accessState = vi.hoisted(() => ({ expiresAt: string | null; } | null, touched: [] as number[], + adminIds: new Set(), })); vi.mock("../../database/repositories/factory.js", () => ({ @@ -44,7 +45,8 @@ vi.mock("../../database/repositories/factory.js", () => ({ userHasAnyRoleName: async () => false, }), createCurrentUserRepository: () => ({ - findById: async () => null, + findById: async (userId: string) => + accessState.adminIds.has(userId) ? { id: userId, isAdmin: true } : null, }), })); @@ -116,6 +118,7 @@ describe("PermissionManager.canAccessHost level hierarchy", () => { accessState.ownerId = "owner"; accessState.grant = null; accessState.touched = []; + accessState.adminIds = new Set(); }); it("grants the owner every action including delete", async () => { @@ -165,4 +168,30 @@ describe("PermissionManager.canAccessHost level hierarchy", () => { await manager.canAccessHost("recipient", 42, "connect"); expect(accessState.touched).toEqual([5]); }); + + it("grants admins owner-equivalent access to any host via bypass", async () => { + accessState.adminIds = new Set(["adminUser"]); + for (const action of actions) { + const info = await manager.canAccessHost("adminUser", 42, action); + expect(info).toMatchObject({ + hasAccess: true, + isOwner: false, + isAdminBypass: true, + permissionLevel: "manage", + }); + } + }); + + it("upgrades an under-privileged admin's share access via bypass", async () => { + accessState.adminIds = new Set(["adminUser"]); + accessState.grant = { id: 7, permissionLevel: "connect", expiresAt: null }; + const info = await manager.canAccessHost("adminUser", 42, "manage"); + expect(info).toMatchObject({ hasAccess: true, isAdminBypass: true }); + }); + + it("does not grant a non-admin stranger admin bypass", async () => { + const info = await manager.canAccessHost("stranger", 42, "manage"); + expect(info.hasAccess).toBe(false); + expect(info.isAdminBypass).toBeUndefined(); + }); }); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index f09797ae..6d2a29c6 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -8,6 +8,7 @@ import { import { SystemCrypto } from "./system-crypto.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger, authLogger } from "./logger.js"; +import { logAudit, getRequestMeta } from "./audit-logger.js"; import type { Request, Response, NextFunction } from "express"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; @@ -57,6 +58,7 @@ interface AuthenticatedRequest extends Request { apiKeyId?: string; pendingTOTP?: boolean; dataKey?: Buffer; + actingAdminUserId?: string; } interface RequestWithHeaders extends Request { @@ -65,6 +67,16 @@ interface RequestWithHeaders extends Request { }; } +const ADMIN_TARGET_USER_HEADER = "x-admin-target-user"; + +// Data-plane routes an admin may hit on behalf of another user. Everything +// else (TOTP, sessions, tunnels, file manager, ...) rejects the header. +const IMPERSONATION_PATH_ALLOWLIST = [ + /^\/host\/db\//, + /^\/credentials(\/|$)/, + /^\/snippets(\/|$)/, +]; + class AuthManager { private static instance: AuthManager; private systemCrypto: SystemCrypto; @@ -593,6 +605,13 @@ class AuthManager { token: string, requireAdmin = false, ): Promise { + if (req.headers[ADMIN_TARGET_USER_HEADER]) { + res.status(403).json({ + error: "Impersonation is not allowed with API key authentication", + code: "IMPERSONATION_NOT_ALLOWED", + }); + return; + } try { const tokenPrefix = token.substring(0, 12); const apiKeyRepository = createCurrentApiKeyRepository(); @@ -763,10 +782,103 @@ class AuthManager { authReq.userId = payload.userId; authReq.sessionId = payload.sessionId; authReq.pendingTOTP = payload.pendingTOTP; + + if (authReq.headers[ADMIN_TARGET_USER_HEADER]) { + const handled = await this.applyAdminImpersonation( + authReq, + res, + payload.userId, + ); + if (handled) return; + } + next(); }; } + /** + * Handle the X-Admin-Target-User header: admins may act on another user's + * data for an allowlisted set of data-plane routes. On success req.userId + * becomes the target user and actingAdminUserId records the real caller. + * Returns true when a response was already sent (request must stop). + */ + private async applyAdminImpersonation( + req: AuthenticatedRequest, + res: Response, + adminUserId: string, + ): Promise { + const rawHeader = req.headers[ADMIN_TARGET_USER_HEADER]; + const targetUserId = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + + if (!targetUserId || targetUserId === adminUserId) { + return false; + } + + const userRepository = createCurrentUserRepository(); + const admin = await userRepository.findById(adminUserId); + if (!admin?.isAdmin) { + databaseLogger.warn("Impersonation attempt by non-admin", { + operation: "admin_impersonation_denied", + userId: adminUserId, + targetUserId, + path: req.originalUrl, + }); + res.status(403).json({ + error: "Admin access required", + code: "IMPERSONATION_DENIED", + }); + return true; + } + + const path = (req.originalUrl || req.url || "").split("?")[0]; + const allowed = IMPERSONATION_PATH_ALLOWLIST.some((pattern) => + pattern.test(path), + ); + if (!allowed) { + res.status(403).json({ + error: "Impersonation is not allowed for this route", + code: "IMPERSONATION_NOT_ALLOWED", + }); + return true; + } + + const target = await userRepository.findById(targetUserId); + if (!target) { + res.status(404).json({ + error: "Target user not found", + code: "TARGET_USER_NOT_FOUND", + }); + return true; + } + + if (!DataCrypto.canUserAccessData(targetUserId)) { + res.status(423).json({ + error: "Target user's data stays locked until their next login", + code: "TARGET_DATA_LOCKED", + }); + return true; + } + + req.userId = targetUserId; + req.actingAdminUserId = adminUserId; + + const { ipAddress, userAgent } = getRequestMeta(req); + void logAudit({ + userId: adminUserId, + username: admin.username, + action: "admin_impersonated_request", + resourceType: "user", + resourceId: targetUserId, + resourceName: target.username, + details: JSON.stringify({ method: req.method, path }), + ipAddress, + userAgent, + success: true, + }); + + return false; + } + createDataAccessMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; diff --git a/src/backend/utils/cors-config.ts b/src/backend/utils/cors-config.ts index 7a96d889..582d454d 100644 --- a/src/backend/utils/cors-config.ts +++ b/src/backend/utils/cors-config.ts @@ -36,6 +36,7 @@ export function createCorsMiddleware( "User-Agent", "X-Electron-App", "Cache-Control", + "x-admin-target-user", ...extraHeaders, ]; diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts index 76151038..a3120726 100644 --- a/src/backend/utils/permission-manager.ts +++ b/src/backend/utils/permission-manager.ts @@ -37,6 +37,7 @@ interface HostAccessInfo { hasAccess: boolean; isOwner: boolean; isShared: boolean; + isAdminBypass?: boolean; permissionLevel?: SharePermissionLevel; expiresAt?: string | null; } @@ -209,6 +210,9 @@ class PermissionManager { action === "delete" || LEVEL_RANK[grantedLevel] < LEVEL_RANK[action] ) { + if (await this.isAdmin(userId)) { + return this.adminBypassAccess(); + } return { hasAccess: false, isOwner: false, @@ -240,6 +244,10 @@ class PermissionManager { }; } + if (await this.isAdmin(userId)) { + return this.adminBypassAccess(); + } + return { hasAccess: false, isOwner: false, @@ -260,6 +268,18 @@ class PermissionManager { } } + // Admins get owner-equivalent access to every host; each connect is + // audit-logged in the host resolver. + private adminBypassAccess(): HostAccessInfo { + return { + hasAccess: true, + isOwner: false, + isShared: false, + isAdminBypass: true, + permissionLevel: "manage", + }; + } + async isAdmin(userId: string): Promise { try { const user = await createCurrentUserRepository().findById(userId); diff --git a/src/types/index.ts b/src/types/index.ts index 6d83c5ea..3b2b9a03 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -971,6 +971,7 @@ export interface AuthenticatedRequest extends Request { userId: string; sessionId?: string; apiKeyId?: string; + actingAdminUserId?: string; user?: { id: string; username: string; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index ef127fe0..b8add100 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -1691,8 +1691,14 @@ export function AppShell({ )} {railView === "admin-settings" && isAdmin && ( -
- +
+ { + connectHost(host); + if (isMobile) setSidebarOpen(false); + }} + />
)} @@ -1755,7 +1761,7 @@ export function AppShell({ {/* Desktop: inline resizable sidebar */} {!isMobile && (
{sidebarHeader} diff --git a/src/ui/api/admin-user-data-api.ts b/src/ui/api/admin-user-data-api.ts new file mode 100644 index 00000000..ecac34ea --- /dev/null +++ b/src/ui/api/admin-user-data-api.ts @@ -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 { + return { [ADMIN_TARGET_USER_HEADER]: targetUserId }; +} + +export async function adminGetUserHosts( + targetUserId: string, +): Promise { + 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 { + 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 { + 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> { + 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 { + 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> { + 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> { + 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, +): Promise> { + 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, +): Promise> { + 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> { + 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> { + 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, +): Promise> { + 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, +): Promise> { + 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> { + try { + const response = await authApi.delete(`/snippets/${snippetId}`, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "delete user's snippet"); + } +} diff --git a/src/ui/api/user-management-api.ts b/src/ui/api/user-management-api.ts index 1fe4c6e3..a3ebdb90 100644 --- a/src/ui/api/user-management-api.ts +++ b/src/ui/api/user-management-api.ts @@ -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> { + try { + const response = await authApi.get( + `/users/admin/export/${encodeURIComponent(userId)}`, + { timeout: 120000 }, + ); + return response.data; + } catch (error) { + handleApiError(error, "export user data"); + } +} + export async function updateRegistrationAllowed( allowed: boolean, ): Promise> { diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 37ca97fb..78c99c33 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2838,6 +2838,71 @@ "updateAdminStatusFailed": "Failed to update admin status", "allSessionsRevoked": "All sessions revoked", "revokeSessionsFailed": "Failed to revoke sessions", + "manageUserData": "Manage user data", + "backToUsers": "Back to users", + "manageTabAccount": "Account", + "manageTabHosts": "Hosts", + "manageTabCredentials": "Credentials", + "manageTabSnippets": "Snippets", + "manageTabSessions": "Sessions", + "manageTabDanger": "Danger", + "manageEditorBack": "Back to {{username}}'s data", + "dataLockedBadge": "LOCKED", + "dataLockedNotice": "{{username}}'s data stays locked until they next log in. Their hosts, credentials and snippets cannot be viewed or edited until then.", + "resetPasswordTitle": "Reset Password", + "resetPasswordOidcOnly": "This user signs in through an external provider and has no password.", + "resetPasswordPlaceholder": "New password", + "resetPasswordBtn": "Reset", + "resetPasswordWorking": "Resetting...", + "resetPasswordSuccess": "Password reset", + "resetPasswordSuccessWiped": "Password reset. The user's encrypted data was wiped.", + "resetPasswordFailed": "Failed to reset password", + "resetPasswordConfirmWipe": "{{username}} has not logged in since the encryption upgrade, so their data cannot be recovered. Resetting now will delete their hosts, credentials and snippets. Continue?", + "totpSectionTitle": "Two-Factor Authentication", + "totpStatusEnabled": "TOTP is enabled for this user", + "totpStatusDisabled": "TOTP is not enabled for this user", + "disableTotp": "Disable", + "disableTotpConfirm": "Disable two-factor authentication for {{username}}? They will be able to log in with only their password.", + "totpDisabledSuccess": "TOTP disabled", + "totpDisableFailed": "Failed to disable TOTP", + "manageApiKeys": "API Keys", + "apiKeyNamePlaceholder": "Key name", + "apiKeyCopyNotice": "Copy this key now, it won't be shown again.", + "noApiKeysForUser": "No API keys", + "apiKeyDeleteFailed": "Failed to delete API key", + "exportUserData": "Data Export", + "exportUserDataDesc": "Download this user's hosts, credentials and file manager data as JSON. Secrets are decrypted.", + "exportUserDataSuccess": "User data exported", + "exportUserDataFailed": "Failed to export user data", + "hostsCount": "{{count}} hosts", + "addHostForUser": "Add Host", + "noHostsForUser": "This user has no hosts", + "connectToHost": "Connect", + "deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?", + "hostDeletedSuccess": "Host deleted", + "hostDeleteFailed": "Failed to delete host", + "credentialsCount": "{{count}} credentials", + "addCredentialForUser": "Add Credential", + "noCredentialsForUser": "This user has no credentials", + "deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?", + "credentialDeletedSuccess": "Credential deleted", + "credentialDeleteFailed": "Failed to delete credential", + "snippetsCount": "{{count}} snippets", + "addSnippetForUser": "Add Snippet", + "noSnippetsForUser": "This user has no snippets", + "snippetRequiredFields": "Snippet name and content are required", + "snippetNamePlaceholder": "Snippet name", + "snippetContentPlaceholder": "Command content", + "snippetFolderPlaceholder": "Folder (optional)", + "snippetSaved": "Snippet saved", + "snippetSaveFailed": "Failed to save snippet", + "deleteSnippetConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?", + "snippetDeletedSuccess": "Snippet deleted", + "snippetDeleteFailed": "Failed to delete snippet", + "noSessionsForUser": "No active sessions", + "deleteUserDangerDesc": "Permanently delete {{username}} and all of their data (hosts, credentials, snippets, history). This cannot be undone.", + "deleteUserConfirm": "Permanently delete {{username}} and all of their data?", + "deleteUserAdminBlocked": "Remove admin status before deleting this user.", "createRoleRequired": "Name and display name are required", "createRoleSuccess": "Role \"{{name}}\" created", "createRoleFailed": "Failed to create role", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 9c66a138..31c52e93 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -201,6 +201,7 @@ export interface UserInfo { is_admin: boolean; is_oidc: boolean; password_hash?: string; + data_unlocked?: boolean; } interface UserCount { @@ -1953,10 +1954,33 @@ export { disableOIDCConfig, getCommandHistoryEnabled, updateCommandHistoryEnabled, + adminResetUserPassword, + adminDisableUserTotp, + adminExportUserData, type ApiKey, type CreatedApiKey, } from "@/api/user-management-api"; +// ADMIN USER DATA MANAGEMENT +// ============================================================================ + +export { + adminGetUserHosts, + adminCreateUserHost, + adminUpdateUserHost, + adminDeleteUserHost, + adminGetHostPassword, + adminGetUserCredentials, + adminGetUserCredentialDetails, + adminCreateUserCredential, + adminUpdateUserCredential, + adminDeleteUserCredential, + adminGetUserSnippets, + adminCreateUserSnippet, + adminUpdateUserSnippet, + adminDeleteUserSnippet, +} from "@/api/admin-user-data-api"; + export { setupTOTP, enableTOTP, diff --git a/src/ui/sidebar/AdminManagementSections.tsx b/src/ui/sidebar/AdminManagementSections.tsx index 81a6ba4a..4549b384 100644 --- a/src/ui/sidebar/AdminManagementSections.tsx +++ b/src/ui/sidebar/AdminManagementSections.tsx @@ -18,6 +18,7 @@ import { Pencil, Plus, RefreshCw, + Settings2, Share2, Trash2, Unlink, @@ -32,6 +33,8 @@ export type AdminUser = { isAdmin: boolean; isOidc: boolean; passwordHash?: string; + dataUnlocked?: boolean; + totpEnabled?: boolean; }; export type AdminSession = { @@ -76,6 +79,7 @@ type UsersSectionProps = { SetStateAction<{ id: string; username: string } | null> >; setUnlinkAccountOpen: Dispatch>; + onManageUser: (user: AdminUser) => void; }; export function AdminUsersSection({ @@ -91,6 +95,7 @@ export function AdminUsersSection({ setLinkAccountOpen, setUnlinkAccountTarget, setUnlinkAccountOpen, + onManageUser, }: UsersSectionProps) { const { t } = useTranslation(); @@ -159,6 +164,15 @@ export function AdminUsersSection({
+ + +
+
+ {isHost ? ( + { + setEditor(null); + setEditorTab("general"); + }} + onSave={() => { + setEditor(null); + setEditorTab("general"); + reloadHosts(); + }} + protocols={editorProtocols} + onProtocolChange={(p) => + setEditorProtocols((prev) => ({ ...prev, ...p })) + } + onTabChange={setEditorTab} + hosts={hosts} + credentials={credentials} + adminTargetUserId={user.id} + /> + ) : ( + { + setEditor(null); + setEditorTab("general"); + }} + onSave={() => { + setEditor(null); + setEditorTab("general"); + reloadCredentials(); + }} + adminTargetUserId={user.id} + /> + )} +
+ + ); + } + + return ( +
+ {/* Back bar */} +
+ + ({ ...tab, icon: null }))} + activeTab={activeTab} + onTabChange={(id) => setActiveTab(id as ManageTabId)} + /> +
+ +
+ {activeTab === "account" && ( + <> + {/* Password reset */} +
+ {sectionHeading(t("admin.resetPasswordTitle"))} + {user.isOidc && !user.passwordHash ? ( + + {t("admin.resetPasswordOidcOnly")} + + ) : ( +
+ setResetPassword(e.target.value)} + /> + +
+ )} +
+ + {/* TOTP */} +
+ {sectionHeading(t("admin.totpSectionTitle"))} +
+ + {totpEnabled + ? t("admin.totpStatusEnabled") + : t("admin.totpStatusDisabled")} + + {totpEnabled && ( + + )} +
+
+ + {/* Roles */} +
+ {sectionHeading(t("admin.userRoles"))} + {userRoles.length > 0 && ( +
+ {userRoles.map((ur) => { + const roleInfo = roles.find((r) => r.id === ur.roleId); + const isSystem = roleInfo?.isSystem ?? false; + return ( + + {ur.roleDisplayName} + {!isSystem && ( + + )} + + ); + })} +
+ )} +
+ {roles + .filter( + (r) => + !r.isSystem && + !userRoles.some((ur) => ur.roleId === r.id), + ) + .map((r) => ( + + ))} +
+
+ + {/* API keys */} +
+ {sectionHeading(t("admin.manageApiKeys"))} +
+ setNewKeyName(e.target.value)} + /> + +
+ {createdKeyToken && ( +
+ + {t("admin.apiKeyCopyNotice")} + + + {createdKeyToken} + +
+ )} + {apiKeys.length === 0 ? ( + + {t("admin.noApiKeysForUser")} + + ) : ( + apiKeys.map((key) => ( +
+
+ + {key.name} + + + {key.tokenPrefix}… + +
+ +
+ )) + )} +
+ + {/* Data export */} +
+ {sectionHeading(t("admin.exportUserData"))} +
+ + {t("admin.exportUserDataDesc")} + + +
+ {!dataUnlocked && lockedNotice} +
+ + )} + + {activeTab === "hosts" && + (!dataUnlocked ? ( + lockedNotice + ) : ( +
+
+ + {t("admin.hostsCount", { count: hosts.length })} + +
+ + +
+
+ {!loading && hosts.length === 0 && ( + + {t("admin.noHostsForUser")} + + )} + {hosts.map((host) => ( +
+
+ + {host.name || host.ip} + + + {host.username ? `${host.username}@` : ""} + {host.ip}:{host.port} + +
+
+ {onOpenHostTab && ( + + )} + + +
+
+ ))} +
+ ))} + + {activeTab === "credentials" && + (!dataUnlocked ? ( + lockedNotice + ) : ( +
+
+ + {t("admin.credentialsCount", { count: credentials.length })} + +
+ + +
+
+ {!loading && credentials.length === 0 && ( + + {t("admin.noCredentialsForUser")} + + )} + {credentials.map((cred) => ( +
+
+ +
+ + {cred.name} + + + {cred.username || "-"} · {cred.type} + +
+
+
+ + +
+
+ ))} +
+ ))} + + {activeTab === "snippets" && + (!dataUnlocked ? ( + lockedNotice + ) : ( +
+
+ + {t("admin.snippetsCount", { count: snippets.length })} + +
+ + +
+
+ {editingSnippet && ( +
+ + setSnippetForm((p) => ({ ...p, name: e.target.value })) + } + /> +