diff --git a/ui/web/src/i18n/locales/en/workstations.json b/ui/web/src/i18n/locales/en/workstations.json index 14e7fd93..18df231b 100644 --- a/ui/web/src/i18n/locales/en/workstations.json +++ b/ui/web/src/i18n/locales/en/workstations.json @@ -43,14 +43,34 @@ "portLabel": "Port", "userLabel": "SSH User", "userPlaceholder": "ubuntu", - "identityFileLabel": "Identity File (optional)", - "identityFilePlaceholder": "~/.ssh/id_rsa", + "authMethodLabel": "Authentication", + "authPrivateKeyOption": "Private key", + "authPasswordOption": "Password", + "privateKeyLabel": "Private Key (PEM)", + "privateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "privateKeyHint": "Pasted key material is encrypted at rest and never returned by the API.", + "passwordLabel": "SSH Password", + "passwordPlaceholder": "Password for the SSH user", + "passwordHint": "Prefer key-based auth where possible.", "containerLabel": "Container Name / ID", "containerPlaceholder": "my-container", - "dockerHostLabel": "Docker Host (optional)", - "dockerHostPlaceholder": "unix:///var/run/docker.sock", + "imageLabel": "Image", + "imagePlaceholder": "ubuntu:24.04", + "socketPathLabel": "Docker Socket Path (optional)", + "socketPathPlaceholder": "unix:///var/run/docker.sock", "cancel": "Cancel", - "create": "Create" + "create": "Create", + "errors": { + "keyRequired": "Workstation key is required.", + "nameRequired": "Display name is required.", + "sshHostUserRequired": "Host and SSH user are required for the SSH backend.", + "sshPortRange": "Port must be between 1 and 65535.", + "sshPrivateKeyRequired": "A private key is required for key-based authentication.", + "sshPasswordRequired": "A password is required for password authentication.", + "dockerContainerRequired": "Container name is required for the Docker backend.", + "dockerImageRequired": "Image is required for the Docker backend.", + "createFailed": "Failed to create workstation." + } }, "deleteDialog": { "title": "Delete Workstation", diff --git a/ui/web/src/i18n/locales/vi/workstations.json b/ui/web/src/i18n/locales/vi/workstations.json index 60d421ce..0932c430 100644 --- a/ui/web/src/i18n/locales/vi/workstations.json +++ b/ui/web/src/i18n/locales/vi/workstations.json @@ -43,14 +43,34 @@ "portLabel": "Cổng", "userLabel": "SSH User", "userPlaceholder": "ubuntu", - "identityFileLabel": "File khóa (tùy chọn)", - "identityFilePlaceholder": "~/.ssh/id_rsa", + "authMethodLabel": "Xác thực", + "authPrivateKeyOption": "Khóa riêng tư", + "authPasswordOption": "Mật khẩu", + "privateKeyLabel": "Khóa riêng tư (PEM)", + "privateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "privateKeyHint": "Nội dung khóa được mã hóa khi lưu và không bao giờ trả về qua API.", + "passwordLabel": "Mật khẩu SSH", + "passwordPlaceholder": "Mật khẩu của SSH user", + "passwordHint": "Nên ưu tiên xác thực bằng khóa khi có thể.", "containerLabel": "Tên / ID container", "containerPlaceholder": "my-container", - "dockerHostLabel": "Docker Host (tùy chọn)", - "dockerHostPlaceholder": "unix:///var/run/docker.sock", + "imageLabel": "Image", + "imagePlaceholder": "ubuntu:24.04", + "socketPathLabel": "Đường dẫn Docker socket (tùy chọn)", + "socketPathPlaceholder": "unix:///var/run/docker.sock", "cancel": "Hủy", - "create": "Tạo" + "create": "Tạo", + "errors": { + "keyRequired": "Vui lòng nhập khóa workstation.", + "nameRequired": "Vui lòng nhập tên hiển thị.", + "sshHostUserRequired": "Backend SSH yêu cầu Host và SSH user.", + "sshPortRange": "Cổng phải nằm trong khoảng 1 đến 65535.", + "sshPrivateKeyRequired": "Cần khóa riêng tư khi xác thực bằng khóa.", + "sshPasswordRequired": "Cần mật khẩu khi xác thực bằng mật khẩu.", + "dockerContainerRequired": "Backend Docker yêu cầu tên container.", + "dockerImageRequired": "Backend Docker yêu cầu image.", + "createFailed": "Tạo workstation thất bại." + } }, "deleteDialog": { "title": "Xóa Workstation", diff --git a/ui/web/src/i18n/locales/zh/workstations.json b/ui/web/src/i18n/locales/zh/workstations.json index 92773a1b..77429a8d 100644 --- a/ui/web/src/i18n/locales/zh/workstations.json +++ b/ui/web/src/i18n/locales/zh/workstations.json @@ -43,14 +43,34 @@ "portLabel": "端口", "userLabel": "SSH 用户", "userPlaceholder": "ubuntu", - "identityFileLabel": "密钥文件(可选)", - "identityFilePlaceholder": "~/.ssh/id_rsa", + "authMethodLabel": "身份验证", + "authPrivateKeyOption": "私钥", + "authPasswordOption": "密码", + "privateKeyLabel": "私钥(PEM)", + "privateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "privateKeyHint": "粘贴的密钥内容会加密存储,且不会通过 API 返回。", + "passwordLabel": "SSH 密码", + "passwordPlaceholder": "SSH 用户的密码", + "passwordHint": "在可能的情况下优先使用密钥认证。", "containerLabel": "容器名称 / ID", "containerPlaceholder": "my-container", - "dockerHostLabel": "Docker Host(可选)", - "dockerHostPlaceholder": "unix:///var/run/docker.sock", + "imageLabel": "镜像", + "imagePlaceholder": "ubuntu:24.04", + "socketPathLabel": "Docker Socket 路径(可选)", + "socketPathPlaceholder": "unix:///var/run/docker.sock", "cancel": "取消", - "create": "创建" + "create": "创建", + "errors": { + "keyRequired": "请填写工作站键名。", + "nameRequired": "请填写显示名称。", + "sshHostUserRequired": "SSH 后端需要填写主机和 SSH 用户。", + "sshPortRange": "端口必须在 1 到 65535 之间。", + "sshPrivateKeyRequired": "使用密钥认证时必须提供私钥。", + "sshPasswordRequired": "使用密码认证时必须提供密码。", + "dockerContainerRequired": "Docker 后端需要填写容器名称。", + "dockerImageRequired": "Docker 后端需要填写镜像。", + "createFailed": "创建工作站失败。" + } }, "deleteDialog": { "title": "删除工作站", diff --git a/ui/web/src/pages/workstations/__tests__/workstation-contract.test.ts b/ui/web/src/pages/workstations/__tests__/workstation-contract.test.ts new file mode 100644 index 00000000..46add07a --- /dev/null +++ b/ui/web/src/pages/workstations/__tests__/workstation-contract.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorkstationCreatePayload, + type WorkstationCreateFormState, +} from "../workstation-create-dialog-helpers"; +import type { Workstation } from "../hooks/use-workstations"; + +function form(overrides: Partial = {}): WorkstationCreateFormState { + return { + key: "dev-server", + name: "Dev Server", + backend: "ssh", + host: "192.168.1.100", + port: "22", + user: "ubuntu", + authMethod: "privateKey", + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----", + password: "", + container: "", + image: "", + socketPath: "", + ...overrides, + }; +} + +describe("workstation create payload contract", () => { + it("submits camelCase params the gateway handler actually decodes", () => { + const result = buildWorkstationCreatePayload(form()); + + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") return; + + // Regression guard for #1258: snake_case here decodes to the zero value in + // Go and the handler rejects the request with "workstationKey is required". + expect(Object.keys(result.payload).sort()).toEqual([ + "backendType", + "metadata", + "name", + "workstationKey", + ]); + expect(result.payload.workstationKey).toBe("dev-server"); + expect(result.payload.backendType).toBe("ssh"); + expect(result.payload).not.toHaveProperty("workstation_key"); + expect(result.payload).not.toHaveProperty("backend_type"); + }); + + it("sends SSH credentials as privateKey, not the unsupported identity_file", () => { + const result = buildWorkstationCreatePayload(form()); + if (result.kind !== "ok") throw new Error("expected ok"); + + // store.SSHMetadata accepts privateKey or password only; identity_file was + // silently dropped and left the request failing metadata validation. + expect(result.payload.metadata).toEqual({ + host: "192.168.1.100", + port: 22, + user: "ubuntu", + privateKey: expect.stringContaining("BEGIN OPENSSH PRIVATE KEY"), + }); + expect(result.payload.metadata).not.toHaveProperty("identity_file"); + }); + + it("sends a password when password auth is selected", () => { + const result = buildWorkstationCreatePayload( + form({ authMethod: "password", privateKey: "", password: "hunter2" }), + ); + if (result.kind !== "ok") throw new Error("expected ok"); + + expect(result.payload.metadata).toMatchObject({ password: "hunter2" }); + expect(result.payload.metadata).not.toHaveProperty("privateKey"); + }); + + it("rejects SSH auth with no credential rather than letting the backend 400", () => { + expect( + buildWorkstationCreatePayload(form({ privateKey: "" })), + ).toEqual({ kind: "error", errorKey: "sshPrivateKeyRequired" }); + + expect( + buildWorkstationCreatePayload(form({ authMethod: "password", privateKey: "", password: "" })), + ).toEqual({ kind: "error", errorKey: "sshPasswordRequired" }); + }); + + it("maps the Docker container name onto host and requires an image", () => { + const ok = buildWorkstationCreatePayload( + form({ backend: "docker", container: "my-box", image: "ubuntu:24.04", socketPath: "" }), + ); + if (ok.kind !== "ok") throw new Error("expected ok"); + + // store.DockerMetadata carries the container name in `host` and requires + // `image`; the old payload sent {container, docker_host} and always failed. + expect(ok.payload.metadata).toEqual({ host: "my-box", image: "ubuntu:24.04" }); + + expect( + buildWorkstationCreatePayload(form({ backend: "docker", container: "my-box", image: "" })), + ).toEqual({ kind: "error", errorKey: "dockerImageRequired" }); + }); + + it("requires a workstation key before hitting the network", () => { + expect(buildWorkstationCreatePayload(form({ key: " " }))).toEqual({ + kind: "error", + errorKey: "keyRequired", + }); + }); +}); + +describe("workstation list contract", () => { + // Verbatim body of store.Workstation.SanitizedView() as serialized by the + // gateway, kept complete even though the UI type is a narrower subset — that + // is what proves the subset is actually assignable from a real response. + // If the TS interface drifts back to snake_case this stops compiling. + const apiResponse = { + id: "8f2b0f7e-1c4a-4c9e-9f1a-2b3c4d5e6f70", + workstationKey: "aether", + tenantId: "11111111-2222-3333-4444-555555555555", + name: "Aether", + backendType: "ssh", + defaultCwd: "/home/ubuntu", + active: true, + createdAt: "2026-06-22T15:06:38Z", + updatedAt: "2026-06-22T15:06:38Z", + createdBy: "admin", + metadataSummary: { host: "10.0.0.5", port: 22, user: "ubuntu", hasKey: true }, + } as const; + + it("resolves every field the workstations table renders", () => { + const ws: Workstation = apiResponse; + + // Regression guard for #1258: these read undefined under the old snake_case + // interface, rendering a blank key, "backend.undefined" and "Invalid Date". + expect(ws.workstationKey).toBe("aether"); + expect(ws.backendType).toBe("ssh"); + expect(Number.isNaN(new Date(ws.createdAt).getTime())).toBe(false); + expect(ws.name).toBe("Aether"); + expect(ws.active).toBe(true); + }); +}); diff --git a/ui/web/src/pages/workstations/hooks/use-workstations.ts b/ui/web/src/pages/workstations/hooks/use-workstations.ts index 142576a0..2b210000 100644 --- a/ui/web/src/pages/workstations/hooks/use-workstations.ts +++ b/ui/web/src/pages/workstations/hooks/use-workstations.ts @@ -3,20 +3,28 @@ import { useWs } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; +export type WorkstationBackendType = "ssh" | "docker"; + +/** + * Subset of store.SanitizedWorkstation that this page consumes. Field names are + * camelCase to match the Go json tags — the WS client sends and receives params + * verbatim, so any divergence here silently yields undefined rather than a + * decode error. + */ export interface Workstation { id: string; - workstation_key: string; + workstationKey: string; name: string; - backend_type: "ssh" | "docker"; + backendType: WorkstationBackendType; active: boolean; - created_at: string; - updated_at: string; + createdAt: string; + updatedAt: string; } export interface CreateWorkstationParams { - workstation_key: string; + workstationKey: string; name: string; - backend_type: "ssh" | "docker"; + backendType: WorkstationBackendType; metadata?: Record; } @@ -62,7 +70,9 @@ export function useWorkstations() { const updateWorkstation = useCallback( async (id: string, params: UpdateWorkstationParams): Promise => { - await ws.call(Methods.WORKSTATIONS_UPDATE, { id, ...params }); + // The handler reads a nested `updates` map; a flattened body decodes to an + // empty map and is rejected with "no updates provided". + await ws.call(Methods.WORKSTATIONS_UPDATE, { id, updates: params }); await load(); }, [ws, load], diff --git a/ui/web/src/pages/workstations/workstation-create-dialog-helpers.ts b/ui/web/src/pages/workstations/workstation-create-dialog-helpers.ts new file mode 100644 index 00000000..3efe76b2 --- /dev/null +++ b/ui/web/src/pages/workstations/workstation-create-dialog-helpers.ts @@ -0,0 +1,97 @@ +import type { CreateWorkstationParams, WorkstationBackendType } from "./hooks/use-workstations"; + +/** Auth method for the SSH backend. The gateway accepts inline PEM or a password. */ +export type SshAuthMethod = "privateKey" | "password"; + +export interface WorkstationCreateFormState { + key: string; + name: string; + backend: WorkstationBackendType; + // SSH + host: string; + port: string; + user: string; + authMethod: SshAuthMethod; + privateKey: string; + password: string; + // Docker + container: string; + image: string; + socketPath: string; +} + +export type BuildCreatePayloadResult = + | { kind: "ok"; payload: CreateWorkstationParams } + | { kind: "error"; errorKey: string }; + +/** + * Builds the workstations.create RPC payload from dialog form state. + * + * Field names here must match the gateway wire contract exactly — the WS client + * serializes params verbatim with no case conversion, and Go's json decoder + * silently drops unknown members rather than erroring. A mismatched name + * therefore surfaces as " is required" rather than as a decode failure. + * + * Metadata shape mirrors store.SSHMetadata / store.DockerMetadata: SSH requires + * privateKey or password, and Docker requires an image plus host or socketPath. + */ +export function buildWorkstationCreatePayload( + form: WorkstationCreateFormState, +): BuildCreatePayloadResult { + const key = form.key.trim(); + const name = form.name.trim(); + + if (!key) return { kind: "error", errorKey: "keyRequired" }; + if (!name) return { kind: "error", errorKey: "nameRequired" }; + + let metadata: Record; + + if (form.backend === "ssh") { + const host = form.host.trim(); + const user = form.user.trim(); + if (!host || !user) return { kind: "error", errorKey: "sshHostUserRequired" }; + + const port = parseInt(form.port, 10) || 22; + if (port < 1 || port > 65535) return { kind: "error", errorKey: "sshPortRange" }; + + const privateKey = form.privateKey.trim(); + const password = form.password; + if (form.authMethod === "privateKey" && !privateKey) { + return { kind: "error", errorKey: "sshPrivateKeyRequired" }; + } + if (form.authMethod === "password" && !password) { + return { kind: "error", errorKey: "sshPasswordRequired" }; + } + + metadata = { + host, + port, + user, + ...(form.authMethod === "privateKey" ? { privateKey } : { password }), + }; + } else { + // The gateway stores the container name in DockerMetadata.host and surfaces + // it back as metadataSummary.containerName. + const container = form.container.trim(); + const image = form.image.trim(); + const socketPath = form.socketPath.trim(); + if (!container) return { kind: "error", errorKey: "dockerContainerRequired" }; + if (!image) return { kind: "error", errorKey: "dockerImageRequired" }; + + metadata = { + host: container, + image, + ...(socketPath ? { socketPath } : {}), + }; + } + + return { + kind: "ok", + payload: { + workstationKey: key, + name, + backendType: form.backend, + metadata, + }, + }; +} diff --git a/ui/web/src/pages/workstations/workstation-create-dialog.tsx b/ui/web/src/pages/workstations/workstation-create-dialog.tsx index 563821a8..03a3b328 100644 --- a/ui/web/src/pages/workstations/workstation-create-dialog.tsx +++ b/ui/web/src/pages/workstations/workstation-create-dialog.tsx @@ -18,7 +18,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { CreateWorkstationParams } from "./hooks/use-workstations"; +import { Textarea } from "@/components/ui/textarea"; +import type { CreateWorkstationParams, WorkstationBackendType } from "./hooks/use-workstations"; +import { + buildWorkstationCreatePayload, + type SshAuthMethod, +} from "./workstation-create-dialog-helpers"; interface WorkstationCreateDialogProps { open: boolean; @@ -26,8 +31,6 @@ interface WorkstationCreateDialogProps { onCreate: (params: CreateWorkstationParams) => Promise; } -type BackendType = "ssh" | "docker"; - export function WorkstationCreateDialog({ open, onOpenChange, @@ -37,15 +40,18 @@ export function WorkstationCreateDialog({ const [name, setName] = useState(""); const [key, setKey] = useState(""); - const [backend, setBackend] = useState("ssh"); + const [backend, setBackend] = useState("ssh"); // SSH fields const [host, setHost] = useState(""); const [port, setPort] = useState("22"); const [user, setUser] = useState(""); - const [identityFile, setIdentityFile] = useState(""); + const [authMethod, setAuthMethod] = useState("privateKey"); + const [privateKey, setPrivateKey] = useState(""); + const [password, setPassword] = useState(""); // Docker fields const [container, setContainer] = useState(""); - const [dockerHost, setDockerHost] = useState(""); + const [image, setImage] = useState(""); + const [socketPath, setSocketPath] = useState(""); const [submitting, setSubmitting] = useState(false); const [fieldError, setFieldError] = useState(null); @@ -57,48 +63,45 @@ export function WorkstationCreateDialog({ setHost(""); setPort("22"); setUser(""); - setIdentityFile(""); + setAuthMethod("privateKey"); + setPrivateKey(""); + setPassword(""); setContainer(""); - setDockerHost(""); + setImage(""); + setSocketPath(""); setFieldError(null); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!name.trim() || !key.trim()) return; - // Build backend metadata - let metadata: Record; - if (backend === "ssh") { - if (!host.trim() || !user.trim()) { - setFieldError("Host and SSH user are required for SSH backend."); - return; - } - metadata = { - host: host.trim(), - port: parseInt(port, 10) || 22, - user: user.trim(), - ...(identityFile.trim() ? { identity_file: identityFile.trim() } : {}), - }; - } else { - if (!container.trim()) { - setFieldError("Container name is required for Docker backend."); - return; - } - metadata = { - container: container.trim(), - ...(dockerHost.trim() ? { docker_host: dockerHost.trim() } : {}), - }; + const built = buildWorkstationCreatePayload({ + key, + name, + backend, + host, + port, + user, + authMethod, + privateKey, + password, + container, + image, + socketPath, + }); + if (built.kind === "error") { + setFieldError(t(`createDialog.errors.${built.errorKey}`)); + return; } setFieldError(null); setSubmitting(true); try { - await onCreate({ workstation_key: key.trim(), name: name.trim(), backend_type: backend, metadata }); + await onCreate(built.payload); resetForm(); onOpenChange(false); } catch (err) { - setFieldError(err instanceof Error ? err.message : "Failed to create workstation."); + setFieldError(err instanceof Error ? err.message : t("createDialog.errors.createFailed")); } finally { setSubmitting(false); } @@ -141,7 +144,7 @@ export function WorkstationCreateDialog({
- setBackend(v as WorkstationBackendType)}> @@ -189,15 +192,48 @@ export function WorkstationCreateDialog({ />
- - setIdentityFile(e.target.value)} - placeholder={t("createDialog.identityFilePlaceholder")} - className="text-base md:text-sm" - /> + +
+ + {authMethod === "privateKey" ? ( +
+ +