mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -168,5 +168,11 @@
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,14 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["hosts:read", "db:own", "network:serve", "ui:surface"],
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"users:impersonate",
|
||||
"db:core-refs"
|
||||
],
|
||||
"dependencies": {
|
||||
"ssh-terminal": "^1.0.0"
|
||||
},
|
||||
@@ -96,7 +103,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": ["session.remoteDisplay.token", "terminal.open"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,16 +6,43 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"redis": "^6.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"xterm-android-ime"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"react-xtermjs": {
|
||||
"@xterm/xterm": "$@xterm/xterm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export interface SessionGuestsV1 {
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SESSION_GUESTS_KEY = "sessions.sharing.guests";
|
||||
export const SESSION_GUESTS_KEY = "session-sharing.guests";
|
||||
|
||||
function toRef(share: ShareRecord): SharedSessionRef {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { SquareTerminal } from "lucide-react";
|
||||
import {
|
||||
invokeAction,
|
||||
useHosts,
|
||||
usePermission,
|
||||
useTranslation,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Badge, Button } from "@termix/plugin-sdk/ui";
|
||||
import { getSharedWithMe } from "./api";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
interface SharedSession {
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
sharedByUsername: string | null;
|
||||
permissionLevel: string | null;
|
||||
shareId: string | null;
|
||||
}
|
||||
|
||||
function isSharedSession(value: unknown): value is SharedSession {
|
||||
const row = value as Partial<SharedSession> | null;
|
||||
return (
|
||||
!!row &&
|
||||
typeof row.sessionId === "string" &&
|
||||
typeof row.hostName === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active connections list's "Shared with me" section: live sessions other
|
||||
* users shared with this one, each joinable in a terminal tab.
|
||||
*/
|
||||
export function SharedWithMeSection({
|
||||
search = "",
|
||||
openTabData = [],
|
||||
}: {
|
||||
search?: string;
|
||||
openTabData?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const allowed = usePermission("use");
|
||||
const [sessions, setSessions] = useState<SharedSession[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!allowed) return;
|
||||
getSharedWithMe()
|
||||
.then((rows) => setSessions(rows.filter(isSharedSession)))
|
||||
.catch(() => setSessions([]));
|
||||
}, [allowed]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const joined = new Set(
|
||||
openTabData
|
||||
.map((data) => data.joinSharedSessionId)
|
||||
.filter((id): id is string => typeof id === "string"),
|
||||
);
|
||||
const q = search.trim().toLowerCase();
|
||||
const visible = sessions.filter(
|
||||
(session) =>
|
||||
!joined.has(session.sessionId) &&
|
||||
(!q ||
|
||||
session.hostName.toLowerCase().includes(q) ||
|
||||
(session.sharedByUsername ?? "").toLowerCase().includes(q)),
|
||||
);
|
||||
if (!allowed || visible.length === 0) return null;
|
||||
|
||||
const join = (session: SharedSession) => {
|
||||
if (!session.shareId) return;
|
||||
const host: PluginHostRecord = hosts.find(
|
||||
(candidate) => String(candidate.id) === String(session.hostId),
|
||||
) ?? {
|
||||
id: String(session.hostId),
|
||||
name: session.hostName,
|
||||
ip: "",
|
||||
port: 0,
|
||||
username: "",
|
||||
};
|
||||
void invokeAction("terminal.open", host, {
|
||||
joinSharedSessionId: session.sessionId,
|
||||
joinShareId: session.shareId,
|
||||
label: t("connections.sharedSessionLabel", {
|
||||
hostName: session.hostName,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||
{t("connections.sectionSharedWithMe")}
|
||||
</span>
|
||||
<span className="rounded bg-muted/60 px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground/40">
|
||||
{visible.length}
|
||||
</span>
|
||||
</div>
|
||||
{visible.map((session) => {
|
||||
const readWrite = session.permissionLevel === "read-write";
|
||||
return (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="group flex items-center gap-2.5 border-b border-border/40 px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded bg-muted/60 text-muted-foreground">
|
||||
<SquareTerminal className="size-3.5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${
|
||||
session.isConnected
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex-1 truncate text-xs font-semibold text-foreground">
|
||||
{session.hostName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`h-4 shrink-0 border-border/60 px-1 py-0 font-mono text-[9px] ${
|
||||
readWrite ? "text-accent-brand" : "text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{readWrite
|
||||
? t("sessionSharing.permissionLevel.readWrite")
|
||||
: t("sessionSharing.permissionLevel.readOnly")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="truncate pl-3 text-[10px] text-muted-foreground/60">
|
||||
{t("connections.sharedBy", {
|
||||
username: session.sharedByUsername ?? "?",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-2 text-[10px] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => join(session)}
|
||||
>
|
||||
{t("connections.join")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useEffect, type ComponentType } from "react";
|
||||
import { Presentation, Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -9,7 +11,8 @@ import {
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { bindApi, getSharedWithMe, listCollabRooms } from "./api";
|
||||
import { bindApi, listCollabRooms } from "./api";
|
||||
import { SharedWithMeSection } from "./SharedWithMeSection";
|
||||
import CollabGuestView from "./CollabGuestView";
|
||||
import { CollabPanel } from "./CollabPanel";
|
||||
import { CollabRoomTab } from "./CollabRoomTab";
|
||||
@@ -24,7 +27,6 @@ import { REMOTE_DISPLAY_SLOT } from "./shared";
|
||||
|
||||
const COLLAB = "collab";
|
||||
const SHARE_ACTION = "session-sharing.share";
|
||||
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
|
||||
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
|
||||
const INVITE_POLL_MS = 60_000;
|
||||
|
||||
@@ -237,9 +239,12 @@ export function activate(app: TermixApp): void {
|
||||
>,
|
||||
});
|
||||
|
||||
// Core's active connections list asks for sessions shared with the user.
|
||||
app.registerAction(SHARED_WITH_ME_ACTION, () => getSharedWithMe(), {
|
||||
permission: "use",
|
||||
// The "Shared with me" section of the active connections list.
|
||||
app.registerSlotContribution("connections.sections", {
|
||||
actionId: "session-sharing.sharedWithMe",
|
||||
titleKey: "connections.sectionSharedWithMe",
|
||||
kind: "component",
|
||||
component: SharedWithMeSection as ComponentType<Record<string, unknown>>,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function startServer(
|
||||
live,
|
||||
sharing: mock.services.get("sessions.sharing") as SessionSharingV1,
|
||||
guests: mock.ctx.registry.consume<SessionGuestsV1>(
|
||||
"sessions.sharing.guests",
|
||||
"session-sharing.guests",
|
||||
)!,
|
||||
async request(method, path, { user = "alice", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
|
||||
@@ -416,7 +416,9 @@ describe("room guest links", () => {
|
||||
roomGuestToken: "guess",
|
||||
clientIp: "9.9.9.9",
|
||||
});
|
||||
if (!result.ok && result.reason === "Too many requests") refused = true;
|
||||
if (result.ok === false && result.reason === "Too many requests") {
|
||||
refused = true;
|
||||
}
|
||||
}
|
||||
expect(refused).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getSharedWithMe: vi.fn(async () => [] as unknown[]),
|
||||
}));
|
||||
const sdk = vi.hoisted(() => ({
|
||||
invokeAction: vi.fn(async () => undefined),
|
||||
allowed: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/frontend/api", () => api);
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importActual) => ({
|
||||
...(await importActual<typeof import("@termix/plugin-sdk/frontend")>()),
|
||||
invokeAction: sdk.invokeAction,
|
||||
usePermission: () => sdk.allowed,
|
||||
useHosts: () => ({ hosts: [], loaded: true }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options ? `${key}:${JSON.stringify(options)}` : key,
|
||||
language: "en",
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SharedWithMeSection } from "../../src/frontend/SharedWithMeSection";
|
||||
|
||||
function shared(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sessionId: "sess-1",
|
||||
hostId: 5,
|
||||
hostName: "prod-db",
|
||||
isConnected: true,
|
||||
sharedByUsername: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "share-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api.getSharedWithMe.mockReset();
|
||||
sdk.invokeAction.mockClear();
|
||||
sdk.allowed = true;
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("shared with me section", () => {
|
||||
it("lists sessions shared with the user", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(<SharedWithMeSection />);
|
||||
|
||||
expect(await screen.findByText("prod-db")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('connections.sharedBy:{"username":"alice"}'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("sessionSharing.permissionLevel.readOnly"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides a session the user already joined in a tab", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(
|
||||
<SharedWithMeSection openTabData={[{ joinSharedSessionId: "sess-1" }]} />,
|
||||
);
|
||||
await vi.waitFor(() => expect(api.getSharedWithMe).toHaveBeenCalled());
|
||||
expect(screen.queryByText("prod-db")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by the connections search", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([
|
||||
shared(),
|
||||
shared({ sessionId: "sess-2", hostName: "web", sharedByUsername: "bob" }),
|
||||
]);
|
||||
render(<SharedWithMeSection search="bob" />);
|
||||
expect(await screen.findByText("web")).toBeTruthy();
|
||||
expect(screen.queryByText("prod-db")).toBeNull();
|
||||
});
|
||||
|
||||
it("joins through the terminal's open action", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(<SharedWithMeSection />);
|
||||
fireEvent.click(await screen.findByText("connections.join"));
|
||||
|
||||
expect(sdk.invokeAction).toHaveBeenCalledWith(
|
||||
"terminal.open",
|
||||
expect.objectContaining({ id: "5", name: "prod-db" }),
|
||||
expect.objectContaining({
|
||||
joinSharedSessionId: "sess-1",
|
||||
joinShareId: "share-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a user without the use permission", async () => {
|
||||
sdk.allowed = false;
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
const { container } = render(<SharedWithMeSection />);
|
||||
expect(container.textContent).toBe("");
|
||||
expect(api.getSharedWithMe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,9 @@ describe("session-sharing activate", () => {
|
||||
expect(rendered.registered.slot("shell.overlay")).toHaveLength(2);
|
||||
expect(rendered.registered.actions().sort()).toEqual([
|
||||
"session-sharing.share",
|
||||
"sessions.sharedWithMe",
|
||||
]);
|
||||
expect(rendered.registered.slot("connections.sections")).toEqual([
|
||||
"session-sharing.sharedWithMe",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -167,20 +169,6 @@ describe("session-sharing activate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("answers sessions shared with the user through the api", async () => {
|
||||
const shared = [{ sessionId: "s", isOwnSession: false }];
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi({
|
||||
get: vi.fn(async () => ({ data: shared })) as never,
|
||||
}),
|
||||
});
|
||||
await expect(invokeAction("sessions.sharedWithMe")).resolves.toEqual(
|
||||
shared,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
|
||||
Reference in new issue
Block a user