diff --git a/src/components/ContainerTerminal.test.ts b/src/components/ContainerTerminal.test.ts index 3a92226..60c1464 100644 --- a/src/components/ContainerTerminal.test.ts +++ b/src/components/ContainerTerminal.test.ts @@ -121,8 +121,15 @@ describe("ContainerTerminal", () => { } } as any; - // Mock localStorage - vi.spyOn(Storage.prototype, "getItem").mockReturnValue("test-token"); + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn().mockReturnValue("test-token"), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + configurable: true, + }); }); afterEach(() => { @@ -223,6 +230,29 @@ describe("ContainerTerminal", () => { expect(wrapper.emitted("connected")).toBeTruthy(); }); + it("shows terminal disabled denial before auth success", async () => { + const wrapper = mountTerminal(); + const button = wrapper.find(".terminal-overlay button"); + await button.trigger("click"); + + mockWebSocket.onopen(); + mockWebSocket.onmessage({ + data: JSON.stringify({ + type: "error", + code: "protected_mode", + message: "Terminal access is disabled for this deployment by protected mode settings", + }), + }); + mockWebSocket.onclose(); + + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Terminal access is disabled for this deployment by protected mode settings"); + expect(wrapper.emitted("error")?.[0]).toEqual([ + "Terminal access is disabled for this deployment by protected mode settings", + ]); + }); + it("sends resize message after auth success", async () => { const wrapper = mountTerminal(); const button = wrapper.find(".terminal-overlay button"); diff --git a/src/components/ContainerTerminal.vue b/src/components/ContainerTerminal.vue index df7e4ff..8c59595 100644 --- a/src/components/ContainerTerminal.vue +++ b/src/components/ContainerTerminal.vue @@ -47,6 +47,7 @@ let resizeObserver: ResizeObserver | null = null; let resizeTimeout: ReturnType | null = null; let lastRows = 0; let lastCols = 0; +let explicitCloseMessage = ""; const getWebSocketUrl = () => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -70,6 +71,7 @@ const connect = () => { connectionStatus.value = "connecting"; statusMessage.value = "Connecting..."; authenticated = false; + explicitCloseMessage = ""; socket = new WebSocket(getWebSocketUrl()); socket.binaryType = "arraybuffer"; @@ -123,10 +125,27 @@ const connect = () => { } return; } + if (parsed.type === "error") { + const message = parsed.message || "Terminal connection denied"; + explicitCloseMessage = message; + connectionStatus.value = "error"; + statusMessage.value = message; + emit("error", message); + socket?.close(); + return; + } } catch { // Not JSON, might be an error message before auth } // If we get data before auth_success, show as error + // eslint-disable-next-line no-control-regex + const text = data.replace(/\x1b\[[0-9;]*m/g, "").trim(); + if (text) { + explicitCloseMessage = text; + connectionStatus.value = "error"; + statusMessage.value = text; + emit("error", text); + } if (terminal) { terminal.write(data); } @@ -139,8 +158,8 @@ const connect = () => { }; socket.onclose = () => { - connectionStatus.value = "disconnected"; - statusMessage.value = "Connection closed. Click Connect to reconnect."; + connectionStatus.value = explicitCloseMessage ? "error" : "disconnected"; + statusMessage.value = explicitCloseMessage || "Connection closed. Click Connect to reconnect."; authenticated = false; emit("disconnected"); }; diff --git a/src/components/DeploymentAccessField.vue b/src/components/DeploymentAccessField.vue new file mode 100644 index 0000000..40dabf0 --- /dev/null +++ b/src/components/DeploymentAccessField.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/src/components/FileBrowser.vue b/src/components/FileBrowser.vue index e449da1..ff99a2d 100644 --- a/src/components/FileBrowser.vue +++ b/src/components/FileBrowser.vue @@ -18,6 +18,11 @@ Hidden +
+
+ @@ -230,6 +269,36 @@ + +