From c7785f5b1c367ca59dafdf0e1ba9f35482880c43 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 25 May 2026 11:21:06 +0100 Subject: [PATCH 1/4] fix(ui): Unblock /api-keys refresh by tightening the dev proxy The dev server proxied any path starting with the literal string "api" to the agent. The path "/api-keys" matched that prefix and was being sent to a backend that has no such endpoint, so refreshing the API keys page returned a 404 instead of letting the SPA router handle the route. The proxy now matches only paths starting with "/api/", so the front end's own routes that share the "api" prefix fall through to the SPA fallback as expected. --- vite.config.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 869245a..6633826 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,25 +1,25 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import { fileURLToPath, URL } from 'node:url' -import pkg from './package.json' +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { fileURLToPath, URL } from "node:url"; +import pkg from "./package.json"; export default defineConfig({ plugins: [vue()], define: { - __APP_VERSION__: JSON.stringify(pkg.version) + __APP_VERSION__: JSON.stringify(pkg.version), }, resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, }, server: { port: 5173, proxy: { - '/api': { - target: 'http://localhost:8090', - changeOrigin: true - } - } - } -}) + "^/api/": { + target: "http://localhost:8090", + changeOrigin: true, + }, + }, + }, +}); From c26a05a262b9a19f226ee60b46d7b5a9bf5e1fff Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 25 May 2026 11:21:31 +0100 Subject: [PATCH 2/4] feat: Add API key edit, protected mode UI, file manager improvements, system views API keys can now be edited from the UI in a tabbed dialog that mirrors the user dialog: profile, permissions, and a deployment access tab that grants per-deployment read, write, or admin level. The tabbed modal shell and the deployment access field are extracted into shared components so the user dialog and the API key dialog use exactly the same primitives. The blanket 401 interceptor that logged people out on any per-resource auth error is narrowed to only redirect on session endpoints, so a refused create or update surfaces as a toast instead of a forced sign-out. The deployment protected-mode panel and the global system-terminal protection panel share a single explainer helper, so each blocked command rule renders a human-readable description ("blocks any command containing rm -rf") and the add form previews the rule as it is typed. Tooltips on the blocked-action chips state what each action covers. The file browser becomes context-agnostic: it accepts an injected API adapter and renders the same UI for deployment files and for a new system-wide file manager. Files and folders can be created in place, permissions edited through a per-bit chmod dialog, and row actions are collapsed into an overflow menu to reduce accidental clicks. Hidden and system folders are hidden by default with toggles to reveal them, and the manager opens in the user's home directory while still allowing navigation up to the configured root. New routes mount the system-wide file manager and the system terminal in the dashboard, both gated by the corresponding new permissions threaded through the role defaults. Closes #117 Closes #122 Closes #123 --- src/components/ContainerTerminal.test.ts | 34 +- src/components/ContainerTerminal.vue | 22 +- src/components/DeploymentAccessField.vue | 252 +++++++++++ src/components/FileBrowser.vue | 508 +++++++++++++++++++++-- src/components/TabbedFormModal.vue | 227 ++++++++++ src/layouts/DashboardLayout.vue | 29 +- src/router/index.ts | 12 + src/services/api.ts | 74 +++- src/stores/users.ts | 28 +- src/types/index.ts | 27 +- src/utils/permissions.ts | 1 + src/utils/protectedMode.ts | 32 ++ src/views/APIKeysView.vue | 367 ++++++++++++---- src/views/DeploymentDetailView.vue | 353 +++++++++++++++- src/views/SettingsView.vue | 299 ++++++++++++- src/views/SystemFilesView.vue | 41 ++ src/views/SystemTerminalView.vue | 238 +++++++++++ src/views/UsersView.vue | 342 ++++++--------- 18 files changed, 2539 insertions(+), 347 deletions(-) create mode 100644 src/components/DeploymentAccessField.vue create mode 100644 src/components/TabbedFormModal.vue create mode 100644 src/utils/protectedMode.ts create mode 100644 src/views/SystemFilesView.vue create mode 100644 src/views/SystemTerminalView.vue 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..3c6ab8d 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,26 @@ 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 + 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 +157,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..07f07f1 100644 --- a/src/components/FileBrowser.vue +++ b/src/components/FileBrowser.vue @@ -18,6 +18,11 @@ Hidden +
+
+ @@ -230,6 +269,36 @@ + +