From 29e7ae3e80f25ba22399e21d40e38ab0e20332e7 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 13 Apr 2026 20:36:57 +0200 Subject: [PATCH 01/11] fix(dashboard): make auth prompt visible and resilient --- .../admin/dashboard/static/css/dashboard.css | 148 +++++++++++++++++- .../admin/dashboard/static/js/dashboard.js | 73 +++++++-- .../dashboard/static/js/modules/aliases.js | 10 +- .../dashboard/static/js/modules/audit-list.js | 5 +- .../dashboard/static/js/modules/auth-keys.js | 5 +- .../js/modules/contribution-calendar.js | 4 +- .../static/js/modules/conversation-drawer.js | 5 +- .../js/modules/dashboard-display.test.js | 90 ++++++++++- .../js/modules/dashboard-layout.test.js | 52 +++++- .../static/js/modules/execution-plans.js | 52 +++--- .../dashboard/static/js/modules/guardrails.js | 10 +- .../dashboard/static/js/modules/providers.js | 4 +- .../dashboard/static/js/modules/usage.js | 22 +-- .../dashboard/templates/auth-banner.html | 5 +- .../admin/dashboard/templates/layout.html | 47 +++++- 15 files changed, 454 insertions(+), 78 deletions(-) diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 21604eff..b3517888 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -92,6 +92,10 @@ * { box-sizing: border-box; margin: 0; padding: 0; } +[x-cloak] { + display: none !important; +} + body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); @@ -193,29 +197,151 @@ body { border-top: 1px solid var(--border); } -.api-key-section label { +.api-key-section { + display: grid; + gap: 8px; +} + +.api-key-title { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); - margin-bottom: 6px; } -.api-key-section input { +.api-key-open-btn { width: 100%; padding: 8px 10px; - background: var(--bg); + background: var(--accent); + border: 1px solid var(--border); + border-radius: var(--radius); + color: #fff; + font-size: 13px; + font-family: inherit; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; +} + +.api-key-open-btn:hover { + background: color-mix(in srgb, var(--accent) 90%, #fff 10%); + border-color: color-mix(in srgb, var(--accent) 78%, #000 12%); +} + +.api-key-open-btn:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 36%, transparent); + outline-offset: 2px; +} + +.auth-dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.48); + z-index: 80; +} + +.auth-dialog-shell { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + padding: 20px; +} + +.auth-dialog { + width: min(440px, 100%); + background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38); + padding: 22px; +} + +.auth-dialog-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.auth-dialog-kicker { + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.auth-dialog h2 { + font-size: 22px; + line-height: 1.2; + margin-top: 2px; +} + +.auth-dialog-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + font: inherit; + line-height: 1; +} + +.auth-dialog-close:hover { color: var(--text); + background: var(--bg-surface-hover); +} + +.auth-dialog-copy, +.auth-dialog-hint { + color: var(--text-muted); font-size: 13px; +} + +.auth-dialog-form { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.auth-dialog-form label { + font-size: 12px; + font-weight: 700; + color: var(--text); +} + +.auth-dialog-input { + width: 100%; + padding: 11px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 14px; + font-family: inherit; outline: none; } -.api-key-section input:focus { +.auth-dialog-input:focus { border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + +.auth-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; } /* Theme toggle — compact pill */ @@ -1083,6 +1209,14 @@ body { flex-wrap: wrap; } +.auth-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + /* Table */ .table-toolbar { display: flex; @@ -3185,6 +3319,10 @@ body.conversation-drawer-open { .sidebar-footer .theme-toggle-mobile { display: flex; margin: 0 auto; } .sidebar-toggle { display: none; } .content { width: 100%; margin: 0 auto; padding: 20px; } + .auth-dialog-shell { align-items: end; padding: 12px; } + .auth-dialog { padding: 18px; } + .auth-dialog-actions { flex-direction: column-reverse; } + .auth-dialog-actions .pagination-btn { width: 100%; } .cards { grid-template-columns: repeat(2, 1fr); } .provider-status-flag { diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index b0018a01..d77ebe2b 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -28,6 +28,8 @@ function dashboard() { authError: false, needsAuth: false, apiKey: '', + authDialogOpen: false, + authRequestGeneration: 0, theme: 'system', sidebarCollapsed: false, settingsSubpage: 'general', @@ -172,7 +174,7 @@ function dashboard() { if (typeof this.initProviderStatusPreferences === 'function') { this.initProviderStatusPreferences(); } - this.apiKey = localStorage.getItem('gomodel_api_key') || ''; + this.apiKey = this.normalizeApiKey(localStorage.getItem('gomodel_api_key') || ''); this.theme = localStorage.getItem('gomodel_theme') || 'system'; this.sidebarCollapsed = localStorage.getItem('gomodel_sidebar_collapsed') === 'true'; this.applyTheme(); @@ -263,7 +265,14 @@ function dashboard() { this.renderUserPathChart(); }, + normalizeApiKey(value) { + const key = String(value || '').trim(); + const match = key.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : key; + }, + saveApiKey() { + this.apiKey = this.normalizeApiKey(this.apiKey); if (this.apiKey) { localStorage.setItem('gomodel_api_key', this.apiKey); } else { @@ -271,10 +280,47 @@ function dashboard() { } }, + requestOptions(options) { + const request = { ...(options || {}) }; + request.headers = this.headers(); + request.authGeneration = this.authRequestGeneration; + return request; + }, + + isStaleAuthResponse(request) { + return request && + typeof request.authGeneration === 'number' && + request.authGeneration < this.authRequestGeneration; + }, + + openAuthDialog() { + this.authDialogOpen = true; + setTimeout(() => { + const input = document.getElementById('authDialogApiKey'); + if (input && typeof input.focus === 'function') { + input.focus(); + } + }, 0); + }, + + closeAuthDialog() { + this.authDialogOpen = false; + }, + + submitApiKey() { + this.saveApiKey(); + this.authRequestGeneration++; + this.authError = false; + this.needsAuth = false; + this.closeAuthDialog(); + this.fetchAll(); + }, + headers() { const h = { 'Content-Type': 'application/json' }; - if (this.apiKey) { - h.Authorization = 'Bearer ' + this.apiKey; + const apiKey = this.normalizeApiKey(this.apiKey); + if (apiKey) { + h.Authorization = 'Bearer ' + apiKey; } if (typeof this.effectiveTimezone === 'function') { h['X-GoModel-Timezone'] = this.effectiveTimezone(); @@ -354,11 +400,11 @@ function dashboard() { this.runtimeRefreshReport = null; try { - const res = await fetch('/admin/api/v1/runtime/refresh', { + const request = this.requestOptions({ method: 'POST', - headers: this.headers() }); - if (!this.handleFetchResponse(res, 'runtime refresh')) { + const res = await fetch('/admin/api/v1/runtime/refresh', request); + if (!this.handleFetchResponse(res, 'runtime refresh', request)) { this.runtimeRefreshNotice = 'Runtime refresh failed.'; this.runtimeRefreshError = this.runtimeRefreshNotice; return; @@ -433,10 +479,14 @@ function dashboard() { return name + ': ' + status + ' - ' + detail; }, - handleFetchResponse(res, label) { + handleFetchResponse(res, label, request) { if (res.status === 401) { + if (this.isStaleAuthResponse(request)) { + return false; + } this.authError = true; this.needsAuth = true; + this.openAuthDialog(); return false; } if (!res.ok) { @@ -457,7 +507,7 @@ function dashboard() { async fetchModels() { const controller = this._startAbortableRequest('_modelsFetchController'); const isCurrentRequest = () => this._isCurrentAbortableRequest('_modelsFetchController', controller); - const options = { headers: this.headers() }; + const options = this.requestOptions(); if (controller) { options.signal = controller.signal; } @@ -472,7 +522,7 @@ function dashboard() { if (!isCurrentRequest()) { return; } - if (!this.handleFetchResponse(res, 'models')) { + if (!this.handleFetchResponse(res, 'models', options)) { if (!isCurrentRequest()) { return; } @@ -506,9 +556,10 @@ function dashboard() { }, async fetchCategories() { + const request = this.requestOptions(); try { - const res = await fetch('/admin/api/v1/models/categories', { headers: this.headers() }); - if (!this.handleFetchResponse(res, 'categories')) { + const res = await fetch('/admin/api/v1/models/categories', request); + if (!this.handleFetchResponse(res, 'categories', request)) { this.categories = []; return; } diff --git a/internal/admin/dashboard/static/js/modules/aliases.js b/internal/admin/dashboard/static/js/modules/aliases.js index 1b104892..f21dfd11 100644 --- a/internal/admin/dashboard/static/js/modules/aliases.js +++ b/internal/admin/dashboard/static/js/modules/aliases.js @@ -149,8 +149,9 @@ async fetchAliases() { this.aliasLoading = true; this.aliasError = ''; + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { - const res = await fetch('/admin/api/v1/aliases', { headers: this.headers() }); + const res = await fetch('/admin/api/v1/aliases', request); if (res.status === 503) { this.aliasesAvailable = false; this.aliases = []; @@ -158,7 +159,7 @@ return; } this.aliasesAvailable = true; - if (!this.handleFetchResponse(res, 'aliases')) { + if (!this.handleFetchResponse(res, 'aliases', request)) { this.aliases = []; this.syncDisplayModels(); return; @@ -178,8 +179,9 @@ async fetchModelOverrides() { this.modelOverrideError = ''; + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { - const res = await fetch('/admin/api/v1/model-overrides', { headers: this.headers() }); + const res = await fetch('/admin/api/v1/model-overrides', request); if (res.status === 503) { this.modelOverridesAvailable = false; this.modelOverrideViews = []; @@ -187,7 +189,7 @@ return; } this.modelOverridesAvailable = true; - if (!this.handleFetchResponse(res, 'model overrides')) { + if (!this.handleFetchResponse(res, 'model overrides', request)) { this.modelOverrideViews = []; this.syncDisplayModels(); return; diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index 10eb9db7..9c29d2f4 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -27,8 +27,9 @@ if (this.auditStatusCode) qs += '&status_code=' + encodeURIComponent(this.auditStatusCode); if (this.auditStream) qs += '&stream=' + encodeURIComponent(this.auditStream); - const res = await fetch('/admin/api/v1/audit/log?' + qs, { headers: this.headers() }); - if (!this.handleFetchResponse(res, 'audit log')) { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/api/v1/audit/log?' + qs, request); + if (!this.handleFetchResponse(res, 'audit log', request)) { if (requestToken !== this.auditFetchToken) return; this.auditLog = { entries: [], total: 0, limit: 25, offset: 0 }; return; diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.js b/internal/admin/dashboard/static/js/modules/auth-keys.js index 5ee6bcec..a436a53d 100644 --- a/internal/admin/dashboard/static/js/modules/auth-keys.js +++ b/internal/admin/dashboard/static/js/modules/auth-keys.js @@ -89,15 +89,16 @@ async fetchAuthKeys() { this.authKeysLoading = true; this.authKeyError = ''; + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { - const res = await fetch('/admin/api/v1/auth-keys', { headers: this.headers() }); + const res = await fetch('/admin/api/v1/auth-keys', request); if (res.status === 503) { this.authKeysAvailable = false; this.authKeys = []; return; } this.authKeysAvailable = true; - if (!this.handleFetchResponse(res, 'auth keys')) { + if (!this.handleFetchResponse(res, 'auth keys', request)) { if (res.status !== 401) { this.authKeyError = await this._authKeyResponseMessage(res, 'Unable to load API keys.'); } diff --git a/internal/admin/dashboard/static/js/modules/contribution-calendar.js b/internal/admin/dashboard/static/js/modules/contribution-calendar.js index 5c52d0dc..ed7d2649 100644 --- a/internal/admin/dashboard/static/js/modules/contribution-calendar.js +++ b/internal/admin/dashboard/static/js/modules/contribution-calendar.js @@ -19,7 +19,7 @@ } return this._calendarFetchController === controller && !controller.signal.aborted; }; - const options = { headers: this.headers() }; + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; if (controller) { options.signal = controller.signal; } @@ -30,7 +30,7 @@ if (!isCurrentRequest()) { return; } - if (!this.handleFetchResponse(res, 'calendar')) { + if (!this.handleFetchResponse(res, 'calendar', options)) { if (!isCurrentRequest()) { return; } diff --git a/internal/admin/dashboard/static/js/modules/conversation-drawer.js b/internal/admin/dashboard/static/js/modules/conversation-drawer.js index 9b182cda..225f69b9 100644 --- a/internal/admin/dashboard/static/js/modules/conversation-drawer.js +++ b/internal/admin/dashboard/static/js/modules/conversation-drawer.js @@ -107,11 +107,12 @@ async fetchConversation(logID, requestToken) { try { const qs = 'log_id=' + encodeURIComponent(logID) + '&limit=120'; - const res = await fetch('/admin/api/v1/audit/conversation?' + qs, { headers: this.headers() }); + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/api/v1/audit/conversation?' + qs, request); if (requestToken !== this.conversationRequestToken) return; - if (!this.handleFetchResponse(res, 'audit conversation')) { + if (!this.handleFetchResponse(res, 'audit conversation', request)) { this.conversationError = 'Unable to load interactions.'; this.conversationEntries = []; this.conversationMessages = []; diff --git a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js index dceeb9c3..cda6dd40 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js @@ -4,16 +4,26 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +function createLocalStorage(initial = {}) { + const values = { ...initial }; + return { + getItem(key) { + return Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null; + }, + setItem(key, value) { + values[key] = String(value); + }, + removeItem(key) { + delete values[key]; + }, + values + }; +} + function loadDashboardApp(overrides = {}) { const dashboardSource = fs.readFileSync(path.join(__dirname, '../dashboard.js'), 'utf8'); const window = { - localStorage: { - getItem() { - return null; - }, - setItem() {}, - removeItem() {} - }, + localStorage: createLocalStorage(), location: { pathname: '/admin/dashboard/usage' }, matchMedia() { return { addEventListener() {} }; @@ -133,3 +143,69 @@ test('system theme media changes rerender all dashboard charts', () => { assert.equal(modelCalls, 1); assert.equal(userPathCalls, 1); }); + +test('unauthorized dashboard responses open the auth dialog', () => { + const app = loadDashboardApp(); + const request = app.requestOptions(); + + const handled = app.handleFetchResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }, 'models', request); + + assert.equal(handled, false); + assert.equal(app.authError, true); + assert.equal(app.needsAuth, true); + assert.equal(app.authDialogOpen, true); +}); + +test('stale unauthorized dashboard responses do not reopen the auth dialog', () => { + const app = loadDashboardApp(); + const staleRequest = app.requestOptions(); + + app.authRequestGeneration++; + app.authError = false; + app.needsAuth = false; + app.authDialogOpen = false; + + const handled = app.handleFetchResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }, 'categories', staleRequest); + + assert.equal(handled, false); + assert.equal(app.authError, false); + assert.equal(app.needsAuth, false); + assert.equal(app.authDialogOpen, false); +}); + +test('submitApiKey trims bearer input and stores the key before refreshing dashboard data', () => { + const storage = createLocalStorage(); + const app = loadDashboardApp({ + window: { localStorage: storage } + }); + let fetches = 0; + app.fetchAll = () => { + fetches++; + }; + + app.authDialogOpen = true; + app.apiKey = ' Bearer secret-token '; + app.submitApiKey(); + + assert.equal(app.apiKey, 'secret-token'); + assert.equal(app.authRequestGeneration, 1); + assert.equal(storage.getItem('gomodel_api_key'), 'secret-token'); + assert.equal(app.authDialogOpen, false); + assert.equal(fetches, 1); +}); + +test('headers accept a pasted bearer value without duplicating the prefix', () => { + const app = loadDashboardApp(); + + app.apiKey = 'Bearer secret-token'; + + assert.equal(app.headers().Authorization, 'Bearer secret-token'); +}); diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index 27d0fab1..82429fad 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -138,7 +138,7 @@ test('dashboard pages reuse a shared auth banner template', () => { assert.match( authBannerTemplate, - /{{define "auth-banner"}}[\s\S]*x-show="authError"[\s\S]*Authentication required\. Enter your API key in the sidebar to view data\.[\s\S]*{{end}}/ + /{{define "auth-banner"}}[\s\S]*class="alert alert-warning auth-banner"[\s\S]*x-show="authError"[\s\S]*Authentication required for dashboard data\.[\s\S]*@click="openAuthDialog\(\)"[\s\S]*Enter API key[\s\S]*{{end}}/ ); const authBannerCalls = indexTemplate.match(/{{template "auth-banner" \.}}/g) || []; @@ -147,10 +147,58 @@ test('dashboard pages reuse a shared auth banner template', () => { assert.match(indexTemplate, /
- - + Dashboard locked +
@@ -96,6 +96,49 @@

GOModel

{{template "index" .}}
+ +
+ +
From 7f2ffaa50d1f5c626ba9e0dfff66be7cfc443d18 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 13 Apr 2026 22:57:04 +0200 Subject: [PATCH 02/11] fix(dashboard): harden auth fetch handling --- .../admin/dashboard/static/css/dashboard.css | 6 + .../admin/dashboard/static/js/dashboard.js | 39 +++++- .../dashboard/static/js/modules/aliases.js | 16 ++- .../dashboard/static/js/modules/audit-list.js | 6 +- .../dashboard/static/js/modules/auth-keys.js | 8 +- .../js/modules/contribution-calendar.js | 15 ++- .../static/js/modules/conversation-drawer.js | 6 +- .../js/modules/dashboard-display.test.js | 49 +++++++- .../dashboard/static/js/modules/guardrails.js | 16 ++- .../dashboard/static/js/modules/providers.js | 22 ++-- .../dashboard/static/js/modules/usage.js | 113 +++++++++++------- .../dashboard/static/js/modules/workflows.js | 20 +++- .../admin/dashboard/templates/layout.html | 3 + 13 files changed, 237 insertions(+), 82 deletions(-) diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index e5202448..46c696a7 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -308,6 +308,12 @@ body { font-size: 13px; } +.auth-dialog-error { + color: var(--danger); + font-size: 13px; + font-weight: 600; +} + .auth-dialog-form { display: grid; gap: 10px; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index fad49f70..314f87c7 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -1,6 +1,8 @@ // GOModel Dashboard — Alpine.js + Chart.js logic function dashboard() { + const STALE_AUTH_RESPONSE = 'STALE_AUTH'; + function resolveModuleFactory(factory, windowName) { if (typeof factory === 'function') { return factory; @@ -308,6 +310,15 @@ function dashboard() { }, submitApiKey() { + const apiKey = this.normalizeApiKey(this.apiKey); + if (!apiKey) { + this.apiKey = ''; + this.authError = true; + this.needsAuth = true; + this.openAuthDialog(); + return; + } + this.apiKey = apiKey; this.saveApiKey(); this.authRequestGeneration++; this.authError = false; @@ -361,6 +372,14 @@ function dashboard() { return Boolean(error) && (error.name === 'AbortError' || error.code === 20); }, + staleAuthResponseResult() { + return STALE_AUTH_RESPONSE; + }, + + isStaleAuthFetchResult(result) { + return result === STALE_AUTH_RESPONSE; + }, + dashboardDataFetches() { const requests = [this.fetchUsage(), this.fetchModels(), this.fetchCategories()]; if (typeof this.fetchProviderStatus === 'function') { @@ -404,7 +423,11 @@ function dashboard() { method: 'POST', }); const res = await fetch('/admin/api/v1/runtime/refresh', request); - if (!this.handleFetchResponse(res, 'runtime refresh', request)) { + const handled = this.handleFetchResponse(res, 'runtime refresh', request); + if (this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.runtimeRefreshNotice = 'Runtime refresh failed.'; this.runtimeRefreshError = this.runtimeRefreshNotice; return; @@ -482,7 +505,7 @@ function dashboard() { handleFetchResponse(res, label, request) { if (res.status === 401) { if (this.isStaleAuthResponse(request)) { - return false; + return STALE_AUTH_RESPONSE; } this.authError = true; this.needsAuth = true; @@ -522,7 +545,11 @@ function dashboard() { if (!isCurrentRequest()) { return; } - if (!this.handleFetchResponse(res, 'models', options)) { + const handled = this.handleFetchResponse(res, 'models', options); + if (this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { if (!isCurrentRequest()) { return; } @@ -559,7 +586,11 @@ function dashboard() { const request = this.requestOptions(); try { const res = await fetch('/admin/api/v1/models/categories', request); - if (!this.handleFetchResponse(res, 'categories', request)) { + const handled = this.handleFetchResponse(res, 'categories', request); + if (this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.categories = []; return; } diff --git a/internal/admin/dashboard/static/js/modules/aliases.js b/internal/admin/dashboard/static/js/modules/aliases.js index f21dfd11..e01a32bb 100644 --- a/internal/admin/dashboard/static/js/modules/aliases.js +++ b/internal/admin/dashboard/static/js/modules/aliases.js @@ -149,8 +149,8 @@ async fetchAliases() { this.aliasLoading = true; this.aliasError = ''; - const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; const res = await fetch('/admin/api/v1/aliases', request); if (res.status === 503) { this.aliasesAvailable = false; @@ -158,8 +158,12 @@ this.syncDisplayModels(); return; } + const handled = this.handleFetchResponse(res, 'aliases', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.aliasesAvailable = true; - if (!this.handleFetchResponse(res, 'aliases', request)) { + if (!handled) { this.aliases = []; this.syncDisplayModels(); return; @@ -179,8 +183,8 @@ async fetchModelOverrides() { this.modelOverrideError = ''; - const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; const res = await fetch('/admin/api/v1/model-overrides', request); if (res.status === 503) { this.modelOverridesAvailable = false; @@ -188,8 +192,12 @@ this.syncDisplayModels(); return; } + const handled = this.handleFetchResponse(res, 'model overrides', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.modelOverridesAvailable = true; - if (!this.handleFetchResponse(res, 'model overrides', request)) { + if (!handled) { this.modelOverrideViews = []; this.syncDisplayModels(); return; diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index ae0f4adc..ec96f89e 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -29,7 +29,11 @@ const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; const res = await fetch('/admin/api/v1/audit/log?' + qs, request); - if (!this.handleFetchResponse(res, 'audit log', request)) { + const handled = this.handleFetchResponse(res, 'audit log', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { if (requestToken !== this.auditFetchToken) return; this.auditLog = { entries: [], total: 0, limit: 25, offset: 0 }; return; diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.js b/internal/admin/dashboard/static/js/modules/auth-keys.js index a436a53d..8bc0e587 100644 --- a/internal/admin/dashboard/static/js/modules/auth-keys.js +++ b/internal/admin/dashboard/static/js/modules/auth-keys.js @@ -89,16 +89,20 @@ async fetchAuthKeys() { this.authKeysLoading = true; this.authKeyError = ''; - const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; const res = await fetch('/admin/api/v1/auth-keys', request); if (res.status === 503) { this.authKeysAvailable = false; this.authKeys = []; return; } + const handled = this.handleFetchResponse(res, 'auth keys', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.authKeysAvailable = true; - if (!this.handleFetchResponse(res, 'auth keys', request)) { + if (!handled) { if (res.status !== 401) { this.authKeyError = await this._authKeyResponseMessage(res, 'Unable to load API keys.'); } diff --git a/internal/admin/dashboard/static/js/modules/contribution-calendar.js b/internal/admin/dashboard/static/js/modules/contribution-calendar.js index ed7d2649..896b6a4c 100644 --- a/internal/admin/dashboard/static/js/modules/contribution-calendar.js +++ b/internal/admin/dashboard/static/js/modules/contribution-calendar.js @@ -19,18 +19,21 @@ } return this._calendarFetchController === controller && !controller.signal.aborted; }; - const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - this.calendarLoading = true; try { + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } const res = await fetch('/admin/api/v1/usage/daily?days=365&interval=daily', options); if (!isCurrentRequest()) { return; } - if (!this.handleFetchResponse(res, 'calendar', options)) { + const handled = this.handleFetchResponse(res, 'calendar', options); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { if (!isCurrentRequest()) { return; } diff --git a/internal/admin/dashboard/static/js/modules/conversation-drawer.js b/internal/admin/dashboard/static/js/modules/conversation-drawer.js index 225f69b9..c58f2f99 100644 --- a/internal/admin/dashboard/static/js/modules/conversation-drawer.js +++ b/internal/admin/dashboard/static/js/modules/conversation-drawer.js @@ -112,7 +112,11 @@ if (requestToken !== this.conversationRequestToken) return; - if (!this.handleFetchResponse(res, 'audit conversation', request)) { + const handled = this.handleFetchResponse(res, 'audit conversation', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.conversationError = 'Unable to load interactions.'; this.conversationEntries = []; this.conversationMessages = []; diff --git a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js index cda6dd40..763d5609 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js @@ -175,7 +175,31 @@ test('stale unauthorized dashboard responses do not reopen the auth dialog', () statusText: 'Unauthorized' }, 'categories', staleRequest); - assert.equal(handled, false); + assert.equal(handled, app.staleAuthResponseResult()); + assert.equal(app.authError, false); + assert.equal(app.needsAuth, false); + assert.equal(app.authDialogOpen, false); +}); + +test('stale unauthorized category responses preserve existing categories', async () => { + const existingCategories = [{ category: 'chat', count: 2 }]; + const app = loadDashboardApp({ + context: { + fetch: async () => ({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }) + } + }); + const staleRequest = app.requestOptions(); + app.requestOptions = () => staleRequest; + app.categories = existingCategories; + app.authRequestGeneration++; + + await app.fetchCategories(); + + assert.equal(app.categories, existingCategories); assert.equal(app.authError, false); assert.equal(app.needsAuth, false); assert.equal(app.authDialogOpen, false); @@ -202,6 +226,29 @@ test('submitApiKey trims bearer input and stores the key before refreshing dashb assert.equal(fetches, 1); }); +test('submitApiKey rejects blank input without unlocking dashboard', () => { + const storage = createLocalStorage({ gomodel_api_key: 'existing-token' }); + const app = loadDashboardApp({ + window: { localStorage: storage } + }); + let fetches = 0; + app.fetchAll = () => { + fetches++; + }; + + app.authDialogOpen = true; + app.apiKey = ' '; + app.submitApiKey(); + + assert.equal(app.apiKey, ''); + assert.equal(app.authRequestGeneration, 0); + assert.equal(storage.getItem('gomodel_api_key'), 'existing-token'); + assert.equal(app.authError, true); + assert.equal(app.needsAuth, true); + assert.equal(app.authDialogOpen, true); + assert.equal(fetches, 0); +}); + test('headers accept a pasted bearer value without duplicating the prefix', () => { const app = loadDashboardApp(); diff --git a/internal/admin/dashboard/static/js/modules/guardrails.js b/internal/admin/dashboard/static/js/modules/guardrails.js index df7a59fc..7e8786f0 100644 --- a/internal/admin/dashboard/static/js/modules/guardrails.js +++ b/internal/admin/dashboard/static/js/modules/guardrails.js @@ -248,16 +248,20 @@ async fetchGuardrailTypes() { this.guardrailTypesLoading = true; - const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; const res = await fetch('/admin/api/v1/guardrails/types', request); if (res.status === 503) { this.guardrailsAvailable = false; this.guardrailTypes = []; return; } + const handled = this.handleFetchResponse(res, 'guardrail types', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.guardrailsAvailable = true; - if (!this.handleFetchResponse(res, 'guardrail types', request)) { + if (!handled) { this.guardrailTypes = []; return; } @@ -281,16 +285,20 @@ async fetchGuardrails() { this.guardrailsLoading = true; this.guardrailError = ''; - const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; const res = await fetch('/admin/api/v1/guardrails', request); if (res.status === 503) { this.guardrailsAvailable = false; this.guardrails = []; return; } + const handled = this.handleFetchResponse(res, 'guardrails', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.guardrailsAvailable = true; - if (!this.handleFetchResponse(res, 'guardrails', request)) { + if (!handled) { this.guardrails = []; return; } diff --git a/internal/admin/dashboard/static/js/modules/providers.js b/internal/admin/dashboard/static/js/modules/providers.js index ab11418a..73eedec4 100644 --- a/internal/admin/dashboard/static/js/modules/providers.js +++ b/internal/admin/dashboard/static/js/modules/providers.js @@ -62,17 +62,21 @@ }, async fetchProviderStatus() { - const controller = typeof this._startAbortableRequest === 'function' - ? this._startAbortableRequest('_providerStatusFetchController') - : null; - const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - + let controller = null; try { + controller = typeof this._startAbortableRequest === 'function' + ? this._startAbortableRequest('_providerStatusFetchController') + : null; + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } const res = await fetch('/admin/api/v1/providers/status', options); - if (!this.handleFetchResponse(res, 'provider status', options)) { + const handled = this.handleFetchResponse(res, 'provider status', options); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.providerStatus = this.emptyProviderStatus(); return; } diff --git a/internal/admin/dashboard/static/js/modules/usage.js b/internal/admin/dashboard/static/js/modules/usage.js index 95130895..a4470556 100644 --- a/internal/admin/dashboard/static/js/modules/usage.js +++ b/internal/admin/dashboard/static/js/modules/usage.js @@ -37,15 +37,15 @@ return; } - const controller = typeof this._startAbortableRequest === 'function' - ? this._startAbortableRequest('_cacheOverviewFetchController') - : null; - const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - + let controller = null; try { + controller = typeof this._startAbortableRequest === 'function' + ? this._startAbortableRequest('_cacheOverviewFetchController') + : null; + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } let queryStr; if (this.customStartDate && this.customEndDate) { queryStr = 'start_date=' + this._formatDate(this.customStartDate) + @@ -56,7 +56,11 @@ queryStr += '&interval=' + this.interval; const res = await fetch('/admin/api/v1/cache/overview?' + queryStr, options); - if (!this.handleFetchResponse(res, 'cache overview', options)) { + const handled = this.handleFetchResponse(res, 'cache overview', options); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.cacheOverview = this.emptyCacheOverview(); return; } @@ -86,15 +90,15 @@ }, async fetchUsage() { - const controller = typeof this._startAbortableRequest === 'function' - ? this._startAbortableRequest('_usageFetchController') - : null; - const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - + let controller = null; try { + controller = typeof this._startAbortableRequest === 'function' + ? this._startAbortableRequest('_usageFetchController') + : null; + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } let queryStr; if (this.customStartDate && this.customEndDate) { queryStr = 'start_date=' + this._formatDate(this.customStartDate) + @@ -109,8 +113,13 @@ fetch('/admin/api/v1/usage/daily?' + queryStr, options) ]); - if (!this.handleFetchResponse(summaryRes, 'usage summary', options) || - !this.handleFetchResponse(dailyRes, 'usage daily', options)) { + const summaryHandled = this.handleFetchResponse(summaryRes, 'usage summary', options); + const dailyHandled = this.handleFetchResponse(dailyRes, 'usage daily', options); + if ((typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(summaryHandled)) || + (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(dailyHandled))) { + return; + } + if (!summaryHandled || !dailyHandled) { return; } @@ -154,17 +163,21 @@ }, async fetchModelUsage() { - const controller = typeof this._startAbortableRequest === 'function' - ? this._startAbortableRequest('_modelUsageFetchController') - : null; - const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - + let controller = null; try { + controller = typeof this._startAbortableRequest === 'function' + ? this._startAbortableRequest('_modelUsageFetchController') + : null; + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } const res = await fetch('/admin/api/v1/usage/models?' + this._usageQueryStr(), options); - if (!this.handleFetchResponse(res, 'usage models', options)) { + const handled = this.handleFetchResponse(res, 'usage models', options); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.modelUsage = []; return; } @@ -187,17 +200,21 @@ }, async fetchUserPathUsage() { - const controller = typeof this._startAbortableRequest === 'function' - ? this._startAbortableRequest('_userPathUsageFetchController') - : null; - const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - + let controller = null; try { + controller = typeof this._startAbortableRequest === 'function' + ? this._startAbortableRequest('_userPathUsageFetchController') + : null; + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } const res = await fetch('/admin/api/v1/usage/user-paths?' + this._usageQueryStr(), options); - if (!this.handleFetchResponse(res, 'usage user paths', options)) { + const handled = this.handleFetchResponse(res, 'usage user paths', options); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.userPathUsage = []; return; } @@ -220,15 +237,15 @@ }, async fetchUsageLog(resetOffset) { - const controller = typeof this._startAbortableRequest === 'function' - ? this._startAbortableRequest('_usageLogFetchController') - : null; - const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; - if (controller) { - options.signal = controller.signal; - } - + let controller = null; try { + controller = typeof this._startAbortableRequest === 'function' + ? this._startAbortableRequest('_usageLogFetchController') + : null; + const options = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + if (controller) { + options.signal = controller.signal; + } if (resetOffset) this.usageLog.offset = 0; let qs = this._usageQueryStr(); qs += '&limit=' + this.usageLog.limit + '&offset=' + this.usageLog.offset; @@ -238,7 +255,11 @@ if (this.usageLogUserPath) qs += '&user_path=' + encodeURIComponent(this.usageLogUserPath); const res = await fetch('/admin/api/v1/usage/log?' + qs, options); - if (!this.handleFetchResponse(res, 'usage log', options)) { + const handled = this.handleFetchResponse(res, 'usage log', options); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.usageLog = { entries: [], total: 0, limit: 50, offset: 0 }; return; } diff --git a/internal/admin/dashboard/static/js/modules/workflows.js b/internal/admin/dashboard/static/js/modules/workflows.js index 776b08ae..f1690d94 100644 --- a/internal/admin/dashboard/static/js/modules/workflows.js +++ b/internal/admin/dashboard/static/js/modules/workflows.js @@ -707,7 +707,11 @@ request.signal = controller.signal; } const res = await fetch('/admin/api/v1/dashboard/config', request); - if (!this.handleFetchResponse(res, 'dashboard config', request)) { + const handled = this.handleFetchResponse(res, 'dashboard config', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.workflowRuntimeConfig = {}; return; } @@ -832,8 +836,12 @@ this.workflows = []; return; } + const handled = this.handleFetchResponse(res, 'workflows', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } this.workflowsAvailable = true; - if (!this.handleFetchResponse(res, 'workflows', request)) { + if (!handled) { this.workflows = []; return; } @@ -855,10 +863,14 @@ }, async fetchWorkflowGuardrails() { - const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; const res = await fetch('/admin/api/v1/workflows/guardrails', request); - if (!this.handleFetchResponse(res, 'workflow guardrails', request)) { + const handled = this.handleFetchResponse(res, 'workflow guardrails', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { this.guardrailRefs = []; return; } diff --git a/internal/admin/dashboard/templates/layout.html b/internal/admin/dashboard/templates/layout.html index a0a11ee6..fa0ea328 100644 --- a/internal/admin/dashboard/templates/layout.html +++ b/internal/admin/dashboard/templates/layout.html @@ -131,6 +131,9 @@

Dashboard locked

placeholder="Master key or bearer token" autocomplete="current-password" x-model="apiKey"> +

Stored in this browser. Requests use the Authorization bearer header.

From 3a2ab7f53a14538fd53d46c2cb97732de68ad51d Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 14 Apr 2026 09:46:06 +0200 Subject: [PATCH 03/11] fix(dashboard): refine api key dialog controls --- .../admin/dashboard/static/css/dashboard.css | 88 ++++++++++++------- .../admin/dashboard/static/js/dashboard.js | 4 + .../js/modules/dashboard-display.test.js | 10 +++ .../js/modules/dashboard-layout.test.js | 41 ++++++++- .../admin/dashboard/templates/layout.html | 52 +++++++---- 5 files changed, 146 insertions(+), 49 deletions(-) diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 46c696a7..67f73842 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -202,22 +202,17 @@ body { gap: 8px; } -.api-key-title { - display: block; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-muted); -} - .api-key-open-btn { width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; padding: 8px 10px; - background: var(--accent); - border: 1px solid var(--border); + background: transparent; + border: 1px solid var(--accent); border-radius: var(--radius); - color: #fff; + color: var(--accent); font-size: 13px; font-family: inherit; font-weight: 600; @@ -225,9 +220,16 @@ body { transition: background-color 0.15s, border-color 0.15s; } +.api-key-open-icon { + width: 15px; + height: 15px; + flex: 0 0 15px; +} + .api-key-open-btn:hover { - background: color-mix(in srgb, var(--accent) 90%, #fff 10%); - border-color: color-mix(in srgb, var(--accent) 78%, #000 12%); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 78%, var(--text)); + color: color-mix(in srgb, var(--accent) 78%, var(--text)); } .api-key-open-btn:focus-visible { @@ -268,14 +270,6 @@ body { margin-bottom: 12px; } -.auth-dialog-kicker { - color: var(--text-muted); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.5px; - text-transform: uppercase; -} - .auth-dialog h2 { font-size: 22px; line-height: 1.2; @@ -302,7 +296,6 @@ body { background: var(--bg-surface-hover); } -.auth-dialog-copy, .auth-dialog-hint { color: var(--text-muted); font-size: 13px; @@ -320,15 +313,28 @@ body { margin-top: 18px; } -.auth-dialog-form label { - font-size: 12px; - font-weight: 700; - color: var(--text); +.auth-dialog-input-shell { + position: relative; +} + +.auth-dialog-input-icon { + position: absolute; + left: 12px; + top: 50%; + width: 16px; + height: 16px; + color: var(--text-muted); + pointer-events: none; + transform: translateY(-50%); +} + +.auth-dialog-input-shell:focus-within .auth-dialog-input-icon { + color: var(--accent); } .auth-dialog-input { width: 100%; - padding: 11px 12px; + padding: 11px 12px 11px 38px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); @@ -343,6 +349,12 @@ body { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } +.auth-dialog-submit-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + .auth-dialog-actions { display: flex; justify-content: flex-end; @@ -3328,8 +3340,24 @@ body.conversation-drawer-open { .sidebar-header h1, .badge { display: none; } .sidebar-nav .nav-item { justify-content: center; padding: 10px; } .sidebar-nav .nav-item span { display: none; } - .sidebar-footer { padding: 8px; } - .sidebar-footer .api-key-section { display: none; } + .sidebar-footer { display: grid; gap: 8px; padding: 8px; } + .sidebar-footer .api-key-section { display: grid; } + .sidebar.sidebar-collapsed .sidebar-footer .api-key-section { display: grid; } + .sidebar-footer .api-key-open-btn { + width: 36px; + height: 36px; + min-height: 36px; + justify-self: center; + padding: 0; + } + .sidebar-footer .api-key-open-btn span { + display: none; + } + .sidebar-footer .api-key-open-icon { + width: 16px; + height: 16px; + flex-basis: 16px; + } .sidebar-footer .theme-toggle { display: none; } .sidebar-footer .theme-toggle-mobile { display: flex; margin: 0 auto; } .sidebar-toggle { display: none; } diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 314f87c7..92956ab2 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -273,6 +273,10 @@ function dashboard() { return match ? match[1].trim() : key; }, + hasApiKey() { + return this.normalizeApiKey(this.apiKey) !== ''; + }, + saveApiKey() { this.apiKey = this.normalizeApiKey(this.apiKey); if (this.apiKey) { diff --git a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js index 763d5609..08d1f09e 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-display.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-display.test.js @@ -226,6 +226,16 @@ test('submitApiKey trims bearer input and stores the key before refreshing dashb assert.equal(fetches, 1); }); +test('hasApiKey reflects trimmed bearer input for the sidebar change action', () => { + const app = loadDashboardApp(); + + app.apiKey = ''; + assert.equal(app.hasApiKey(), false); + + app.apiKey = ' Bearer secret-token '; + assert.equal(app.hasApiKey(), true); +}); + test('submitApiKey rejects blank input without unlocking dashboard', () => { const storage = createLocalStorage({ gomodel_api_key: 'existing-token' }); const app = loadDashboardApp({ diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index 82429fad..4a661c4a 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -158,8 +158,11 @@ test('dashboard auth uses a root-level dialog instead of a hidden sidebar input' assert.doesNotMatch(template, //); assert.ok(backdropBlock, 'Expected auth dialog backdrop block'); assert.match(backdropBlock[0], /x-show="authDialogOpen"/); @@ -176,7 +179,16 @@ test('dashboard auth uses a root-level dialog instead of a hidden sidebar input' template, /role="dialog"[\s\S]*aria-modal="true"[\s\S]*@click\.stop[\s\S]*id="authDialogApiKey"/ ); + assert.match(template, /class="auth-dialog-input-shell"[\s\S]*class="auth-dialog-input-icon"[\s\S]*id="authDialogApiKey"/); + assert.match(template, /x-text="needsAuth \? 'Dashboard locked' : 'Change API key'"/); + assert.match(template, /class="pagination-btn pagination-btn-primary pagination-btn-with-icon auth-dialog-submit-btn"[\s\S]*class="auth-dialog-submit-icon"[\s\S]*x-text="needsAuth \? 'Unlock dashboard' : 'Save API key'"/); assert.match(template, /placeholder="Master key or bearer token"/); + assert.match(template, /aria-label="API key"/); + assert.doesNotMatch(template, /aria-describedby="authDialogDescription"/); + assert.doesNotMatch(template, /Enter a different API key for this browser\./); + assert.doesNotMatch(template, /Enter the API key configured for this GoModel instance\./); + assert.doesNotMatch(template, /