diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f0ff273d1b..30e053a3ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. - Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. +- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. ## 2026.3.12 diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 472bb057304..aa1a30d8866 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -14,6 +14,7 @@ export const ConnectErrorDetailCodes = { AUTH_TAILSCALE_PROXY_MISSING: "AUTH_TAILSCALE_PROXY_MISSING", AUTH_TAILSCALE_WHOIS_FAILED: "AUTH_TAILSCALE_WHOIS_FAILED", AUTH_TAILSCALE_IDENTITY_MISMATCH: "AUTH_TAILSCALE_IDENTITY_MISMATCH", + CONTROL_UI_ORIGIN_NOT_ALLOWED: "CONTROL_UI_ORIGIN_NOT_ALLOWED", CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED", DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED", DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID", diff --git a/src/gateway/server.auth.browser-hardening.test.ts b/src/gateway/server.auth.browser-hardening.test.ts index b28f60ad8c6..0c0ca0deabd 100644 --- a/src/gateway/server.auth.browser-hardening.test.ts +++ b/src/gateway/server.auth.browser-hardening.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; +import { ConnectErrorDetailCodes } from "../gateway/protocol/connect-error-details.js"; import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, @@ -123,6 +124,9 @@ describe("gateway auth browser hardening", () => { }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("origin not allowed"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, + ); }); }); @@ -188,6 +192,9 @@ describe("gateway auth browser hardening", () => { expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok"); } else { expect(res.error?.message ?? "").toContain(expectedMessage ?? ""); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, + ); } } finally { ws.close(); @@ -207,6 +214,9 @@ describe("gateway auth browser hardening", () => { }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("origin not allowed"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, + ); } finally { ws.close(); } diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 88813663a85..670f73637ac 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -169,9 +169,47 @@ describe("ws connect policy", () => { isLocalClient: false, }).kind, ).toBe("allow"); + + const bypass = resolveControlUiAuthPolicy({ + isControlUi: true, + controlUiConfig: { dangerouslyDisableDeviceAuth: true }, + deviceRaw: null, + }); + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: true, + controlUiAuthPolicy: bypass, + trustedProxyAuthOk: false, + sharedAuthOk: false, + authOk: false, + hasSharedAuth: false, + isLocalClient: false, + }).kind, + ).toBe("allow"); + + // Regression: dangerouslyDisableDeviceAuth bypass must NOT extend to node-role + // sessions — the break-glass flag is scoped to operator Control UI only. + // A device-less node-role connection must still be rejected even when the flag + // is set, to prevent the flag from being abused to admit unauthorized node + // registrations. + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "node", + isControlUi: true, + controlUiAuthPolicy: bypass, + trustedProxyAuthOk: false, + sharedAuthOk: false, + authOk: false, + hasSharedAuth: false, + isLocalClient: false, + }).kind, + ).toBe("reject-device-required"); }); - test("pairing bypass requires control-ui bypass + shared auth (or trusted-proxy auth)", () => { + test("dangerouslyDisableDeviceAuth skips pairing for operator control-ui only", () => { const bypass = resolveControlUiAuthPolicy({ isControlUi: true, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, @@ -182,10 +220,10 @@ describe("ws connect policy", () => { controlUiConfig: undefined, deviceRaw: null, }); - expect(shouldSkipControlUiPairing(bypass, true, false)).toBe(true); - expect(shouldSkipControlUiPairing(bypass, false, false)).toBe(false); - expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false); - expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true); + expect(shouldSkipControlUiPairing(bypass, "operator", false)).toBe(true); + expect(shouldSkipControlUiPairing(bypass, "node", false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, "operator", false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, "operator", true)).toBe(true); }); test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => { diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index f2467aedc98..c5c4c1d0a07 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -34,13 +34,16 @@ export function resolveControlUiAuthPolicy(params: { export function shouldSkipControlUiPairing( policy: ControlUiAuthPolicy, - sharedAuthOk: boolean, + role: GatewayRole, trustedProxyAuthOk = false, ): boolean { if (trustedProxyAuthOk) { return true; } - return policy.allowBypass && sharedAuthOk; + // dangerouslyDisableDeviceAuth is the break-glass path for Control UI + // operators. Keep pairing aligned with the missing-device bypass, including + // open-auth deployments where there is no shared token/password to prove. + return role === "operator" && policy.allowBypass; } export function isTrustedProxyControlUiOperatorAuth(params: { @@ -82,6 +85,14 @@ export function evaluateMissingDeviceIdentity(params: { if (params.isControlUi && params.trustedProxyAuthOk) { return { kind: "allow" }; } + if (params.isControlUi && params.controlUiAuthPolicy.allowBypass && params.role === "operator") { + // dangerouslyDisableDeviceAuth: true — operator has explicitly opted out of + // device-identity enforcement for this Control UI. Allow for operator-role + // sessions only; node-role sessions must still satisfy device identity so + // that the break-glass flag cannot be abused to admit device-less node + // registrations (see #45405 review). + return { kind: "allow" }; + } if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { // Allow localhost Control UI connections when allowInsecureAuth is configured. // Localhost has no network interception risk, and browser SubtleCrypto diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index e226ebfc911..e0116190009 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -421,7 +421,12 @@ export function attachGatewayWsMessageHandler(params: { host: requestHost ?? "n/a", reason: originCheck.reason, }); - sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage); + sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, { + details: { + code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, + reason: originCheck.reason, + }, + }); close(1008, truncateCloseReason(errorMessage)); return; } @@ -676,7 +681,7 @@ export function attachGatewayWsMessageHandler(params: { hasBrowserOriginHeader, sharedAuthOk, authMethod, - }) || shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk); + }) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) { diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index bcef1be3ed3..471a719c603 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -271,6 +271,48 @@ describe("connectGateway", () => { expect(host.lastError).toContain("too many failed authentication attempts"); }); + it("maps generic fetch failures to actionable device identity guidance", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitClose({ + code: 4008, + reason: "connect failed", + error: { + code: "INVALID_REQUEST", + message: "Fetch failed", + details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED }, + }, + }); + + expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED); + expect(host.lastError).toContain("device identity required"); + }); + + it("maps generic fetch failures to actionable origin guidance", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitClose({ + code: 4008, + reason: "connect failed", + error: { + code: "INVALID_REQUEST", + message: "Fetch failed", + details: { code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED }, + }, + }); + + expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED); + expect(host.lastError).toContain("origin not allowed"); + }); + it("preserves specific close errors even when auth detail codes are present", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 0cf39df0bc4..bcd8a866e4e 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -2,7 +2,6 @@ import { GATEWAY_EVENT_UPDATE_AVAILABLE, type GatewayUpdateAvailableEventPayload, } from "../../../src/gateway/events.js"; -import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { @@ -14,6 +13,7 @@ import { import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; +import { formatConnectError } from "./connect-error.ts"; import { loadAgents } from "./controllers/agents.ts"; import { loadAssistantIdentity } from "./controllers/assistant-identity.ts"; import { loadChatHistory } from "./controllers/chat.ts"; @@ -49,20 +49,6 @@ function isGenericBrowserFetchFailure(message: string): boolean { return /^(?:typeerror:\s*)?(?:fetch failed|failed to fetch)$/i.test(message.trim()); } -function formatAuthCloseErrorMessage(code: string | null, fallback: string): string { - const resolvedCode = code ?? ""; - if (resolvedCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) { - return "unauthorized: gateway token mismatch (open dashboard URL with current token)"; - } - if (resolvedCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED) { - return "unauthorized: too many failed authentication attempts (retry later)"; - } - if (resolvedCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED) { - return "unauthorized: authentication failed"; - } - return fallback; -} - type GatewayHost = { settings: UiSettings; password: string; @@ -240,7 +226,11 @@ export function connectGateway(host: GatewayHost) { if (error?.message) { host.lastError = host.lastErrorCode && isGenericBrowserFetchFailure(error.message) - ? formatAuthCloseErrorMessage(host.lastErrorCode, error.message) + ? formatConnectError({ + message: error.message, + details: error.details, + code: error.code, + } as Parameters[0]) : error.message; return; } diff --git a/ui/src/ui/connect-error.ts b/ui/src/ui/connect-error.ts new file mode 100644 index 00000000000..0dffd77cf91 --- /dev/null +++ b/ui/src/ui/connect-error.ts @@ -0,0 +1,58 @@ +import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; +import { resolveGatewayErrorDetailCode } from "./gateway.ts"; + +type ErrorWithMessageAndDetails = { + message?: unknown; + details?: unknown; +}; + +function normalizeErrorMessage(message: unknown): string { + if (typeof message === "string") { + return message; + } + if (message instanceof Error && typeof message.message === "string") { + return message.message; + } + return "unknown error"; +} + +function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): string { + const message = normalizeErrorMessage(error.message); + const detailCode = resolveGatewayErrorDetailCode(error); + + switch (detailCode) { + case ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH: + return "gateway token mismatch"; + case ConnectErrorDetailCodes.AUTH_UNAUTHORIZED: + return "gateway auth failed"; + case ConnectErrorDetailCodes.AUTH_RATE_LIMITED: + return "too many failed authentication attempts"; + case ConnectErrorDetailCodes.PAIRING_REQUIRED: + return "gateway pairing required"; + case ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED: + return "device identity required (use HTTPS/localhost or allow insecure auth explicitly)"; + case ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED: + return "origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)"; + case ConnectErrorDetailCodes.AUTH_TOKEN_MISSING: + return "gateway token missing"; + default: + break; + } + + const normalized = message.trim().toLowerCase(); + if ( + normalized === "fetch failed" || + normalized === "failed to fetch" || + normalized === "connect failed" + ) { + return "gateway connect failed"; + } + return message; +} + +export function formatConnectError(error: unknown): string { + if (error && typeof error === "object") { + return formatErrorFromMessageAndDetails(error as ErrorWithMessageAndDetails); + } + return normalizeErrorMessage(error); +} diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 65b998dc8c4..ba102fe0919 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import { handleChatEvent, loadChatHistory, type ChatEventPayload, type ChatState } from "./chat.ts"; +import { GatewayRequestError } from "../gateway.ts"; +import { + abortChatRun, + handleChatEvent, + loadChatHistory, + sendChatMessage, + type ChatEventPayload, + type ChatState, +} from "./chat.ts"; function createState(overrides: Partial = {}): ChatState { return { @@ -536,6 +544,63 @@ describe("loadChatHistory", () => { }); }); +describe("sendChatMessage", () => { + it("formats structured non-auth connect failures for chat send", async () => { + const request = vi.fn().mockRejectedValue( + new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "Fetch failed", + details: { code: "CONTROL_UI_ORIGIN_NOT_ALLOWED" }, + }), + ); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + const result = await sendChatMessage(state, "hello"); + + expect(result).toBeNull(); + expect(state.lastError).toContain("origin not allowed"); + expect(state.chatMessages.at(-1)).toMatchObject({ + role: "assistant", + content: [ + { + type: "text", + text: expect.stringContaining("origin not allowed"), + }, + ], + }); + }); +}); + +describe("abortChatRun", () => { + it("formats structured non-auth connect failures for chat abort", async () => { + // Abort now shares the same structured connect-error formatter as send. + const request = vi.fn().mockRejectedValue( + new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "Fetch failed", + details: { code: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED" }, + }), + ); + const state = createState({ + connected: true, + chatRunId: "run-1", + client: { request } as unknown as ChatState["client"], + }); + + const result = await abortChatRun(state); + + expect(result).toBe(false); + expect(request).toHaveBeenCalledWith("chat.abort", { + sessionKey: "main", + runId: "run-1", + }); + expect(state.lastError).toContain("device identity required"); + }); +}); + describe("loadChatHistory", () => { it("filters assistant NO_REPLY messages and keeps user NO_REPLY messages", async () => { const request = vi.fn().mockResolvedValue({ diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index e7773a67f56..f2fccf57f92 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,5 +1,6 @@ import { resetToolStream } from "../app-tool-stream.ts"; import { extractText } from "../chat/message-extract.ts"; +import { formatConnectError } from "../connect-error.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { ChatAttachment } from "../ui-types.ts"; import { generateUUID } from "../uuid.ts"; @@ -223,7 +224,7 @@ export async function sendChatMessage( }); return runId; } catch (err) { - const error = String(err); + const error = formatConnectError(err); state.chatRunId = null; state.chatStream = null; state.chatStreamStartedAt = null; @@ -254,7 +255,7 @@ export async function abortChatRun(state: ChatState): Promise { ); return true; } catch (err) { - state.lastError = String(err); + state.lastError = formatConnectError(err); return false; } } diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index fa661016464..d4599818c48 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -26,6 +26,8 @@ const INSECURE_CONTEXT_CODES = new Set([ ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, ]); +type AuthHintKind = "required" | "failed"; + /** Whether the overview should show device-pairing guidance for this error. */ export function shouldShowPairingHint( connected: boolean, @@ -41,30 +43,34 @@ export function shouldShowPairingHint( return lastError.toLowerCase().includes("pairing required"); } -export function shouldShowAuthHint( - connected: boolean, - lastError: string | null, - lastErrorCode?: string | null, -): boolean { - if (connected || !lastError) { - return false; +/** + * Return the overview auth hint to show, if any. + * + * Keep fallback string matching narrow so generic "connect failed" close reasons + * do not get misclassified as token/password problems. + */ +export function resolveAuthHintKind(params: { + connected: boolean; + lastError: string | null; + lastErrorCode?: string | null; + hasToken: boolean; + hasPassword: boolean; +}): AuthHintKind | null { + if (params.connected || !params.lastError) { + return null; } - if (lastErrorCode) { - return AUTH_FAILURE_CODES.has(lastErrorCode); + if (params.lastErrorCode) { + if (!AUTH_FAILURE_CODES.has(params.lastErrorCode)) { + return null; + } + return AUTH_REQUIRED_CODES.has(params.lastErrorCode) ? "required" : "failed"; } - const lower = lastError.toLowerCase(); - return lower.includes("unauthorized") || lower.includes("connect failed"); -} -export function shouldShowAuthRequiredHint( - hasToken: boolean, - hasPassword: boolean, - lastErrorCode?: string | null, -): boolean { - if (lastErrorCode) { - return AUTH_REQUIRED_CODES.has(lastErrorCode); + const lower = params.lastError.toLowerCase(); + if (!lower.includes("unauthorized")) { + return null; } - return !hasToken && !hasPassword; + return !params.hasToken && !params.hasPassword ? "required" : "failed"; } export function shouldShowInsecureContextHint( diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 3fa65b93391..313c2edf850 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; -import { shouldShowPairingHint } from "./overview-hints.ts"; +import { resolveAuthHintKind, shouldShowPairingHint } from "./overview-hints.ts"; describe("shouldShowPairingHint", () => { it("returns true for 'pairing required' close reason", () => { @@ -37,3 +37,53 @@ describe("shouldShowPairingHint", () => { ).toBe(true); }); }); + +describe("resolveAuthHintKind", () => { + it("returns required for structured auth-required codes", () => { + expect( + resolveAuthHintKind({ + connected: false, + lastError: "disconnected (4008): connect failed", + lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, + hasToken: false, + hasPassword: false, + }), + ).toBe("required"); + }); + + it("returns failed for structured auth mismatch codes", () => { + expect( + resolveAuthHintKind({ + connected: false, + lastError: "disconnected (4008): connect failed", + lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, + hasToken: true, + hasPassword: false, + }), + ).toBe("failed"); + }); + + it("does not treat generic connect failures as auth failures", () => { + expect( + resolveAuthHintKind({ + connected: false, + lastError: "disconnected (4008): connect failed", + lastErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + hasToken: true, + hasPassword: false, + }), + ).toBeNull(); + }); + + it("falls back to unauthorized string matching without structured codes", () => { + expect( + resolveAuthHintKind({ + connected: false, + lastError: "disconnected (4008): unauthorized", + lastErrorCode: null, + hasToken: true, + hasPassword: false, + }), + ).toBe("failed"); + }); +}); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index ed8ef6fb740..d24aa92ce9d 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -18,8 +18,7 @@ import { renderOverviewAttention } from "./overview-attention.ts"; import { renderOverviewCards } from "./overview-cards.ts"; import { renderOverviewEventLog } from "./overview-event-log.ts"; import { - shouldShowAuthHint, - shouldShowAuthRequiredHint, + resolveAuthHintKind, shouldShowInsecureContextHint, shouldShowPairingHint, } from "./overview-hints.ts"; @@ -103,15 +102,17 @@ export function renderOverview(props: OverviewProps) { })(); const authHint = (() => { - if (props.connected || !props.lastError) { + const authHintKind = resolveAuthHintKind({ + connected: props.connected, + lastError: props.lastError, + lastErrorCode: props.lastErrorCode, + hasToken: Boolean(props.settings.token.trim()), + hasPassword: Boolean(props.password.trim()), + }); + if (authHintKind == null) { return null; } - if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) { - return null; - } - const hasToken = Boolean(props.settings.token.trim()); - const hasPassword = Boolean(props.password.trim()); - if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) { + if (authHintKind === "required") { return html`
${t("overview.auth.required")}