mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda70dcc5d | ||
|
|
ab3edbdf7b | ||
|
|
f9a6b451c2 | ||
|
|
4d41a02efa |
No files matched your search
@@ -9,6 +9,11 @@
|
|||||||
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
|
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
|
||||||
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
|
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
|
||||||
|
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
|
||||||
|
|
||||||
## 26.10.5
|
## 26.10.5
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import type { RequestHandler, Router } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { createCurrentUserRepository } from "../repositories/factory.js";
|
||||||
|
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||||
|
|
||||||
|
function isNonEmptyString(val: unknown): val is string {
|
||||||
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUserDeleteAccountRoutes(
|
||||||
|
router: Router,
|
||||||
|
authenticateJWT: RequestHandler,
|
||||||
|
): void {
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/delete-account:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete user account
|
||||||
|
* description: Deletes the authenticated user's account and all of their data.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Account deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Password is required.
|
||||||
|
* 401:
|
||||||
|
* description: Incorrect password.
|
||||||
|
* 403:
|
||||||
|
* description: Cannot delete external authentication accounts or the last admin user.
|
||||||
|
* 404:
|
||||||
|
* description: User not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete account.
|
||||||
|
*/
|
||||||
|
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { password } = req.body ?? {};
|
||||||
|
|
||||||
|
if (!isNonEmptyString(password)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Password is required to delete account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const userRecord = await userRepository.findById(userId);
|
||||||
|
if (!userRecord) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRecord.isExternal) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error:
|
||||||
|
"Cannot delete external authentication accounts through this endpoint",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
authLogger.warn(
|
||||||
|
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
||||||
|
);
|
||||||
|
return res.status(401).json({ error: "Incorrect password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Cannot delete the last admin user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same cleanup as an admin delete, so plugins hear user.deleted too.
|
||||||
|
await deleteUserAndRelatedData(userId);
|
||||||
|
|
||||||
|
authLogger.success(`User account deleted: ${userRecord.username}`);
|
||||||
|
res.json({ message: "Account deleted successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to delete user account", err);
|
||||||
|
res.status(500).json({ error: "Failed to delete account" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
|
|||||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||||
|
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
|
||||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||||
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
"Rejected proxy authentication from an untrusted source",
|
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
|
||||||
{
|
{
|
||||||
operation: "trusted_proxy_auth_rejected",
|
operation: "trusted_proxy_auth_rejected",
|
||||||
sourceAddress,
|
sourceAddress,
|
||||||
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
registerUserDeleteAccountRoutes(router, authenticateJWT);
|
||||||
* @openapi
|
|
||||||
* /users/delete-account:
|
|
||||||
* delete:
|
|
||||||
* summary: Delete user account
|
|
||||||
* description: Deletes the authenticated user's account.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* password:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Account deleted successfully.
|
|
||||||
* 400:
|
|
||||||
* description: Password is required.
|
|
||||||
* 401:
|
|
||||||
* description: Incorrect password.
|
|
||||||
* 403:
|
|
||||||
* description: Cannot delete external authentication accounts or the last admin user.
|
|
||||||
* 404:
|
|
||||||
* description: User not found.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to delete account.
|
|
||||||
*/
|
|
||||||
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
const { password } = req.body;
|
|
||||||
|
|
||||||
if (!isNonEmptyString(password)) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Password is required to delete account" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userRecord = await findCurrentUser(userId);
|
|
||||||
if (!userRecord) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRecord.isExternal) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error:
|
|
||||||
"Cannot delete external authentication accounts through this endpoint",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
|
||||||
if (!isMatch) {
|
|
||||||
authLogger.warn(
|
|
||||||
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
|
||||||
);
|
|
||||||
return res.status(401).json({ error: "Incorrect password" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRecord.isAdmin) {
|
|
||||||
const adminCount = await createCurrentUserRepository().countAdmins();
|
|
||||||
if (adminCount <= 1) {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.json({ error: "Cannot delete the last admin user" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await createCurrentUserRepository().delete(userId);
|
|
||||||
|
|
||||||
authLogger.success(`User account deleted: ${userRecord.username}`);
|
|
||||||
res.json({ message: "Account deleted successfully" });
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to delete user account", err);
|
|
||||||
res.status(500).json({ error: "Failed to delete account" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerUserPasswordResetRoutes(router, { authManager });
|
registerUserPasswordResetRoutes(router, { authManager });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import type { Request, RequestHandler, Response } from "express";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
users: new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isExternal: boolean;
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
adminCount: 1,
|
||||||
|
deleted: [] as string[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
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("bcryptjs", () => ({
|
||||||
|
default: {
|
||||||
|
compare: async (password: string, hash: string) =>
|
||||||
|
hash === `hash:${password}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentUserRepository: () => ({
|
||||||
|
findById: async (id: string) => state.users.get(id) ?? null,
|
||||||
|
countAdmins: async () => state.adminCount,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
||||||
|
deleteUserAndRelatedData: async (userId: string) => {
|
||||||
|
state.deleted.push(userId);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { registerUserDeleteAccountRoutes } =
|
||||||
|
await import("../../../database/routes/user-delete-account-routes.js");
|
||||||
|
|
||||||
|
let handler: RequestHandler | null = null;
|
||||||
|
registerUserDeleteAccountRoutes(
|
||||||
|
{
|
||||||
|
delete: (_path: string, ...handlers: RequestHandler[]) => {
|
||||||
|
handler = handlers[handlers.length - 1];
|
||||||
|
},
|
||||||
|
} as unknown as import("express").Router,
|
||||||
|
(_req, _res, next) => next(),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function deleteAccount(userId: string, body: Record<string, unknown>) {
|
||||||
|
const req = { userId, body } as unknown as Request;
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
jsonBody: null as Record<string, unknown> | null,
|
||||||
|
status(code: number) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload: Record<string, unknown>) {
|
||||||
|
this.jsonBody = payload;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await handler!(req, res as unknown as Response, () => {});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.adminCount = 1;
|
||||||
|
state.deleted = [];
|
||||||
|
state.users = new Map([
|
||||||
|
[
|
||||||
|
"u1",
|
||||||
|
{
|
||||||
|
id: "u1",
|
||||||
|
username: "user",
|
||||||
|
isAdmin: false,
|
||||||
|
isExternal: false,
|
||||||
|
passwordHash: "hash:secret",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"admin1",
|
||||||
|
{
|
||||||
|
id: "admin1",
|
||||||
|
username: "admin",
|
||||||
|
isAdmin: true,
|
||||||
|
isExternal: false,
|
||||||
|
passwordHash: "hash:secret",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"sso1",
|
||||||
|
{
|
||||||
|
id: "sso1",
|
||||||
|
username: "sso",
|
||||||
|
isAdmin: false,
|
||||||
|
isExternal: true,
|
||||||
|
passwordHash: "hash:secret",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /delete-account", () => {
|
||||||
|
it("runs the full user cleanup", async () => {
|
||||||
|
const res = await deleteAccount("u1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(state.deleted).toEqual(["u1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs a password", async () => {
|
||||||
|
const res = await deleteAccount("u1", {});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a wrong password", async () => {
|
||||||
|
const res = await deleteAccount("u1", { password: "nope" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses external accounts", async () => {
|
||||||
|
const res = await deleteAccount("sso1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the last admin", async () => {
|
||||||
|
const res = await deleteAccount("admin1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an admin go when another admin is left", async () => {
|
||||||
|
state.adminCount = 2;
|
||||||
|
const res = await deleteAccount("admin1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(state.deleted).toEqual(["admin1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s for an unknown user", async () => {
|
||||||
|
const res = await deleteAccount("ghost", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import { getTabType, type TabRenderProps } from "@/shell/tab-registry";
|
|||||||
import { getPanel } from "@/shell/panel-registry";
|
import { getPanel } from "@/shell/panel-registry";
|
||||||
import { getSSHHosts } from "@/main-axios";
|
import { getSSHHosts } from "@/main-axios";
|
||||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||||
|
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
|
||||||
import type { Host, Tab } from "@/types/ui-types";
|
import type { Host, Tab } from "@/types/ui-types";
|
||||||
import { createPluginApp } from "./app";
|
import { createPluginApp } from "./app";
|
||||||
import { installPluginHostBridge } from "./bridge";
|
import { installPluginHostBridge } from "./bridge";
|
||||||
@@ -150,6 +151,11 @@ function FrameBody({
|
|||||||
key={`${view.view}:${view.hostId ?? ""}:${version}`}
|
key={`${view.view}:${view.hostId ?? ""}:${version}`}
|
||||||
tab={record}
|
tab={record}
|
||||||
host={host}
|
host={host}
|
||||||
|
sshHost={
|
||||||
|
host
|
||||||
|
? (hostToSSHHost(host) as unknown as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
label={view.view}
|
label={view.view}
|
||||||
isVisible
|
isVisible
|
||||||
isFocusedPane
|
isFocusedPane
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import type { PluginSummary } from "@/api/plugins-api";
|
|||||||
vi.mock("@/main-axios", async (importOriginal) => ({
|
vi.mock("@/main-axios", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/main-axios")>()),
|
...(await importOriginal<typeof import("@/main-axios")>()),
|
||||||
getSSHHosts: vi.fn(async () => [
|
getSSHHosts: vi.fn(async () => [
|
||||||
{ id: 7, name: "web-01", ip: "10.0.0.7", port: 22, username: "root" },
|
{
|
||||||
|
id: 7,
|
||||||
|
name: "web-01",
|
||||||
|
ip: "10.0.0.7",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
pluginSettings: { gizmo: { enableGizmo: true } },
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -59,8 +66,19 @@ function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostView({ host }: { host?: { name: string } }) {
|
function HostView({
|
||||||
return <p>host view: {host?.name ?? "none"}</p>;
|
host,
|
||||||
|
sshHost,
|
||||||
|
}: {
|
||||||
|
host?: { name: string };
|
||||||
|
sshHost?: { pluginSettings?: { gizmo?: { enableGizmo?: boolean } } };
|
||||||
|
}) {
|
||||||
|
const on = sshHost?.pluginSettings?.gizmo?.enableGizmo === true;
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
host view: {host?.name ?? "none"} {on ? "on" : "off"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const module = {
|
const module = {
|
||||||
@@ -159,7 +177,7 @@ describe("startPluginFrame", () => {
|
|||||||
shell: makeShell(),
|
shell: makeShell(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() => screen.getByText("host view: web-01"));
|
await waitFor(() => screen.getByText("host view: web-01 on"));
|
||||||
act(() => frame!.setView({ view: "gizmo-home" }));
|
act(() => frame!.setView({ view: "gizmo-home" }));
|
||||||
await waitFor(() => screen.getByText("hosts: web-01"));
|
await waitFor(() => screen.getByText("hosts: web-01"));
|
||||||
act(() => frame!.setTheme({ id: "nord", mode: "dark" }));
|
act(() => frame!.setTheme({ id: "nord", mode: "dark" }));
|
||||||
|
|||||||
Reference in new issue
Block a user