From 6bcfe3a19847dc314c604fa996dafaf9dfd0b2c3 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Wed, 15 Jul 2026 17:47:17 -0400 Subject: [PATCH 001/238] feat(chat): AI agent chat panel (dev-only) Docked right-column chat talking to the ai-agent service over SSE: - ChatPanel/ChatMessages/ChatToolCalls/ChatApproval/ChatInput components - Persisted chat model (transcript, streaming, write-tool confirmations) - Panel width folds into sidePanelWidth so all panel layouts reflow - Expand toggle (400px/640px), full-screen overlay below single-panel width - Vite dev proxy /agent -> :3001 (CSP-safe); VITE_AGENT_URL for deploys - react-markdown + remark-gfm for assistant replies - Dev-gated: header robot toggle and panel render only in dev builds --- frontend/package.json | 2 + frontend/src/components/App.tsx | 17 +- frontend/src/components/Chat/ChatApproval.tsx | 32 ++++ frontend/src/components/Chat/ChatInput.tsx | 65 +++++++ .../src/components/Chat/ChatMessageItem.tsx | 83 +++++++++ frontend/src/components/Chat/ChatMessages.tsx | 36 ++++ frontend/src/components/Chat/ChatPanel.tsx | 88 +++++++++ .../src/components/Chat/ChatToolCalls.tsx | 48 +++++ frontend/src/components/Header/Header.tsx | 13 +- frontend/src/constants.ts | 2 + frontend/src/models/chat.ts | 167 ++++++++++++++++++ frontend/src/models/index.ts | 3 + frontend/src/services/agent.ts | 81 +++++++++ frontend/src/store.ts | 1 + frontend/vite.config.ts | 12 ++ 15 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Chat/ChatApproval.tsx create mode 100644 frontend/src/components/Chat/ChatInput.tsx create mode 100644 frontend/src/components/Chat/ChatMessageItem.tsx create mode 100644 frontend/src/components/Chat/ChatMessages.tsx create mode 100644 frontend/src/components/Chat/ChatPanel.tsx create mode 100644 frontend/src/components/Chat/ChatToolCalls.tsx create mode 100644 frontend/src/models/chat.ts create mode 100644 frontend/src/services/agent.ts diff --git a/frontend/package.json b/frontend/package.json index c541bfb49..80bca7e20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -61,10 +61,12 @@ "react-dropzone": "^14.3.8", "react-gtm-module": "^2.0.11", "react-i18next": "^12.3.1", + "react-markdown": "^9.0.1", "react-redux": "^9.2.0", "react-router-dom": "^5.3.4", "react-select": "^5.10.2", "react-string-replace": "^1.1.1", + "remark-gfm": "^4.0.0", "reaptcha": "^1.12.1", "reconnecting-websocket": "^4.4.0", "redux": "^5.0.1", diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 775b14416..74220ca2f 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -17,6 +17,9 @@ import { ORGANIZATION_BAR_WIDTH, REGEX_FIRST_PATH, SHOW_TRIPLE_PANEL_WIDTH, + CHAT_PANEL_WIDTH, + CHAT_PANEL_WIDTH_EXPANDED, + MODE, } from '../constants' import { State, Dispatch } from '../store' import { useMediaQuery, Box } from '@mui/material' @@ -27,6 +30,7 @@ import { SidebarMenu } from './SidebarMenu' import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' +import { ChatPanel } from './Chat/ChatPanel' import { Router } from '../routers/Router' import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' @@ -49,13 +53,23 @@ export const App: React.FC = () => { const installed = useSelector((state: State) => state.binaries.installed) const waitMessage = useSelector((state: State) => state.ui.waitMessage) const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) + const chatOpen = useSelector((state: State) => state.chat.open) + const chatExpanded = useSelector((state: State) => state.chat.expanded) const reseller = useSelector(selectResellerRef) const dispatch = useDispatch() const hideSidebar = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) const singlePanel = useMediaQuery(`(max-width:${HIDE_TWO_PANEL_WIDTH}px)`) const triplePanel = useMediaQuery(`(min-width:${SHOW_TRIPLE_PANEL_WIDTH}px)`) const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) - const sidePanelWidth = hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0) + // The open chat column reserves layout space the same way the sidebar does, + // so Panel/DoublePanel/TriplePanel all reflow and clamp their resize math to it + const chatPanelWidth = + MODE === 'development' && chatOpen && !singlePanel + ? chatExpanded + ? CHAT_PANEL_WIDTH_EXPANDED + : CHAT_PANEL_WIDTH + : 0 + const sidePanelWidth = (hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0)) + chatPanelWidth const isRootMenu = location.pathname.match(REGEX_FIRST_PATH)?.[0] === location.pathname const showBottomMenu = (mobile || browser.isMobile) && isRootMenu && hideSidebar const needsUserHydration = authenticated && !user @@ -132,6 +146,7 @@ export const App: React.FC = () => { > {hideSidebar ? : } + {MODE === 'development' && } {showBottomMenu && } diff --git a/frontend/src/components/Chat/ChatApproval.tsx b/frontend/src/components/Chat/ChatApproval.tsx new file mode 100644 index 000000000..37eaf9c22 --- /dev/null +++ b/frontend/src/components/Chat/ChatApproval.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Paper, Typography, Button, Box } from '@mui/material' + +type Props = { + toolName: string + input: Record + onRespond: (approved: boolean) => void +} + +/* Inline card shown when the agent pauses on a write tool awaiting approval */ +export const ChatApproval: React.FC = ({ toolName, input, onRespond }) => ( + + + The agent wants to run {toolName} + + + {JSON.stringify(input, null, 2)} + + + + + + +) diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx new file mode 100644 index 000000000..eda003026 --- /dev/null +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react' +import { Box, InputBase } from '@mui/material' +import { IconButton } from '../../buttons/IconButton' + +type Props = { + disabled: boolean + streaming: boolean + onSend: (text: string) => void + onStop: () => void +} + +export const ChatInput: React.FC = ({ disabled, streaming, onSend, onStop }) => { + const [text, setText] = useState('') + const submit = () => { + const trimmed = text.trim() + if (!trimmed || disabled || streaming) return + onSend(trimmed) + setText('') + } + return ( + + + setText(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + submit() + } + }} + /> + {streaming ? ( + + ) : ( + + )} + + + ) +} diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx new file mode 100644 index 000000000..02a510244 --- /dev/null +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { Box, Typography } from '@mui/material' +import { ChatTranscriptMessage } from '../../models/chat' +import { ChatToolCalls } from './ChatToolCalls' + +export const ChatMessageItem: React.FC<{ message: ChatTranscriptMessage }> = ({ message }) => { + if (message.role === 'user') + return ( + + + + {message.text} + + + + ) + + return ( + + + + {message.text} + + {message.interrupted && ( + + Interrupted + + )} + + ) +} diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx new file mode 100644 index 000000000..adb48fce5 --- /dev/null +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Box } from '@mui/material' +import { ChatTranscriptMessage } from '../../models/chat' +import { ChatMessageItem } from './ChatMessageItem' + +type Props = { + messages: ChatTranscriptMessage[] + streaming: boolean + children?: React.ReactNode +} + +export const ChatMessages: React.FC = ({ messages, streaming, children }) => { + const ref = useRef(null) + const [pinned, setPinned] = useState(true) + + // Follow the stream, but release when the user scrolls up to read + useEffect(() => { + if (pinned) ref.current?.scrollTo({ top: ref.current.scrollHeight }) + }, [messages, streaming, pinned, children]) + + return ( + { + const el = ref.current + if (el) setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 40) + }} + sx={{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }} + > + {messages.map((message, index) => ( + + ))} + {children} + + ) +} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx new file mode 100644 index 000000000..8f6c9774a --- /dev/null +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -0,0 +1,88 @@ +import React, { useEffect } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Box, Typography } from '@mui/material' +import { State, Dispatch } from '../../store' +import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants' +import { IconButton } from '../../buttons/IconButton' +import { ChatMessages } from './ChatMessages' +import { ChatApproval } from './ChatApproval' +import { ChatInput } from './ChatInput' +import { Notice } from '../Notice' + +export const ChatPanel: React.FC = () => { + const chat = useSelector((state: State) => state.chat) + const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel) + const dispatch = useDispatch() + + useEffect(() => { + if (chat.open) { + dispatch.chat.resetTransient() + dispatch.chat.checkHealth() + } + }, [chat.open]) + + if (!chat.open) return null + + return ( + + + + New Chat + + {!singlePanel && ( + dispatch.chat.set({ expanded: !chat.expanded })} + /> + )} + dispatch.chat.clearConversation()} /> + dispatch.chat.set({ open: false })} /> + + {chat.health === 'unreachable' && ( + + Agent unreachable — is the dev service running on :3001? + + )} + + {chat.pendingConfirmation && ( + dispatch.chat.confirm(approved)} + /> + )} + {chat.error && ( + dispatch.chat.set({ error: null })}> + {chat.error} + + )} + + dispatch.chat.send(text)} + onStop={() => dispatch.chat.stop()} + /> + + ) +} diff --git a/frontend/src/components/Chat/ChatToolCalls.tsx b/frontend/src/components/Chat/ChatToolCalls.tsx new file mode 100644 index 000000000..5ddd8d58c --- /dev/null +++ b/frontend/src/components/Chat/ChatToolCalls.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react' +import { Box, ButtonBase, Collapse, Typography, CircularProgress } from '@mui/material' +import { ChatToolCall } from '../../models/chat' +import { Icon } from '../Icon' + +export const ChatToolCalls: React.FC<{ toolCalls: ChatToolCall[] }> = ({ toolCalls }) => { + const [open, setOpen] = useState(false) + if (!toolCalls.length) return null + const running = toolCalls.some(c => c.status === 'running') + return ( + + setOpen(!open)} sx={{ borderRadius: 1, paddingX: 0.5, color: 'grayDark.main' }}> + {running ? ( + + ) : ( + + )} + + Used {toolCalls.length} tool{toolCalls.length === 1 ? '' : 's'} + + + + {toolCalls.map(call => ( + + + {call.name} + {call.status === 'running' ? ' …' : ''} + + {call.result && ( + + {call.result.slice(0, 200)} + + )} + + ))} + + + ) +} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 85ab21f08..3362dc64a 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,4 +1,4 @@ -import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH } from '../../constants' +import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH, MODE } from '../../constants' import React, { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import useNavigationUp from '../../hooks/useNavigationUp' @@ -29,6 +29,7 @@ export const Header: React.FC = ({ panels = 1 }) => { const { t } = useTranslation() const { searched } = useSelector(selectDeviceModelAttributes) const permissions = useSelector(selectPermissions) + const chatOpen = useSelector((state: State) => state.chat.open) const layout = useSelector((state: State) => state.ui.layout) const overlapHeader = layout.hideSidebar && browser.isElectron && browser.isMac @@ -80,6 +81,16 @@ export const Header: React.FC = ({ panels = 1 }) => { color="grayDarker" /> )} + {MODE === 'development' && ( + dispatch.chat.set({ open: !chatOpen })} + /> + )} {!showSearch && } {sidebarHidden && ( diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index d66ef15ec..71209cb27 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -91,6 +91,8 @@ export const SIGN_OUT_BACKEND_TIMEOUT = 3000 export const MAX_CONNECTION_NAME_LENGTH = 62 export const MAX_DESCRIPTION_LENGTH = 1024 export const SIDEBAR_WIDTH = 250 +export const CHAT_PANEL_WIDTH = 400 +export const CHAT_PANEL_WIDTH_EXPANDED = 640 export const ORGANIZATION_BAR_WIDTH = 70 export const HIDE_SIDEBAR_WIDTH = 1150 export const HIDE_TWO_PANEL_WIDTH = 750 diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts new file mode 100644 index 000000000..8f4e27850 --- /dev/null +++ b/frontend/src/models/chat.ts @@ -0,0 +1,167 @@ +import { createModel } from '@rematch/core' +import { RootModel } from '.' +import { streamChat, confirmTool, agentHealth, AgentEvent, AgentMessageParam } from '../services/agent' + +export type ChatToolCall = { + id: string + name: string + input: Record + status: 'running' | 'done' | 'error' + result?: string +} + +export type ChatTranscriptMessage = + | { role: 'user'; text: string } + | { role: 'assistant'; text: string; toolCalls: ChatToolCall[]; interrupted?: boolean } + +export type IChatState = { + open: boolean + expanded: boolean + messages: ChatTranscriptMessage[] + conversationId: string + streaming: boolean + pendingConfirmation: { toolUseId: string; toolName: string; input: Record } | null + error: string | null + health: 'unknown' | 'ok' | 'unreachable' +} + +export const defaultChatState: IChatState = { + open: false, + expanded: false, + messages: [], + conversationId: '', + streaming: false, + pendingConfirmation: null, + error: null, + health: 'unknown', +} + +/* Reduce one agent stream event into chat state. Mutates the immer draft. */ +function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { + const last = state.messages[state.messages.length - 1] + let assistant = last?.role === 'assistant' ? last : undefined + const ensureAssistant = () => { + if (!assistant) { + assistant = { role: 'assistant', text: '', toolCalls: [] } + state.messages.push(assistant) + } + return assistant + } + + switch (event.type) { + case 'text_delta': + ensureAssistant().text += event.text + break + case 'tool_call_start': + ensureAssistant().toolCalls.push({ id: event.id, name: event.name, input: event.input, status: 'running' }) + break + case 'tool_call_result': { + const call = assistant?.toolCalls.find(c => c.id === event.id) + if (call) { + call.status = event.isError ? 'error' : 'done' + call.result = event.result + } + break + } + case 'confirmation_required': + state.pendingConfirmation = { toolUseId: event.id, toolName: event.name, input: event.input } + break + case 'done': + state.streaming = false + state.pendingConfirmation = null + break + case 'error': + state.error = event.message + state.streaming = false + state.pendingConfirmation = null + if (assistant) assistant.interrupted = true + break + } + return state +} + +/* The agent service is stateless: resend the transcript as role/content pairs each turn */ +function toMessageParams(messages: ChatTranscriptMessage[]): AgentMessageParam[] { + return messages.filter(m => m.text.trim().length > 0).map(m => ({ role: m.role, content: m.text })) +} + +let abortController: AbortController | null = null + +export default createModel()({ + state: { ...defaultChatState }, + effects: dispatch => ({ + async send(text: string, state) { + if (state.chat.streaming || state.chat.pendingConfirmation) return + const conversationId = state.chat.conversationId || crypto.randomUUID() + const messages = toMessageParams([...state.chat.messages, { role: 'user', text }]) + dispatch.chat.addUserMessage(text) + dispatch.chat.set({ conversationId, streaming: true, error: null }) + abortController = new AbortController() + try { + await streamChat({ + conversationId, + messages, + signal: abortController.signal, + onEvent: event => dispatch.chat.applyEvent(event), + }) + } catch (error) { + if ((error as Error).name !== 'AbortError') + dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message }) + } finally { + abortController = null + dispatch.chat.set({ streaming: false }) + } + }, + async confirm(approved: boolean, state) { + const pending = state.chat.pendingConfirmation + if (!pending) return + try { + await confirmTool({ + conversationId: state.chat.conversationId, + toolUseId: pending.toolUseId, + approved, + }) + dispatch.chat.set({ pendingConfirmation: null }) + } catch (error) { + dispatch.chat.set({ error: (error as Error).message }) + } + }, + async stop() { + abortController?.abort() + abortController = null + dispatch.chat.set({ streaming: false, pendingConfirmation: null }) + }, + async checkHealth() { + dispatch.chat.set({ health: (await agentHealth()) ? 'ok' : 'unreachable' }) + }, + }), + reducers: { + set(state: IChatState, params: Partial) { + Object.assign(state, params) + return state + }, + addUserMessage(state: IChatState, text: string) { + state.messages.push({ role: 'user', text }) + return state + }, + applyEvent(state: IChatState, event: AgentEvent) { + return applyAgentEvent(state, event) + }, + // Streaming state must not survive a reload — called when the panel mounts + resetTransient(state: IChatState) { + state.streaming = false + state.pendingConfirmation = null + state.error = null + state.health = 'unknown' + return state + }, + clearConversation(state: IChatState) { + state.messages = [] + state.conversationId = '' + state.streaming = false + state.pendingConfirmation = null + state.error = null + return state + }, + }, +}) diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index 140dfea56..a99ba139d 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -13,6 +13,7 @@ import backend from './backend' import billing from './billing' import binaries from './binaries' import bluetooth from './bluetooth' +import chat from './chat' import connections from './connections' import contacts from './contacts' import devices from './devices' @@ -50,6 +51,7 @@ export interface RootModel extends Models { billing: typeof billing binaries: typeof binaries bluetooth: typeof bluetooth + chat: typeof chat connections: typeof connections contacts: typeof contacts devices: typeof devices @@ -88,6 +90,7 @@ export const models: RootModel = { billing, binaries, bluetooth, + chat, connections, contacts, devices, diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts new file mode 100644 index 000000000..d4016ac0b --- /dev/null +++ b/frontend/src/services/agent.ts @@ -0,0 +1,81 @@ +/** + * Client for the ai-agent service (REST + SSE). The service is stateless: + * the client holds the transcript and resends it each turn. + */ + +// Dev: relative path served by the vite proxy (same-origin, CSP-clean). +// Staging/prod: set VITE_AGENT_URL to the deployed agent service domain. +export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent' + +export type AgentEvent = + | { type: 'text_delta'; text: string } + | { type: 'tool_call_start'; id: string; name: string; input: Record } + | { type: 'tool_call_result'; id: string; name: string; result: string; isError: boolean; durationMs: number } + | { type: 'confirmation_required'; id: string; name: string; input: Record } + | { type: 'done'; stopReason: string | null } + | { type: 'error'; message: string } + +export type AgentMessageParam = { role: 'user' | 'assistant'; content: string } + +/* Stream one chat turn. Events arrive as SSE: `event: \ndata: \n\n` */ +export async function streamChat(options: { + conversationId: string + messages: AgentMessageParam[] + signal?: AbortSignal + onEvent: (event: AgentEvent) => void +}): Promise { + const { conversationId, messages, signal, onEvent } = options + const response = await fetch(`${AGENT_URL}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversationId, messages }), + signal, + }) + if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let index: number + while ((index = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, index) + buffer = buffer.slice(index + 2) + let event = 'message' + const dataLines: string[] = [] + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart()) + } + if (dataLines.length) onEvent({ type: event, ...JSON.parse(dataLines.join('\n')) } as AgentEvent) + } + } +} + +/* Approve or deny a write tool the agent paused on */ +export async function confirmTool(options: { + conversationId: string + toolUseId: string + approved: boolean +}): Promise { + const response = await fetch(`${AGENT_URL}/api/chat/confirm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }) + if (!response.ok) throw new Error(`Confirm failed (${response.status})`) +} + +export async function agentHealth(): Promise { + try { + const response = await fetch(`${AGENT_URL}/api/health`) + if (!response.ok) return false + const body = (await response.json()) as { ok?: boolean } + return !!body.ok + } catch { + return false + } +} diff --git a/frontend/src/store.ts b/frontend/src/store.ts index c820815be..53d53143b 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -20,6 +20,7 @@ const persistConfig: PersistConfig = { 'accounts', 'announcements', 'applicationTypes', + 'chat', 'connections', 'contacts', 'devices', diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index eaba04531..85a8e7afb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -24,5 +24,17 @@ export default defineConfig(({ mode }) => ({ resolve: { alias: { '@common': path.resolve(__dirname, '../common/src') }, }, + server: { + // Dev-only: same-origin path to the local ai-agent service, so the app's + // CSP ('self') passes without loosening. Staging/prod set VITE_AGENT_URL + // to the deployed agent domain instead — this proxy does not exist in builds. + proxy: { + '/agent': { + target: 'http://localhost:3001', + changeOrigin: true, + rewrite: p => p.replace(/^\/agent/, ''), + }, + }, + }, type: 'module', })) From 47b61aced93785cb84810e2c6263111740222bec Mon Sep 17 00:00:00 2001 From: thelg4 Date: Wed, 22 Jul 2026 13:08:39 -0400 Subject: [PATCH 002/238] feat(chat): send Hydra bearer token to the agent service Stage A of agent auth: attach Authorization from a locally stored token (pasted from the ai-agent dev harness) on chat/confirm/health calls, surface 401 reauth_required and mid-turn auth failures as a sign-in notice with a token paste field. The in-app PKCE flow replaces the paste field as the token writer in a later stage. --- frontend/src/components/Chat/ChatPanel.tsx | 31 ++++++++++++++- frontend/src/models/chat.ts | 39 +++++++++++++++--- frontend/src/services/agent.ts | 46 ++++++++++++++++++---- 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 8f6c9774a..c2c2a0721 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' -import { Box, Typography } from '@mui/material' +import { Box, TextField, Typography } from '@mui/material' import { State, Dispatch } from '../../store' import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants' import { IconButton } from '../../buttons/IconButton' @@ -13,6 +13,7 @@ export const ChatPanel: React.FC = () => { const chat = useSelector((state: State) => state.chat) const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel) const dispatch = useDispatch() + const [tokenDraft, setTokenDraft] = useState('') useEffect(() => { if (chat.open) { @@ -63,6 +64,32 @@ export const ChatPanel: React.FC = () => { Agent unreachable — is the dev service running on :3001? )} + {chat.health === 'unauthorized' && ( + + {/* Stage A: paste a token from the ai-agent dev harness + (`node scripts/hydra-login.mjs token`). Replaced by the in-app + sign-in flow in a later stage. */} + <> + Agent sign-in required. + setTokenDraft(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter' && tokenDraft.trim()) { + dispatch.chat.setToken(tokenDraft) + setTokenDraft('') + } + }} + sx={{ marginTop: 1 }} + /> + + + )} {chat.pendingConfirmation && ( } | null error: string | null - health: 'unknown' | 'ok' | 'unreachable' + health: 'unknown' | AgentHealth } export const defaultChatState: IChatState = { @@ -71,7 +80,14 @@ function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { state.pendingConfirmation = null break case 'error': - state.error = event.message + // The backend prefixes auth failures so the client knows a retry is + // pointless until the token is refreshed (e.g. it expired mid-turn). + if (event.message.startsWith('reauth_required')) { + state.error = 'Agent session expired — sign in again to continue.' + state.health = 'unauthorized' + } else { + state.error = event.message + } state.streaming = false state.pendingConfirmation = null if (assistant) assistant.interrupted = true @@ -105,7 +121,9 @@ export default createModel()({ onEvent: event => dispatch.chat.applyEvent(event), }) } catch (error) { - if ((error as Error).name !== 'AbortError') + if (error instanceof AgentAuthError) + dispatch.chat.set({ error: 'Agent authentication required — sign in to continue.', health: 'unauthorized' }) + else if ((error as Error).name !== 'AbortError') dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message }) } finally { abortController = null @@ -123,7 +141,9 @@ export default createModel()({ }) dispatch.chat.set({ pendingConfirmation: null }) } catch (error) { - dispatch.chat.set({ error: (error as Error).message }) + if (error instanceof AgentAuthError) + dispatch.chat.set({ error: 'Agent authentication required — sign in to continue.', health: 'unauthorized' }) + else dispatch.chat.set({ error: (error as Error).message }) } }, async stop() { @@ -132,7 +152,14 @@ export default createModel()({ dispatch.chat.set({ streaming: false, pendingConfirmation: null }) }, async checkHealth() { - dispatch.chat.set({ health: (await agentHealth()) ? 'ok' : 'unreachable' }) + dispatch.chat.set({ health: await agentHealth() }) + }, + // Stage A: token pasted from the ai-agent dev harness. The in-app Hydra + // PKCE flow becomes the writer in a later stage; readers are unchanged. + async setToken(token: string) { + setAgentToken(token) + dispatch.chat.set({ error: null }) + await dispatch.chat.checkHealth() }, }), reducers: { diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index d4016ac0b..85b17f657 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -7,6 +7,33 @@ // Staging/prod: set VITE_AGENT_URL to the deployed agent service domain. export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent' +// Hydra access token for the agent service (AUTH_MODE=hydra). Stage A: the +// token is pasted in (from `node scripts/hydra-login.mjs token` in the +// ai-agent repo); the in-app PKCE flow replaces this as the writer later. +// localStorage matches where Amplify keeps the Cognito session today. +const AGENT_TOKEN_KEY = 'agentToken' + +export const getAgentToken = (): string | null => window.localStorage.getItem(AGENT_TOKEN_KEY) + +export function setAgentToken(token: string | null): void { + if (token?.trim()) window.localStorage.setItem(AGENT_TOKEN_KEY, token.trim().replace(/^Bearer\s+/i, '')) + else window.localStorage.removeItem(AGENT_TOKEN_KEY) +} + +/* The agent rejected our credential (401 reauth_required) — sign in again */ +export class AgentAuthError extends Error { + constructor() { + super('Agent authentication required') + } +} + +function agentHeaders(json = true): Record { + const headers: Record = json ? { 'Content-Type': 'application/json' } : {} + const token = getAgentToken() + if (token) headers.Authorization = `Bearer ${token}` + return headers +} + export type AgentEvent = | { type: 'text_delta'; text: string } | { type: 'tool_call_start'; id: string; name: string; input: Record } @@ -27,10 +54,11 @@ export async function streamChat(options: { const { conversationId, messages, signal, onEvent } = options const response = await fetch(`${AGENT_URL}/api/chat`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: agentHeaders(), body: JSON.stringify({ conversationId, messages }), signal, }) + if (response.status === 401) throw new AgentAuthError() if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`) const reader = response.body.getReader() @@ -63,19 +91,23 @@ export async function confirmTool(options: { }): Promise { const response = await fetch(`${AGENT_URL}/api/chat/confirm`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: agentHeaders(), body: JSON.stringify(options), }) + if (response.status === 401) throw new AgentAuthError() if (!response.ok) throw new Error(`Confirm failed (${response.status})`) } -export async function agentHealth(): Promise { +export type AgentHealth = 'ok' | 'unauthorized' | 'unreachable' + +export async function agentHealth(): Promise { try { - const response = await fetch(`${AGENT_URL}/api/health`) - if (!response.ok) return false + const response = await fetch(`${AGENT_URL}/api/health`, { headers: agentHeaders(false) }) + if (response.status === 401) return 'unauthorized' + if (!response.ok) return 'unreachable' const body = (await response.json()) as { ok?: boolean } - return !!body.ok + return body.ok ? 'ok' : 'unreachable' } catch { - return false + return 'unreachable' } } From 1857be03511d8ffd83d3e880f6894502216fd618 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Wed, 22 Jul 2026 13:16:57 -0400 Subject: [PATCH 003/238] feat(chat): in-app Hydra sign-in for the agent (PKCE + DCR) Replaces the pasted-token stopgap: a Sign in button self-registers an OAuth client (Dynamic Client Registration), redirects to the Hydra login/consent pages, exchanges the code on return (PKCE), and silently refreshes the 30m access token before each turn. Register/token calls ride the new dev-only /hydra vite proxy so no CORS setup is needed; packaged builds need the origin allow-listed or a main-process exchange. --- frontend/src/components/Chat/ChatPanel.tsx | 35 ++-- frontend/src/models/chat.ts | 25 ++- frontend/src/services/agent.ts | 29 +++- frontend/src/services/hydra.ts | 187 +++++++++++++++++++++ frontend/vite.config.ts | 9 + 5 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 frontend/src/services/hydra.ts diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index c2c2a0721..47f998598 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' -import { Box, TextField, Typography } from '@mui/material' +import { Box, Button, Typography } from '@mui/material' import { State, Dispatch } from '../../store' import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants' import { IconButton } from '../../buttons/IconButton' @@ -13,7 +13,12 @@ export const ChatPanel: React.FC = () => { const chat = useSelector((state: State) => state.chat) const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel) const dispatch = useDispatch() - const [tokenDraft, setTokenDraft] = useState('') + + // Completes a Hydra sign-in redirect if this page load carries ?code — + // runs on mount regardless of whether the panel is open + useEffect(() => { + dispatch.chat.handleSignInCallback() + }, []) useEffect(() => { if (chat.open) { @@ -66,27 +71,17 @@ export const ChatPanel: React.FC = () => { )} {chat.health === 'unauthorized' && ( - {/* Stage A: paste a token from the ai-agent dev harness - (`node scripts/hydra-login.mjs token`). Replaced by the in-app - sign-in flow in a later stage. */} <> - Agent sign-in required. - setTokenDraft(event.target.value)} - onKeyDown={event => { - if (event.key === 'Enter' && tokenDraft.trim()) { - dispatch.chat.setToken(tokenDraft) - setTokenDraft('') - } - }} + variant="contained" + onClick={() => dispatch.chat.signIn()} sx={{ marginTop: 1 }} - /> + > + Sign in with remote.it + )} diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index d2902f9ff..a72e1b543 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -10,6 +10,7 @@ import { AgentHealth, AgentMessageParam, } from '../services/agent' +import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken } from '../services/hydra' export type ChatToolCall = { id: string @@ -114,6 +115,7 @@ export default createModel()({ dispatch.chat.set({ conversationId, streaming: true, error: null }) abortController = new AbortController() try { + await ensureFreshAgentToken() await streamChat({ conversationId, messages, @@ -134,6 +136,7 @@ export default createModel()({ const pending = state.chat.pendingConfirmation if (!pending) return try { + await ensureFreshAgentToken() await confirmTool({ conversationId: state.chat.conversationId, toolUseId: pending.toolUseId, @@ -152,10 +155,28 @@ export default createModel()({ dispatch.chat.set({ streaming: false, pendingConfirmation: null }) }, async checkHealth() { + await ensureFreshAgentToken() dispatch.chat.set({ health: await agentHealth() }) }, - // Stage A: token pasted from the ai-agent dev harness. The in-app Hydra - // PKCE flow becomes the writer in a later stage; readers are unchanged. + /* Full-page redirect to the Hydra login (registers a client first if + needed); handleSignInCallback picks up the return after reload */ + async signIn() { + try { + await startAgentSignIn() + } catch (error) { + dispatch.chat.set({ error: (error as Error).message }) + } + }, + /* Complete a sign-in redirect if this page load carries one */ + async handleSignInCallback() { + const result = await handleAgentSignInCallback() + if (!result) return + if (result.ok) dispatch.chat.set({ error: null, open: true }) + else dispatch.chat.set({ error: `Agent sign-in failed — ${result.error}`, open: true }) + await dispatch.chat.checkHealth() + }, + // Dev fallback: token pasted from the ai-agent harness (devtools: + // localStorage.agentToken). The sign-in flow is the normal writer. async setToken(token: string) { setAgentToken(token) dispatch.chat.set({ error: null }) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 85b17f657..bad2572f0 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -7,11 +7,18 @@ // Staging/prod: set VITE_AGENT_URL to the deployed agent service domain. export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent' -// Hydra access token for the agent service (AUTH_MODE=hydra). Stage A: the -// token is pasted in (from `node scripts/hydra-login.mjs token` in the -// ai-agent repo); the in-app PKCE flow replaces this as the writer later. -// localStorage matches where Amplify keeps the Cognito session today. +// Hydra credentials for the agent service (AUTH_MODE=hydra), written by the +// in-app sign-in flow (services/hydra.ts) — or a token pasted from the +// ai-agent dev harness as a fallback. localStorage matches where Amplify +// keeps the Cognito session today. const AGENT_TOKEN_KEY = 'agentToken' +const AGENT_SESSION_KEY = 'agentSession' + +export type AgentSession = { + refresh_token: string + expires_at: number + client_id: string +} export const getAgentToken = (): string | null => window.localStorage.getItem(AGENT_TOKEN_KEY) @@ -20,6 +27,20 @@ export function setAgentToken(token: string | null): void { else window.localStorage.removeItem(AGENT_TOKEN_KEY) } +export function getAgentSession(): AgentSession | null { + try { + const raw = window.localStorage.getItem(AGENT_SESSION_KEY) + return raw ? (JSON.parse(raw) as AgentSession) : null + } catch { + return null + } +} + +export function setAgentSession(session: AgentSession | null): void { + if (session) window.localStorage.setItem(AGENT_SESSION_KEY, JSON.stringify(session)) + else window.localStorage.removeItem(AGENT_SESSION_KEY) +} + /* The agent rejected our credential (401 reauth_required) — sign in again */ export class AgentAuthError extends Error { constructor() { diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts new file mode 100644 index 000000000..a923b71b9 --- /dev/null +++ b/frontend/src/services/hydra.ts @@ -0,0 +1,187 @@ +/** + * In-app Hydra sign-in for the agent service — OAuth 2.1 authorization code + + * PKCE, with self-service Dynamic Client Registration. Mirrors the MCP demo + * SPA in the authentication repo (hydra-login-consent/scripts/demo-spa). + * + * Flow: ensure a DCR client for this origin → full-page redirect to the Hydra + * login/consent pages → return to the app root with ?code → exchange for + * tokens → store access token + refresh session (services/agent.ts) → + * silently refresh before expiry. + * + * The register/token calls go through the dev vite proxy at /hydra + * (same-origin, so no CORS); the login redirect itself is a top-level + * navigation to the real issuer. Packaged builds need the app origin on the + * OAuth front's CORS allow-list, or the exchange moved to the Electron main + * process. + */ +import { getAgentSession, getAgentToken, setAgentSession, setAgentToken } from './agent' + +export const HYDRA_ISSUER = import.meta.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it' +export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.evan.remote.it/mcp' +const SCOPE = 'openid offline email device:read device:write device:connect device:execute' +const LIFESPAN = '30m' // access-token TTL override, verified accepted via DCR + +// Same-origin proxy path for fetch calls (vite server.proxy['/hydra']) +const OAUTH_API = '/hydra' + +const CLIENT_KEY = 'agentOauthClient' +const FLOW_KEY = 'agentOauthFlow' + +const b64url = (bytes: ArrayBuffer | Uint8Array): string => + btoa(String.fromCharCode(...new Uint8Array(bytes))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +const randomString = (length: number): string => { + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return b64url(bytes) +} + +const sha256 = async (value: string): Promise => + b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))) + +// The redirect must land somewhere this SPA is served; the app uses hash +// routing, so the root URL with a ?code query never collides with a route. +const redirectUri = (): string => `${window.location.origin}/` + +type StoredClient = { client_id: string; key: string } + +// One public client per (issuer, origin, scope, audience) — the cache key +// busts when the requested grant changes, like the demo SPA. +const clientCacheKey = (): string => `${HYDRA_ISSUER}|${window.location.origin}|${SCOPE}|${MCP_AUDIENCE}` + +async function ensureClient(): Promise { + try { + const cached = JSON.parse(window.localStorage.getItem(CLIENT_KEY) || 'null') as StoredClient | null + if (cached?.client_id && cached.key === clientCacheKey()) return cached.client_id + } catch {} + + const response = await fetch(`${OAUTH_API}/oauth2/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'remote.it desktop agent chat', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: [redirectUri()], + scope: SCOPE, + token_endpoint_auth_method: 'none', + authorization_code_grant_access_token_lifespan: LIFESPAN, + refresh_token_grant_access_token_lifespan: LIFESPAN, + }), + }) + if (!response.ok) throw new Error(`Agent sign-in registration failed (${response.status}): ${await response.text()}`) + const { client_id } = (await response.json()) as { client_id: string } + window.localStorage.setItem(CLIENT_KEY, JSON.stringify({ client_id, key: clientCacheKey() })) + return client_id +} + +async function tokenRequest(params: Record): Promise<{ + access_token: string + refresh_token?: string + expires_in?: number +}> { + const response = await fetch(`${OAUTH_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }) + const text = await response.text() + if (!response.ok) throw new Error(`Agent token exchange failed (${response.status}): ${text}`) + return JSON.parse(text) +} + +function storeTokens(clientId: string, tokens: { access_token: string; refresh_token?: string; expires_in?: number }) { + setAgentToken(tokens.access_token) + setAgentSession({ + refresh_token: tokens.refresh_token || getAgentSession()?.refresh_token || '', + expires_at: Date.now() + (tokens.expires_in ?? 0) * 1000, + client_id: clientId, + }) +} + +/* Kick off the sign-in: registers the client if needed, then navigates the + whole window to the Hydra login page. The app reloads on return. */ +export async function startAgentSignIn(): Promise { + const clientId = await ensureClient() + const verifier = randomString(32) + const state = randomString(16) + window.sessionStorage.setItem(FLOW_KEY, JSON.stringify({ verifier, state, clientId })) + + const auth = new URL(`${HYDRA_ISSUER}/oauth2/auth`) + auth.searchParams.set('response_type', 'code') + auth.searchParams.set('client_id', clientId) + auth.searchParams.set('redirect_uri', redirectUri()) + auth.searchParams.set('scope', SCOPE) + auth.searchParams.set('state', state) + auth.searchParams.set('code_challenge', await sha256(verifier)) + auth.searchParams.set('code_challenge_method', 'S256') + // RFC 8707: binds the access token's audience to the MCP resource + auth.searchParams.set('resource', MCP_AUDIENCE) + window.location.assign(auth.toString()) +} + +/* Complete the flow after the redirect back. Call once on app boot; returns + null when the URL carries no sign-in response. */ +export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?: string } | null> { + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const error = params.get('error') + if (!code && !error) return null + + // Strip the single-use code from the URL (keep the hash route) so a + // refresh doesn't try to reuse it. + window.history.replaceState({}, '', window.location.pathname + window.location.hash) + + if (error) { + // A client cached from before a scope/resource change can be rejected at + // authorize (e.g. invalid_target); drop it so the next attempt re-registers. + window.localStorage.removeItem(CLIENT_KEY) + return { ok: false, error: `${error}: ${params.get('error_description') || ''}` } + } + + const flow = JSON.parse(window.sessionStorage.getItem(FLOW_KEY) || 'null') as { + verifier: string + state: string + clientId: string + } | null + window.sessionStorage.removeItem(FLOW_KEY) + if (!flow || params.get('state') !== flow.state) return { ok: false, error: 'Sign-in expired — try again.' } + + try { + const tokens = await tokenRequest({ + grant_type: 'authorization_code', + code: code!, + redirect_uri: redirectUri(), + client_id: flow.clientId, + code_verifier: flow.verifier, + }) + storeTokens(flow.clientId, tokens) + return { ok: true } + } catch (err) { + return { ok: false, error: (err as Error).message } + } +} + +/* Refresh the access token when it is missing or close to expiry. Silent + no-op when there is nothing to refresh (e.g. a hand-pasted token). */ +export async function ensureFreshAgentToken(): Promise { + const session = getAgentSession() + if (!session?.refresh_token) return + const fresh = getAgentToken() && Date.now() < session.expires_at - 60_000 + if (fresh) return + try { + const tokens = await tokenRequest({ + grant_type: 'refresh_token', + refresh_token: session.refresh_token, + client_id: session.client_id, + }) + storeTokens(session.client_id, tokens) + } catch { + // Refresh chain dead (revoked or reuse-detection) — clear so the UI + // falls back to the sign-in prompt on the next 401. + setAgentSession(null) + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 85a8e7afb..0367f8fbd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -34,6 +34,15 @@ export default defineConfig(({ mode }) => ({ changeOrigin: true, rewrite: p => p.replace(/^\/agent/, ''), }, + // Dev-only: same-origin path to the Hydra OAuth front so the browser's + // DCR + token-exchange calls avoid CORS entirely (top-level login + // redirects go to the real domain and don't need this). Packaged builds + // need the origin CORS-allow-listed or a main-process exchange instead. + '/hydra': { + target: process.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it', + changeOrigin: true, + rewrite: p => p.replace(/^\/hydra/, ''), + }, }, }, type: 'module', From de03d1a3a570907475a75cfccd0a249bef7fe77e Mon Sep 17 00:00:00 2001 From: thelg4 Date: Wed, 22 Jul 2026 13:34:39 -0400 Subject: [PATCH 004/238] fix(chat): stop Amplify from swallowing the agent sign-in callback Amplify's OAuth listener consumes and strips ?code/state on page load for the Cognito flow; the Hydra callback uses the same params on the same origin, so the agent exchange never ran and sign-in looped. Capture the params at module-evaluation time (before Amplify configures), claim them only when this tab started an agent sign-in, and strip the URL immediately so neither flow double-handles the code. --- frontend/src/services/hydra.ts | 37 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts index a923b71b9..7a4cc9b78 100644 --- a/frontend/src/services/hydra.ts +++ b/frontend/src/services/hydra.ts @@ -27,6 +27,22 @@ const OAUTH_API = '/hydra' const CLIENT_KEY = 'agentOauthClient' const FLOW_KEY = 'agentOauthFlow' +// Captured synchronously at module-evaluation time: the app's Cognito side +// (Amplify, configured with an oauth block) installs a URL listener that +// consumes and strips ?code/state params for ITS authorization-code flow. +// Our Hydra callback uses the same param names on the same origin, so we must +// grab them before Amplify boots — and only claim them when this tab actually +// started an agent sign-in (flow state present), so a genuine Cognito +// callback is left untouched. +const bootParams = new URLSearchParams(window.location.search) +const isAgentCallback = + !!window.sessionStorage.getItem(FLOW_KEY) && (bootParams.has('code') || bootParams.has('error')) +if (isAgentCallback) { + // Strip immediately: hides the single-use code from Amplify's listener and + // from any reload. The hash route is preserved. + window.history.replaceState({}, '', window.location.pathname + window.location.hash) +} + const b64url = (bytes: ArrayBuffer | Uint8Array): string => btoa(String.fromCharCode(...new Uint8Array(bytes))) .replace(/\+/g, '-') @@ -123,23 +139,22 @@ export async function startAgentSignIn(): Promise { window.location.assign(auth.toString()) } +let callbackConsumed = false + /* Complete the flow after the redirect back. Call once on app boot; returns - null when the URL carries no sign-in response. */ + null when this page load carries no agent sign-in response. Reads the + module-scope capture, not the live URL (already stripped above). */ export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?: string } | null> { - const params = new URLSearchParams(window.location.search) - const code = params.get('code') - const error = params.get('error') - if (!code && !error) return null - - // Strip the single-use code from the URL (keep the hash route) so a - // refresh doesn't try to reuse it. - window.history.replaceState({}, '', window.location.pathname + window.location.hash) + if (!isAgentCallback || callbackConsumed) return null + callbackConsumed = true + const code = bootParams.get('code') + const error = bootParams.get('error') if (error) { // A client cached from before a scope/resource change can be rejected at // authorize (e.g. invalid_target); drop it so the next attempt re-registers. window.localStorage.removeItem(CLIENT_KEY) - return { ok: false, error: `${error}: ${params.get('error_description') || ''}` } + return { ok: false, error: `${error}: ${bootParams.get('error_description') || ''}` } } const flow = JSON.parse(window.sessionStorage.getItem(FLOW_KEY) || 'null') as { @@ -148,7 +163,7 @@ export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error? clientId: string } | null window.sessionStorage.removeItem(FLOW_KEY) - if (!flow || params.get('state') !== flow.state) return { ok: false, error: 'Sign-in expired — try again.' } + if (!flow || bootParams.get('state') !== flow.state) return { ok: false, error: 'Sign-in expired — try again.' } try { const tokens = await tokenRequest({ From 2f2b69fb5bc93b9f74a7d9983db3598c429e0d3d Mon Sep 17 00:00:00 2001 From: thelg4 Date: Wed, 22 Jul 2026 13:43:19 -0400 Subject: [PATCH 005/238] feat(chat): sign the agent out with the app App sign-out now clears the stored Hydra access/refresh tokens, resets the chat transcript, and best-effort revokes the refresh token at Hydra so the otherwise never-expiring refresh chain dies server-side. Local credentials are cleared synchronously so a sign-out-triggered reload cannot race the revoke call. --- frontend/src/models/auth.ts | 4 ++++ frontend/src/models/chat.ts | 13 ++++++++++++- frontend/src/services/hydra.ts | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 904a2a8e0..385ca9983 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -274,6 +274,10 @@ export default createModel()({ */ async signedOut(_: void, state) { await persistor.purge() + // Agent (Hydra) session goes with the app session — clears stored + // tokens synchronously, revoke is fire-and-forget so sign-out never + // blocks on it + dispatch.chat.signOut() // purge has to happen before signOut because signOut can trigger a reload await state.auth.authService?.signOut() await dispatch.auth.set({ user: undefined }) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index a72e1b543..a37a16178 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -10,7 +10,7 @@ import { AgentHealth, AgentMessageParam, } from '../services/agent' -import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken } from '../services/hydra' +import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra' export type ChatToolCall = { id: string @@ -182,6 +182,14 @@ export default createModel()({ dispatch.chat.set({ error: null }) await dispatch.chat.checkHealth() }, + /* App sign-out tears the agent session down with it: revoke + clear the + Hydra credentials and drop the transcript */ + async signOut() { + abortController?.abort() + abortController = null + dispatch.chat.reset() + await agentSignOut() + }, }), reducers: { set(state: IChatState, params: Partial) { @@ -211,5 +219,8 @@ export default createModel()({ state.error = null return state }, + reset() { + return { ...defaultChatState } + }, }, }) diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts index 7a4cc9b78..14f539dbe 100644 --- a/frontend/src/services/hydra.ts +++ b/frontend/src/services/hydra.ts @@ -180,6 +180,30 @@ export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error? } } +/* Sign the agent out alongside the app: drop the local credentials, then + best-effort revoke the refresh token so Hydra's otherwise never-expiring + refresh chain dies server-side too. The DCR client registration is kept — + it belongs to the app origin, not the user. */ +export async function agentSignOut(): Promise { + const session = getAgentSession() + const token = getAgentToken() + setAgentToken(null) + setAgentSession(null) + const revoke = (value: string, clientId: string) => + fetch(`${OAUTH_API}/oauth2/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: value, client_id: clientId }), + }) + try { + if (session?.refresh_token) await revoke(session.refresh_token, session.client_id) + else if (token && session?.client_id) await revoke(token, session.client_id) + } catch { + // Offline or proxy unavailable — locals are already cleared; the access + // token dies at its 30m TTL. + } +} + /* Refresh the access token when it is missing or close to expiry. Silent no-op when there is nothing to refresh (e.g. a hand-pasted token). */ export async function ensureFreshAgentToken(): Promise { From 080acd14efc55631696defe577b04764ee002e28 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 13:58:11 -0400 Subject: [PATCH 006/238] docs(chat): design spec for chat panel org selector Co-Authored-By: Claude Fable 5 --- .../2026-07-27-chat-org-selector-design.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-chat-org-selector-design.md diff --git a/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md b/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md new file mode 100644 index 000000000..fecfda9f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md @@ -0,0 +1,106 @@ +# Chat Panel Organization Selector — Design + +**Date:** 2026-07-27 +**Repos:** `remoteit/desktop` (frontend), `remoteit/ai-agent` (backend) +**Branch:** `feature/agent-chat-interface` + +## Purpose + +Let the user pick which organization the AI agent chat is scoped to, and pass +that org to the ai-agent service so the agent no longer has to resolve org +context itself (via `whoami` + membership queries) before making org-scoped +GraphQL/MCP calls. + +## Decisions (from brainstorming) + +1. **Independent dropdown** in the chat panel, below the header. Defaults to + the app's active org (`accounts.activeId`) but can diverge from it. Not + persisted across reloads. +2. **Backend consumes the org via system prompt injection** — no MCP or tool + layer changes; the agent stays free to query other orgs when explicitly + asked. +3. **Switching org mid-conversation keeps the chat**; the new org simply + applies from the next turn. No transcript divider, no reset. +4. **Personal account = omit**: when the selection is the user's personal + account, the frontend omits the `org` field entirely. The agent's default + behavior is already personal-account scope, so nothing needs to be said. + +## Frontend (`remoteit/desktop`) + +### State — `frontend/src/models/chat.ts` + +- Add `orgId: string | null` to `IChatState` (default `null`). +- When the panel opens (existing `chat.open` effect path): if `orgId` is null + or no longer matches the user's id or any membership, set it to + `accounts.activeId`. +- `send()` resolves `orgId` to `{ id, name }`: + - Name lookup via `state.organization.accounts[orgId]?.name`, membership via + `state.accounts.membership` (same sources as `OrganizationSelect.tsx`). + - If `orgId` equals the user's own id (personal account), pass `undefined`. +- Passes `org` to `streamChat` each turn (service is stateless). + +### UI — new `frontend/src/components/Chat/ChatOrgSelect.tsx` + +- Compact MUI `Select`, rendered in `ChatPanel.tsx` directly below the header + row. +- Options: "Personal" first (value = user id), then org memberships sorted by + name (skip memberships whose org data hasn't loaded, mirroring the + `disabled: !org.id` guard in `OrganizationSelect.tsx`). +- Always enabled, including while streaming — a change only affects the next + turn. +- `onChange` → `dispatch.chat.set({ orgId })`. + +### Client — `frontend/src/services/agent.ts` + +- `streamChat` options gain `org?: { id: string; name: string }`. +- Included in the `/api/chat` POST body alongside `conversationId` and + `messages` when present. + +## Backend (`remoteit/ai-agent`) + +### `src/server.ts` + +- `parseChatRequest` accepts optional `org`. Validation: if present it must be + `{ id: string, name: string }` with non-empty strings — otherwise 400. + +### `src/chatService.ts` + +- `runChatTurn` gains an optional `org` parameter, threaded to the agent loop + / prompt assembly. +- Audit log entries for the turn include the org id. + +### System prompt + +- When `org` is present, append a section to the system prompt: + + > ## Selected organization + > The user has selected organization "" (accountId ``) in the + > app. Use this accountId for org-scoped tools unless the user explicitly + > asks about a different organization or their personal account. + +- **Sanitization:** the org name is user-influenced data entering the system + prompt. Strip newlines/control characters and cap length (~100 chars) + before injection. The id is validated as a plausible id string (no + whitespace/newlines). + +## Error handling + +- `org` is optional end-to-end; omitted → behavior identical to today. +- Malformed `org` → 400 from the server (matches existing body validation + style). +- Frontend never blocks a send on org resolution. The dropdown only offers + orgs whose data has loaded, so the name lookup should always succeed; if + state is inconsistent anyway (name missing), the frontend omits the `org` + field for that turn rather than sending a value the server would reject. + +## Testing + +- **ai-agent (vitest):** + - `/api/chat` accepts a valid `org` and the assembled system prompt + contains the org section. + - Malformed `org` (wrong types, empty strings) → 400. + - Omitted `org` → prompt unchanged from today. + - Sanitization: newlines and over-long names are cleaned before injection. +- **desktop frontend:** `npm run typecheck`; manual verification in the dev + panel (select org → agent call carries it; personal → field absent; + mid-conversation switch applies next turn). From 368768a05d7ddab925b8d6deac8d81d958c95c67 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 14:09:28 -0400 Subject: [PATCH 007/238] docs(chat): implementation plan for chat panel org selector Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-27-chat-org-selector.md | 682 ++++++++++++++++++ 1 file changed, 682 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-chat-org-selector.md diff --git a/docs/superpowers/plans/2026-07-27-chat-org-selector.md b/docs/superpowers/plans/2026-07-27-chat-org-selector.md new file mode 100644 index 000000000..fa3dc62db --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-chat-org-selector.md @@ -0,0 +1,682 @@ +# Chat Panel Org Selector Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** An org dropdown in the AI chat panel whose selection rides on every `/api/chat` request so the ai-agent scopes org queries without hunting for org context. + +**Architecture:** Frontend (rematch model + MUI Select in `remoteit/desktop`) sends an optional `org: { id, name }` in the chat body each turn; the ai-agent service validates it, threads it through `runChatTurn` → `runAgentLoop`, and injects a sanitized "Selected organization" section into the system prompt after the cache breakpoint. Personal account = field omitted (today's default behavior). + +**Tech Stack:** React + rematch + MUI (desktop frontend), Express + vitest (ai-agent, TypeScript ESM — note `.js` import suffixes). + +**Spec:** `docs/superpowers/specs/2026-07-27-chat-org-selector-design.md` + +## Global Constraints + +- Two repos: `/Users/larrygunteriv/github/remoteit/desktop` (branch `feature/agent-chat-interface`) and `/Users/larrygunteriv/github/remoteit/ai-agent` (create branch `feature/chat-org-scope` off current HEAD, which is `feat/docker-containerization`). NEVER commit to or push `main` in either repo. +- `org` is optional end-to-end; omitted → behavior byte-identical to today. +- Org name is user-influenced data entering the system prompt: strip control chars, collapse whitespace, cap at 100 chars. Org id must match `/^[A-Za-z0-9-]{1,64}$/`. +- The org system block goes AFTER the prompt-cache breakpoint (it changes when the user switches orgs; it must not invalidate the cached prefix). +- ai-agent uses ESM imports with `.js` suffixes (`import ... from "./systemPrompt.js"`); tests run with `npx vitest run `. +- Desktop frontend has no unit-test infra; its verification is `npm run typecheck` (run in `frontend/`) plus the manual check in Task 6. + +--- + +### Task 1: `orgSystemSection` helper (ai-agent) + +**Files:** +- Modify: `src/systemPrompt.ts` (append after the `SYSTEM_PROMPT` export) +- Test: `test/systemPrompt.org.test.ts` (new) + +**Interfaces:** +- Produces: `export type OrgSelection = { id: string; name: string }` and `export function orgSystemSection(org: OrgSelection): string | null` — `null` means "omit the section". Tasks 2–3 import both from `./systemPrompt.js`. + +- [ ] **Step 1: Create the ai-agent feature branch** + +```bash +cd /Users/larrygunteriv/github/remoteit/ai-agent +git status --short # confirm no unrelated staged changes; leave any untracked files alone +git checkout -b feature/chat-org-scope +``` + +- [ ] **Step 2: Write the failing test** + +Create `test/systemPrompt.org.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { orgSystemSection } from "../src/systemPrompt.js"; + +describe("orgSystemSection", () => { + it("renders the section with name and accountId", () => { + const s = orgSystemSection({ id: "org-123-abc", name: "Acme Inc" }); + expect(s).toContain("## Selected organization"); + expect(s).toContain('organization "Acme Inc"'); + expect(s).toContain("accountId `org-123-abc`"); + expect(s).toContain("unless the user explicitly asks"); + }); + + it("strips control characters and collapses whitespace in the name", () => { + const s = orgSystemSection({ id: "org-1", name: "Acme\nInc\t\u0000 Corp" }); + expect(s).toContain('organization "Acme Inc Corp"'); + expect(s).not.toContain("Acme\nInc"); + }); + + it("caps the name at 100 characters", () => { + const s = orgSystemSection({ id: "org-1", name: "x".repeat(500) }); + expect(s).toContain(`"${"x".repeat(100)}"`); + expect(s).not.toContain("x".repeat(101)); + }); + + it("returns null for an id that fails the allowlist", () => { + expect(orgSystemSection({ id: "bad id\nwith spaces", name: "Acme" })).toBeNull(); + expect(orgSystemSection({ id: "", name: "Acme" })).toBeNull(); + expect(orgSystemSection({ id: "x".repeat(65), name: "Acme" })).toBeNull(); + }); + + it("returns null when the name is empty after sanitization", () => { + expect(orgSystemSection({ id: "org-1", name: "\u0000\u0001 \n " })).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run test/systemPrompt.org.test.ts` +Expected: FAIL — `orgSystemSection` is not exported. + +- [ ] **Step 4: Implement** + +Append to `src/systemPrompt.ts`: + +```typescript +export type OrgSelection = { id: string; name: string }; + +/** + * System section for the org the user selected in the app. The name is + * user-influenced data entering the system prompt, so it is sanitized; + * returns null (omit the section) if either value doesn't survive. + */ +export function orgSystemSection(org: OrgSelection): string | null { + const id = org.id.trim(); + if (!/^[A-Za-z0-9-]{1,64}$/.test(id)) return null; + const name = org.name + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 100); + if (!name) return null; + return `## Selected organization\n\nThe user has selected organization "${name}" (accountId \`${id}\`) in the app. Use this accountId for org-scoped tools unless the user explicitly asks about a different organization or their personal account.`; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run test/systemPrompt.org.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/systemPrompt.ts test/systemPrompt.org.test.ts +git commit -m "feat(org): sanitized system-prompt section for the selected org" +``` + +--- + +### Task 2: Org block in the agent loop's system prompt (ai-agent) + +**Files:** +- Modify: `src/agentLoop.ts` (deps interface ~line 87–107, system assembly ~line 149–158) +- Test: `test/agentLoop.org.test.ts` (new) + +**Interfaces:** +- Consumes: `orgSystemSection`, `OrgSelection` from Task 1. +- Produces: `AgentLoopDeps` gains `org?: OrgSelection`. Task 3 sets it from `runChatTurn`. + +- [ ] **Step 1: Write the failing test** + +Create `test/agentLoop.org.test.ts` (fake-anthropic pattern copied from `test/agentLoop.wait.test.ts`, extended to capture the stream params): + +```typescript +import { describe, expect, it } from "vitest"; +import type Anthropic from "@anthropic-ai/sdk"; +import { runAgentLoop } from "../src/agentLoop.js"; +import type { AuditLogger } from "../src/auditLog.js"; +import type { McpConnection } from "../src/mcp/types.js"; + +type SystemBlock = { type: string; text: string; cache_control?: { type: string } }; + +/** Fake anthropic that records each stream() call's params and ends the turn. */ +function capturingAnthropic(captured: Array<{ system: SystemBlock[] }>): Anthropic { + return { + messages: { + stream: (params: { system: SystemBlock[] }) => { + captured.push(params); + return { on: () => {}, finalMessage: async () => ({ stop_reason: "end_turn", content: [] }) }; + }, + }, + } as unknown as Anthropic; +} + +const idleMcp: McpConnection = { + listTools: async () => [], + callTool: async () => ({ text: "{}", isError: false }), + close: async () => {}, +}; + +const audit = { log: () => {} } as unknown as AuditLogger; + +function baseDeps(captured: Array<{ system: SystemBlock[] }>) { + return { + anthropic: capturingAnthropic(captured), + mcp: idleMcp, + audit, + emit: () => {}, + classify: () => "read" as const, + waitForConfirmation: async () => true, + }; +} + +const turn = [{ role: "user" as const, content: "list my devices" }]; + +describe("org scope in the system prompt", () => { + it("appends the org section after the cache breakpoint", async () => { + const captured: Array<{ system: SystemBlock[] }> = []; + await runAgentLoop( + { ...baseDeps(captured), org: { id: "org-123", name: "Acme Inc" } }, + "conv-org", + turn, + ); + const system = captured[0].system; + const last = system[system.length - 1]; + expect(last.text).toContain("accountId `org-123`"); + expect(last.cache_control).toBeUndefined(); + expect(system[system.length - 2].cache_control).toEqual({ type: "ephemeral" }); + }); + + it("without org, the last system block carries the cache breakpoint", async () => { + const captured: Array<{ system: SystemBlock[] }> = []; + await runAgentLoop(baseDeps(captured), "conv-no-org", turn); + const system = captured[0].system; + expect(system[system.length - 1].cache_control).toEqual({ type: "ephemeral" }); + expect(system.some((b) => b.text.includes("## Selected organization"))).toBe(false); + }); + + it("drops an org that fails sanitization instead of injecting it", async () => { + const captured: Array<{ system: SystemBlock[] }> = []; + await runAgentLoop( + { ...baseDeps(captured), org: { id: "bad id", name: "Acme" } }, + "conv-bad-org", + turn, + ); + const system = captured[0].system; + expect(system.some((b) => b.text.includes("## Selected organization"))).toBe(false); + expect(system[system.length - 1].cache_control).toEqual({ type: "ephemeral" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/agentLoop.org.test.ts` +Expected: FAIL — TypeScript rejects the unknown `org` dep / the first assertion finds no org text. + +- [ ] **Step 3: Implement** + +In `src/agentLoop.ts`: + +1. Add to the imports from `./systemPrompt.js`: `orgSystemSection` and `type OrgSelection` (the file already imports `SYSTEM_PROMPT` from there). +2. Add to `AgentLoopDeps` (after `extraSystem`): + +```typescript + /** Org the user selected in the app; injected as a system section. */ + org?: OrgSelection; +``` + +3. Replace the system-assembly block (currently ends with `system[system.length - 1].cache_control = { type: "ephemeral" };`) with: + +```typescript + const system: TextBlockParam[] = [{ type: "text", text: SYSTEM_PROMPT }]; + if (deps.extraSystem) { + system.push({ + type: "text", + text: `## remote.it query cookbook (published by the MCP server)\n\n${deps.extraSystem}`, + }); + } + system[system.length - 1].cache_control = { type: "ephemeral" }; + // The org block rides after the cache breakpoint: it is tiny and changes + // when the user switches orgs, so it must not invalidate the cached prefix. + const orgSection = deps.org && orgSystemSection(deps.org); + if (orgSection) system.push({ type: "text", text: orgSection }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/agentLoop.org.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Run the full suite and typecheck** + +Run: `npx vitest run && npm run typecheck` +Expected: all existing tests still PASS; tsc clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/agentLoop.ts test/agentLoop.org.test.ts +git commit -m "feat(org): inject selected-org section into the agent system prompt" +``` + +--- + +### Task 3: Wire protocol — request validation and threading (ai-agent) + +**Files:** +- Modify: `src/server.ts` (`parseChatRequest` ~line 24–33, `/api/chat` route ~line 136) +- Modify: `src/chatService.ts` (`runChatTurn` signature ~line 48, `runAgentLoop` deps ~line 83–99) +- Test: `test/server.org.test.ts` (new) + +**Interfaces:** +- Consumes: `OrgSelection` from Task 1, `AgentLoopDeps.org` from Task 2. +- Produces: + - `parseChatRequest` is now exported; returns `{ conversationId: string; messages: MessageParam[]; org?: OrgSelection } | { error: string }`. + - `runChatTurn(services, ctx, conversationId, messages, emit, signal?, org?)` — new trailing optional `org?: OrgSelection`. The GraphQL transport (`src/graphql/schema.ts`) is deliberately NOT changed; it simply never passes `org`. + - Task 4's request body: `{ conversationId, messages, org? }`. + +- [ ] **Step 1: Write the failing test** + +Create `test/server.org.test.ts` (boot helper copied from `test/server.auth.test.ts`; env mode so no bearer is needed — malformed bodies are rejected before any turn machinery runs): + +```typescript +import { afterAll, describe, expect, it } from "vitest"; +import type { AddressInfo } from "node:net"; +import type http from "node:http"; +import { createServer, parseChatRequest } from "../src/server.js"; +import type { ChatServices } from "../src/chatService.js"; + +function makeServices(): ChatServices { + return { + config: { toolClassificationOverrides: {} } as ChatServices["config"], + anthropic: {} as ChatServices["anthropic"], + audit: { log: () => {}, withUser: () => ({ log: () => {} }) } as unknown as ChatServices["audit"], + tokenProvider: { getMcpAuth: async () => ({ token: "t", sub: "s", email: "e" }) } as unknown as ChatServices["tokenProvider"], + connectMcp: async () => ({ + listTools: async () => [], + callTool: async () => ({ text: "", isError: false }), + close: async () => {}, + }), + }; +} + +const servers: http.Server[] = []; + +async function boot(): Promise { + const { httpServer } = createServer(makeServices(), { mode: "env" }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + servers.push(httpServer); + const { port } = httpServer.address() as AddressInfo; + return `http://127.0.0.1:${port}`; +} + +afterAll(async () => { + await Promise.all(servers.map((s) => new Promise((resolve) => s.close(resolve)))); +}); + +const validTurn = { conversationId: "c1", messages: [{ role: "user", content: "hi" }] }; + +describe("POST /api/chat org validation", () => { + it.each([ + ["non-object org", { ...validTurn, org: "acme" }], + ["missing name", { ...validTurn, org: { id: "org-1" } }], + ["empty id", { ...validTurn, org: { id: "", name: "Acme" } }], + ["whitespace name", { ...validTurn, org: { id: "org-1", name: " " } }], + ["non-string id", { ...validTurn, org: { id: 42, name: "Acme" } }], + ])("400s on %s", async (_label, body) => { + const base = await boot(); + const res = await fetch(`${base}/api/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(400); + }); +}); + +describe("parseChatRequest org passthrough", () => { + it("accepts a valid org", () => { + const parsed = parseChatRequest({ ...validTurn, org: { id: "org-1", name: "Acme" } }); + expect(parsed).toMatchObject({ conversationId: "c1", org: { id: "org-1", name: "Acme" } }); + }); + + it("accepts an omitted org", () => { + const parsed = parseChatRequest(validTurn); + expect("error" in parsed).toBe(false); + expect((parsed as { org?: unknown }).org).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/server.org.test.ts` +Expected: FAIL — `parseChatRequest` is not exported; malformed-org bodies currently return 200 (SSE), not 400. + +- [ ] **Step 3: Implement** + +In `src/server.ts`: + +1. Import the type: `import type { OrgSelection } from "./systemPrompt.js";` +2. Replace `parseChatRequest` with (note the added `export`): + +```typescript +/** Validate the /api/chat body, returning a typed turn or an error message. */ +export function parseChatRequest( + body: unknown, +): { conversationId: string; messages: MessageParam[]; org?: OrgSelection } | { error: string } { + const b = body as { conversationId?: unknown; messages?: unknown; org?: unknown }; + if (typeof b.conversationId !== "string" || !Array.isArray(b.messages) || b.messages.length === 0) { + return { error: "Body must be { conversationId: string, messages: MessageParam[] }" }; + } + let org: OrgSelection | undefined; + if (b.org !== undefined) { + const o = b.org as { id?: unknown; name?: unknown }; + if ( + typeof b.org !== "object" || + b.org === null || + typeof o.id !== "string" || + !o.id.trim() || + typeof o.name !== "string" || + !o.name.trim() + ) { + return { error: "org must be { id: string, name: string } with non-empty values" }; + } + org = { id: o.id, name: o.name }; + } + return { conversationId: b.conversationId, messages: b.messages as MessageParam[], org }; +} +``` + +3. Pass it through in the `/api/chat` route: + +```typescript + await runChatTurn(services, restContext(req), parsed.conversationId, parsed.messages, emit, controller.signal, parsed.org); +``` + +In `src/chatService.ts`: + +1. Import the type: `import type { OrgSelection } from "./systemPrompt.js";` +2. Add the trailing parameter to `runChatTurn`: + +```typescript +export async function runChatTurn( + services: ChatServices, + ctx: TokenRequestContext, + conversationId: string, + messages: MessageParam[], + emit: AgentEmitter, + signal?: AbortSignal, + org?: OrgSelection, +): Promise { +``` + +3. Inside the tools branch, after `const turnAudit = audit.withUser({ sub, email });`, add the audit entry: + +```typescript + if (org) turnAudit.log({ event: "org_scope", conversationId, detail: org.id }); +``` + +4. Add `org` to the `runAgentLoop` deps object (next to `extraSystem`): + +```typescript + extraSystem: cookbook ?? undefined, + org, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/server.org.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Run the full suite and typecheck** + +Run: `npx vitest run && npm run typecheck` +Expected: all PASS; tsc clean (the unchanged GraphQL call site is fine — `org` is optional). + +- [ ] **Step 6: Commit** + +```bash +git add src/server.ts src/chatService.ts test/server.org.test.ts +git commit -m "feat(org): accept and thread the selected org through /api/chat" +``` + +--- + +### Task 4: Frontend client — send `org` on the wire (desktop) + +**Files:** +- Modify: `frontend/src/services/agent.ts` (types ~line 58–67, `streamChat` ~line 69–81) + +**Interfaces:** +- Consumes: the Task 3 body shape `{ conversationId, messages, org? }`. +- Produces: `export type OrgSelection = { id: string; name: string }` and `streamChat` options gain `org?: OrgSelection`. Task 5 imports `OrgSelection` from `../services/agent`. + +- [ ] **Step 1: Implement** + +In `frontend/src/services/agent.ts`, add the type next to `AgentMessageParam`: + +```typescript +export type OrgSelection = { id: string; name: string } +``` + +Extend `streamChat`'s options and body (only the changed lines shown): + +```typescript +export async function streamChat(options: { + conversationId: string + messages: AgentMessageParam[] + org?: OrgSelection + signal?: AbortSignal + onEvent: (event: AgentEvent) => void +}): Promise { + const { conversationId, messages, org, signal, onEvent } = options + const response = await fetch(`${AGENT_URL}/api/chat`, { + method: 'POST', + headers: agentHeaders(), + body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }), + signal, + }) +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean (no errors introduced; callers pass `org` as optional). + +- [ ] **Step 3: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/services/agent.ts +git commit -m "feat(chat): optional org field on the agent chat request" +``` + +--- + +### Task 5: Chat model — org state, defaulting, and send integration (desktop) + +**Files:** +- Modify: `frontend/src/models/chat.ts` (state ~line 27–47, `send` effect ~line 110–134, new `syncOrg` effect) + +**Interfaces:** +- Consumes: `OrgSelection` from Task 4; app state `state.user.id`, `state.accounts.activeId`, `state.accounts.membership` (items have `account.id`), `state.organization.accounts` (lookup by account id, has `.name`) — the same sources `frontend/src/components/OrganizationSelect.tsx` uses. +- Produces: `IChatState.orgId: string | null`; effect `dispatch.chat.syncOrg()` (Task 6 calls it when the panel opens); `dispatch.chat.set({ orgId })` (Task 6's dropdown calls it). + +- [ ] **Step 1: Implement state** + +In `frontend/src/models/chat.ts`: + +1. Add `OrgSelection` to the imports from `'../services/agent'`. +2. Add to `IChatState` (after `conversationId`): + +```typescript + /** Org the agent is scoped to; null = uninitialized, user id = personal */ + orgId: string | null +``` + +3. Add to `defaultChatState`: `orgId: null,` + +- [ ] **Step 2: Implement `syncOrg`** + +Add to `effects` (after `send`). Runs when the panel opens: adopt the app's active org unless the current selection is still valid, so the chat org defaults to what the user is looking at but can diverge afterward: + +```typescript + /* Default the chat org to the app's active org when unset or no longer valid */ + async syncOrg(_: void, state) { + const userId = state.user.id + const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)]) + if (!state.chat.orgId || !validIds.has(state.chat.orgId)) { + dispatch.chat.set({ orgId: state.accounts.activeId || userId }) + } + }, +``` + +- [ ] **Step 3: Implement send integration** + +In the `send` effect, before the `streamChat` call, resolve the selection (personal account → `undefined`, per spec decision 4; a selection whose org data is missing → `undefined` rather than a value the server would 400 on): + +```typescript + const orgId = state.chat.orgId + let org: OrgSelection | undefined + if (orgId && orgId !== state.user.id) { + const name = state.organization.accounts[orgId]?.name + const isMember = state.accounts.membership.some(m => m.account.id === orgId) + if (name && isMember) org = { id: orgId, name } + } +``` + +and pass it through: + +```typescript + await streamChat({ + conversationId, + messages, + org, + signal: abortController.signal, + onEvent: event => dispatch.chat.applyEvent(event), + }) +``` + +- [ ] **Step 4: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/models/chat.ts +git commit -m "feat(chat): org selection state scoped to the chat panel" +``` + +--- + +### Task 6: Dropdown UI + panel wiring + manual verification (desktop) + +**Files:** +- Create: `frontend/src/components/Chat/ChatOrgSelect.tsx` +- Modify: `frontend/src/components/Chat/ChatPanel.tsx` (open effect ~line 23–28, header ~line 53–66) + +**Interfaces:** +- Consumes: `state.chat.orgId`, `dispatch.chat.set({ orgId })`, `dispatch.chat.syncOrg()` from Task 5; membership/org-name state as in Task 5. +- Produces: ``, rendered directly below the chat header. + +- [ ] **Step 1: Create the component** + +Create `frontend/src/components/Chat/ChatOrgSelect.tsx`. Mirrors `OrganizationSelect.tsx`'s data sourcing: memberships joined to `organization.accounts` for names, orgs whose data hasn't loaded are skipped, sorted by name; hidden entirely when the user has no orgs. + +```tsx +import React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Box, TextField, MenuItem } from '@mui/material' +import { State, Dispatch } from '../../store' + +/* Org the agent is scoped to — defaults to the app's active org (models/chat + syncOrg) but diverges freely; a change applies from the next turn */ +export const ChatOrgSelect: React.FC = () => { + const dispatch = useDispatch() + const orgId = useSelector((state: State) => state.chat.orgId) + const userId = useSelector((state: State) => state.user.id) + const memberships = useSelector((state: State) => state.accounts.membership) + const organizations = useSelector((state: State) => state.organization.accounts) + + const options = memberships + .map(m => ({ id: m.account.id, name: organizations[m.account.id]?.name || '' })) + .filter(o => o.name) + .sort((a, b) => a.name.localeCompare(b.name)) + + if (!options.length) return null + + return ( + + dispatch.chat.set({ orgId: event.target.value })} + > + Personal + {options.map(o => ( + + {o.name} + + ))} + + + ) +} +``` + +- [ ] **Step 2: Wire into the panel** + +In `frontend/src/components/Chat/ChatPanel.tsx`: + +1. Import: `import { ChatOrgSelect } from './ChatOrgSelect'` +2. Add `dispatch.chat.syncOrg()` to the open effect: + +```typescript + useEffect(() => { + if (chat.open) { + dispatch.chat.resetTransient() + dispatch.chat.syncOrg() + dispatch.chat.checkHealth() + } + }, [chat.open]) +``` + +3. Render `` immediately after the header `` (the one closing at line 66), before the health notices. + +- [ ] **Step 3: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +- [ ] **Step 4: Manual verification** + +1. Start the agent service: `cd /Users/larrygunteriv/github/remoteit/ai-agent && npm run dev` (port 3001). +2. Start the frontend: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm start` (port 3003), sign in, open the chat panel. +3. Verify the dropdown sits below the header and defaults to the org the app sidebar has active (or Personal). +4. With devtools → Network open, send a message with an org selected: the `/api/chat` request body contains `"org":{"id":...,"name":...}`. +5. Select Personal, send again: the body has no `org` key. +6. Switch org mid-conversation and send: transcript is kept; the new org appears in the next request body. +7. Ask the agent to "list this organization's devices": it should use the accountId directly (tool call input shows the selected org id) without a `whoami`/membership lookup first. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/components/Chat/ChatOrgSelect.tsx frontend/src/components/Chat/ChatPanel.tsx +git commit -m "feat(chat): org selector dropdown in the chat panel" +``` From c70ee3af36963135aafea2a3bc87ca39ff796f6b Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 14:52:37 -0400 Subject: [PATCH 008/238] feat(chat): optional org field on the agent chat request --- frontend/src/services/agent.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index bad2572f0..5724fc1d9 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -65,18 +65,21 @@ export type AgentEvent = export type AgentMessageParam = { role: 'user' | 'assistant'; content: string } +export type OrgSelection = { id: string; name: string } + /* Stream one chat turn. Events arrive as SSE: `event: \ndata: \n\n` */ export async function streamChat(options: { conversationId: string messages: AgentMessageParam[] + org?: OrgSelection signal?: AbortSignal onEvent: (event: AgentEvent) => void }): Promise { - const { conversationId, messages, signal, onEvent } = options + const { conversationId, messages, org, signal, onEvent } = options const response = await fetch(`${AGENT_URL}/api/chat`, { method: 'POST', headers: agentHeaders(), - body: JSON.stringify({ conversationId, messages }), + body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }), signal, }) if (response.status === 401) throw new AgentAuthError() From 723e41e99180102de908be3f7282fb2f4b204ed8 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 15:00:19 -0400 Subject: [PATCH 009/238] feat(chat): org selection state scoped to the chat panel --- frontend/src/models/chat.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index a37a16178..ae04b4e3e 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -9,6 +9,7 @@ import { AgentEvent, AgentHealth, AgentMessageParam, + OrgSelection, } from '../services/agent' import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra' @@ -29,6 +30,8 @@ export type IChatState = { expanded: boolean messages: ChatTranscriptMessage[] conversationId: string + /** Org the agent is scoped to; null = uninitialized, user id = personal */ + orgId: string | null streaming: boolean pendingConfirmation: { toolUseId: string; toolName: string; input: Record } | null error: string | null @@ -40,6 +43,7 @@ export const defaultChatState: IChatState = { expanded: false, messages: [], conversationId: '', + orgId: null, streaming: false, pendingConfirmation: null, error: null, @@ -114,11 +118,19 @@ export default createModel()({ dispatch.chat.addUserMessage(text) dispatch.chat.set({ conversationId, streaming: true, error: null }) abortController = new AbortController() + const orgId = state.chat.orgId + let org: OrgSelection | undefined + if (orgId && orgId !== state.user.id) { + const name = state.organization.accounts[orgId]?.name + const isMember = state.accounts.membership.some(m => m.account.id === orgId) + if (name && isMember) org = { id: orgId, name } + } try { await ensureFreshAgentToken() await streamChat({ conversationId, messages, + org, signal: abortController.signal, onEvent: event => dispatch.chat.applyEvent(event), }) @@ -132,6 +144,14 @@ export default createModel()({ dispatch.chat.set({ streaming: false }) } }, + /* Default the chat org to the app's active org when unset or no longer valid */ + async syncOrg(_: void, state) { + const userId = state.user.id + const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)]) + if (!state.chat.orgId || !validIds.has(state.chat.orgId)) { + dispatch.chat.set({ orgId: state.accounts.activeId || userId }) + } + }, async confirm(approved: boolean, state) { const pending = state.chat.pendingConfirmation if (!pending) return From c692c24efaacc543c01be8dc9edc8340db7c4b74 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 15:03:14 -0400 Subject: [PATCH 010/238] feat(chat): org selector dropdown in the chat panel --- .../src/components/Chat/ChatOrgSelect.tsx | 41 +++++++++++++++++++ frontend/src/components/Chat/ChatPanel.tsx | 3 ++ 2 files changed, 44 insertions(+) create mode 100644 frontend/src/components/Chat/ChatOrgSelect.tsx diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx new file mode 100644 index 000000000..863761bbc --- /dev/null +++ b/frontend/src/components/Chat/ChatOrgSelect.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Box, TextField, MenuItem } from '@mui/material' +import { State, Dispatch } from '../../store' + +/* Org the agent is scoped to — defaults to the app's active org (models/chat + syncOrg) but diverges freely; a change applies from the next turn */ +export const ChatOrgSelect: React.FC = () => { + const dispatch = useDispatch() + const orgId = useSelector((state: State) => state.chat.orgId) + const userId = useSelector((state: State) => state.user.id) + const memberships = useSelector((state: State) => state.accounts.membership) + const organizations = useSelector((state: State) => state.organization.accounts) + + const options = memberships + .map(m => ({ id: m.account.id, name: organizations[m.account.id]?.name || '' })) + .filter(o => o.name) + .sort((a, b) => a.name.localeCompare(b.name)) + + if (!options.length) return null + + return ( + + dispatch.chat.set({ orgId: event.target.value })} + > + Personal + {options.map(o => ( + + {o.name} + + ))} + + + ) +} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 47f998598..caf8061aa 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -7,6 +7,7 @@ import { IconButton } from '../../buttons/IconButton' import { ChatMessages } from './ChatMessages' import { ChatApproval } from './ChatApproval' import { ChatInput } from './ChatInput' +import { ChatOrgSelect } from './ChatOrgSelect' import { Notice } from '../Notice' export const ChatPanel: React.FC = () => { @@ -23,6 +24,7 @@ export const ChatPanel: React.FC = () => { useEffect(() => { if (chat.open) { dispatch.chat.resetTransient() + dispatch.chat.syncOrg() dispatch.chat.checkHealth() } }, [chat.open]) @@ -64,6 +66,7 @@ export const ChatPanel: React.FC = () => { dispatch.chat.clearConversation()} /> dispatch.chat.set({ open: false })} /> + {chat.health === 'unreachable' && ( Agent unreachable — is the dev service running on :3001? From 63dccb7a22b043ff22420f7ce65ef14ddc451154 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 15:23:43 -0400 Subject: [PATCH 011/238] =?UTF-8?q?fix(chat):=20final=20review=20=E2=80=94?= =?UTF-8?q?=20membership=20name=20fallback,=20no=20orgId=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fall back to membership.name in ChatOrgSelect and chat.ts send() when organization.accounts is unloaded, so org scope never silently degrades to personal; guard against a whitespace-only name so the client never sends one the server's non-empty-after-trim validation would reject. Force-adopt the app's active org on the first syncOrg after load so orgId (redux-persisted) doesn't survive page reloads, per the "not persisted" spec decision, while preserving intra-session divergence across panel close/open. --- .../src/components/Chat/ChatOrgSelect.tsx | 2 +- frontend/src/models/chat.ts | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx index 863761bbc..6152d2b56 100644 --- a/frontend/src/components/Chat/ChatOrgSelect.tsx +++ b/frontend/src/components/Chat/ChatOrgSelect.tsx @@ -13,7 +13,7 @@ export const ChatOrgSelect: React.FC = () => { const organizations = useSelector((state: State) => state.organization.accounts) const options = memberships - .map(m => ({ id: m.account.id, name: organizations[m.account.id]?.name || '' })) + .map(m => ({ id: m.account.id, name: (organizations[m.account.id]?.name || m.name || '').trim() })) .filter(o => o.name) .sort((a, b) => a.name.localeCompare(b.name)) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index ae04b4e3e..dde57d85c 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -107,6 +107,11 @@ function toMessageParams(messages: ChatTranscriptMessage[]): AgentMessageParam[] } let abortController: AbortController | null = null +// orgId is redux-persisted but must not survive a reload (spec: "not +// persisted"); on the first syncOrg after load, force-adopt the app's active +// org regardless of what was rehydrated. After that, intra-session +// divergence (the user picking a different org in the panel) is left alone. +let orgSynced = false export default createModel()({ state: { ...defaultChatState }, @@ -121,7 +126,12 @@ export default createModel()({ const orgId = state.chat.orgId let org: OrgSelection | undefined if (orgId && orgId !== state.user.id) { - const name = state.organization.accounts[orgId]?.name + // organization.accounts can be unloaded while the membership is + // present; fall back to the name carried on the membership itself so + // org scope never silently degrades to personal. + const name = ( + state.organization.accounts[orgId]?.name || state.accounts.membership.find(m => m.account.id === orgId)?.name || '' + ).trim() const isMember = state.accounts.membership.some(m => m.account.id === orgId) if (name && isMember) org = { id: orgId, name } } @@ -144,9 +154,16 @@ export default createModel()({ dispatch.chat.set({ streaming: false }) } }, - /* Default the chat org to the app's active org when unset or no longer valid */ + /* Default the chat org to the app's active org when unset or no longer valid. + orgId is not persisted across reloads: the first sync after load always + adopts the app's active org, discarding whatever was rehydrated. */ async syncOrg(_: void, state) { const userId = state.user.id + if (!orgSynced) { + orgSynced = true + dispatch.chat.set({ orgId: state.accounts.activeId || userId }) + return + } const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)]) if (!state.chat.orgId || !validIds.has(state.chat.orgId)) { dispatch.chat.set({ orgId: state.accounts.activeId || userId }) From 5f77b2a16801c6a3900cc3ce01fae9eb8be6eb49 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 15:59:42 -0400 Subject: [PATCH 012/238] style(chat): taller org selector and themed scrollbars Co-Authored-By: Claude Fable 5 --- .../src/components/Chat/ChatMessageItem.tsx | 100 +++++++++--------- frontend/src/components/Chat/ChatMessages.tsx | 3 +- .../src/components/Chat/ChatOrgSelect.tsx | 1 + frontend/src/components/Chat/chatScrollbar.ts | 16 +++ 4 files changed, 70 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/Chat/chatScrollbar.ts diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 02a510244..acc1ae787 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -4,14 +4,13 @@ import remarkGfm from 'remark-gfm' import { Box, Typography } from '@mui/material' import { ChatTranscriptMessage } from '../../models/chat' import { ChatToolCalls } from './ChatToolCalls' +import { scrollbarStyles } from './chatScrollbar' export const ChatMessageItem: React.FC<{ message: ChatTranscriptMessage }> = ({ message }) => { if (message.role === 'user') return ( - + {message.text} @@ -23,53 +22,56 @@ export const ChatMessageItem: React.FC<{ message: ChatTranscriptMessage }> = ({ ({ '& pre, & table': scrollbarStyles(theme) }), + { + fontSize: 14, + lineHeight: 1.5, + wordBreak: 'break-word', + '& p': { marginY: 0.75 }, + '& ul, & ol': { paddingLeft: 3, marginY: 0.5 }, + '& li': { marginY: 0.25 }, + '& h1, & h2, & h3, & h4': { fontSize: 15, marginTop: 1.5, marginBottom: 0.5 }, + '& a': { color: 'primary.main' }, + '& code': { + fontFamily: "'Roboto Mono', monospace", + fontSize: 12, + bgcolor: 'grayLightest.main', + borderRadius: 1, + paddingX: 0.5, + paddingY: 0.25, + }, + '& pre': { + overflowX: 'auto', + bgcolor: 'grayLightest.main', + borderRadius: 2, + padding: 1.5, + '& code': { padding: 0, bgcolor: 'transparent' }, + }, + '& table': { + display: 'block', + overflowX: 'auto', + borderCollapse: 'collapse', + fontSize: 12, + marginY: 1, + }, + '& th, & td': { + border: '1px solid', + borderColor: 'grayLighter.main', + paddingX: 1, + paddingY: 0.5, + textAlign: 'left', + whiteSpace: 'nowrap', + }, + '& blockquote': { + borderLeft: '3px solid', + borderColor: 'grayLighter.main', + marginX: 0, + paddingLeft: 1.5, + color: 'grayDark.main', + }, }, - '& pre': { - overflowX: 'auto', - bgcolor: 'grayLightest.main', - borderRadius: 2, - padding: 1.5, - '& code': { padding: 0, bgcolor: 'transparent' }, - }, - '& table': { - display: 'block', - overflowX: 'auto', - borderCollapse: 'collapse', - fontSize: 12, - marginY: 1, - }, - '& th, & td': { - border: '1px solid', - borderColor: 'grayLighter.main', - paddingX: 1, - paddingY: 0.5, - textAlign: 'left', - whiteSpace: 'nowrap', - }, - '& blockquote': { - borderLeft: '3px solid', - borderColor: 'grayLighter.main', - marginX: 0, - paddingLeft: 1.5, - color: 'grayDark.main', - }, - }} + ]} > {message.text} diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index adb48fce5..33feaef1f 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import { Box } from '@mui/material' import { ChatTranscriptMessage } from '../../models/chat' import { ChatMessageItem } from './ChatMessageItem' +import { scrollbarStyles } from './chatScrollbar' type Props = { messages: ChatTranscriptMessage[] @@ -25,7 +26,7 @@ export const ChatMessages: React.FC = ({ messages, streaming, children }) const el = ref.current if (el) setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 40) }} - sx={{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }} + sx={[{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }, scrollbarStyles]} > {messages.map((message, index) => ( diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx index 6152d2b56..0181a8dd1 100644 --- a/frontend/src/components/Chat/ChatOrgSelect.tsx +++ b/frontend/src/components/Chat/ChatOrgSelect.tsx @@ -28,6 +28,7 @@ export const ChatOrgSelect: React.FC = () => { label="Organization" value={orgId || userId} onChange={event => dispatch.chat.set({ orgId: event.target.value })} + sx={{ '& .MuiSelect-select': { paddingY: '12px' } }} > Personal {options.map(o => ( diff --git a/frontend/src/components/Chat/chatScrollbar.ts b/frontend/src/components/Chat/chatScrollbar.ts new file mode 100644 index 000000000..dec8fd065 --- /dev/null +++ b/frontend/src/components/Chat/chatScrollbar.ts @@ -0,0 +1,16 @@ +import { Theme } from '@mui/material/styles' + +/* Slim, theme-matched scrollbars for the chat panel's scroll surfaces, + replacing the default browser bars */ +export const scrollbarStyles = (theme: Theme) => ({ + scrollbarWidth: 'thin' as const, // Firefox + scrollbarColor: `${theme.palette.grayLight.main} transparent`, // Firefox + '&::-webkit-scrollbar': { width: 8, height: 8, WebkitAppearance: 'none' as const }, + '&::-webkit-scrollbar-track': { background: 'transparent' }, + '&::-webkit-scrollbar-thumb': { + borderRadius: 4, + backgroundColor: theme.palette.grayLight.main, + '&:hover': { backgroundColor: theme.palette.gray.main }, + }, + '&::-webkit-scrollbar-corner': { background: 'transparent' }, +}) From 0326821c3e8e3a699e0629144664e31a833e94ed Mon Sep 17 00:00:00 2001 From: thelg4 Date: Mon, 27 Jul 2026 17:06:04 -0400 Subject: [PATCH 013/238] chore(chat): bind agent sign-in to the beta MCP audience Matches the ai-agent service's move off mcp.evan.remote.it (older build without org-scoped list_scripts). The client cache key includes the audience, so a fresh sign-in re-registers automatically. Co-Authored-By: Claude Fable 5 --- frontend/src/services/hydra.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts index 14f539dbe..4fae19cc2 100644 --- a/frontend/src/services/hydra.ts +++ b/frontend/src/services/hydra.ts @@ -17,7 +17,7 @@ import { getAgentSession, getAgentToken, setAgentSession, setAgentToken } from './agent' export const HYDRA_ISSUER = import.meta.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it' -export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.evan.remote.it/mcp' +export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.beta.remote.it/mcp' const SCOPE = 'openid offline email device:read device:write device:connect device:execute' const LIFESPAN = '30m' // access-token TTL override, verified accepted via DCR From 5167a16962f256530f84cf878fd164f3cdd36b9e Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 11:14:55 -0400 Subject: [PATCH 014/238] docs(chat): design spec for chat popout window Co-Authored-By: Claude Fable 5 --- .../2026-07-28-chat-popout-window-design.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-chat-popout-window-design.md diff --git a/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md b/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md new file mode 100644 index 000000000..bd4de0899 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md @@ -0,0 +1,126 @@ +# Chat Panel Popout Window — Design + +**Date:** 2026-07-28 +**Repo:** `remoteit/desktop` +**Branch:** `feature/agent-chat-interface` + +## Purpose + +Let the user pop the AI chat panel out of the app into its own window (per +mockup: pop-out button in the docked chat header; standalone chat window +with a pop-in button), and bring it back with the conversation intact. + +## Decisions (from brainstorming) + +1. **Move semantics** — popping out hides the docked panel; the standalone + window owns the conversation. Popping back in (button or window close) + returns it, transcript intact. No live mirroring between windows. +2. **Environments: browser + Electron.** Mobile never shows the button. + The whole feature remains behind the existing `MODE === 'development'` + gate, matching the chat panel itself. +3. **Mechanism: boot flag + BroadcastChannel.** The popout loads the same + app bundle with a `?chatPopout` boot flag; a BroadcastChannel performs + the conversation hand-off. No second Vite entry; no reliance on + redux-persist write ordering (both windows persist to the same + localStorage key, so storage alone is racy). + +## Architecture + +### Boot flag and rendering + +- The `chatPopout` query param is captured at module scope on boot (same + pattern as the hydra `?code` capture in `services/hydra.ts`), so hash + routing cannot clobber it. +- `App.tsx`: when the flag is set (and `MODE === 'development'`), render a + bare `` in place of the app shell (sidebar/router). All + pre-auth gates (loading, sign-in) behave as today; in practice the + popout is already authenticated because Amplify and agent tokens live in + shared localStorage. +- Component split: the chat internals (health notices, org select, + messages, approval, input) are extracted from `ChatPanel` into a shared + piece. `ChatPanel` (docked column) and `ChatWindow` (full-page popout) + both render it: + - `ChatPanel` header: expand, new chat, **pop out** (new), close. + - `ChatWindow` header: new chat, **pop in**. The window's own chrome + provides close. No expand button, no panel-close button. + - `ChatWindow` ignores `chat.open` (it always shows). + +### Popout service — `frontend/src/services/chatPopout.ts` + +Owns `window.open`, the BroadcastChannel (`remoteit-chat-popout`), and the +hand-off protocol. Message types: + +| Message | Direction | Payload | Effect | +|---|---|---|---| +| `hello` | popout → main | — | main replies `adopt`, then sets `open: false, poppedOut: true` | +| `adopt` | main → popout | `{ messages, conversationId, orgId }` | popout replaces its chat slice with the payload | +| `handback` | popout → main | `{ messages, conversationId, orgId }` | main applies payload, sets `poppedOut: false, open: true` | +| `ping` | main → popout | — | presence check on main boot | +| `alive` | popout → main | — | main keeps dock hidden (`poppedOut: true`) | +| `signout` | main → popout | — | popout closes itself WITHOUT sending `handback` (sign-out clears the transcript anyway) | + +- `openChatPopout()`: `window.open(origin + '/?chatPopout', 'remoteit-chat', + 'popup,width=520,height=780')`. +- Popout boot: send `hello`; if no `adopt` arrives within 300 ms, fall + back to the redux-persisted transcript (covers popout refresh / main + gone). A late `adopt` after the fallback is still applied — main's copy + is authoritative at hand-off. +- Pop-in or `beforeunload`: abort any active stream first (same path as + the Stop button; the open message is marked interrupted), then send + `handback`, then close. +- Crash resilience: while `poppedOut`, main polls `popoutWindow.closed` + (~2s). Closed without a `handback` → restore `open: true` from the + persisted transcript. Poll and `handback` are idempotent together. +- Main boot: `ping`; only an `alive` reply keeps `poppedOut: true` + (corrects stale persisted state). +- App sign-out (`chat.signOut`): broadcast `signout` before clearing. + +### Model — `frontend/src/models/chat.ts` + +- `poppedOut: boolean` added to `IChatState` (default false; value is + authoritative only after the boot ping settles). +- Effects for the protocol reactions (adopt/handback application) so all + state changes stay in the model; the service holds no state of its own + beyond the channel and window handle. + +### Electron — `electron/src/ElectronApp.ts` + +`setWindowOpenHandler` gains one branch: a URL on the app's own origin +carrying the `chatPopout` flag returns + +``` +{ action: 'allow', overrideBrowserWindowOptions: + { width: 520, height: 780, minWidth: 360, minHeight: 500, autoHideMenuBar: true } } +``` + +All other URLs keep the existing deny + `shell.openExternal` behavior. +BroadcastChannel works across the two windows unchanged (same origin and +session partition). + +## Edge handling + +- **Main window closes/reloads while popped out** — popout keeps working + (own store, shared tokens). Next main boot pings; `alive` keeps the dock + hidden. +- **Mid-stream pop-in/close** — stream aborted, message marked + interrupted, transcript preserved in the `handback`. +- **Popout opened twice** — the named window (`'remoteit-chat'`) is + reused by `window.open`, so a second click focuses the existing popout. +- **Mobile / non-dev builds** — button absent (`MODE` gate + no button on + mobile via `browser.isMobile`). + +## Verification + +Typecheck (`cd frontend && npm run typecheck`) plus a manual script: + +1. Pop out → docked panel hides, window opens with transcript and org + selection intact. +2. Converse in the popout (org scoping still applies), pop in → dock + returns with the full transcript. +3. Close the popout with the window X → same as pop-in. +4. Kill the popout process / crash it → dock restores within ~2s. +5. Reload the main window while popped out → dock stays hidden; popout + unaffected. +6. Sign out of the app → popout closes. +7. Electron dev build: pop out opens a native window with the specified + size; external links still open in the system browser. From 1d44888ce2ba60c5a074a0199218804dd8e2f458 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 11:21:19 -0400 Subject: [PATCH 015/238] docs(chat): implementation plan for chat popout window Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-28-chat-popout-window.md | 665 ++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-28-chat-popout-window.md diff --git a/docs/superpowers/plans/2026-07-28-chat-popout-window.md b/docs/superpowers/plans/2026-07-28-chat-popout-window.md new file mode 100644 index 000000000..4238e85e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-chat-popout-window.md @@ -0,0 +1,665 @@ +# Chat Popout Window Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Pop the chat panel out into its own window (browser + Electron) with move semantics — the docked panel hides while popped out and the conversation hands back intact. + +**Architecture:** The popout loads the same app bundle with a `?chatPopout` boot flag; `App.tsx` renders a bare `ChatWindow` instead of the app shell. A BroadcastChannel (`remoteit-chat-popout`) carries the hand-off protocol (hello/adopt/handback/ping/alive/signout) with dependency-injected handlers so the service never imports the store (no circular imports). Electron's `setWindowOpenHandler` gains an allow-branch for the app's own popout URL. + +**Tech Stack:** React + rematch + MUI, BroadcastChannel API, Electron BrowserWindow options. + +**Spec:** `docs/superpowers/specs/2026-07-28-chat-popout-window-design.md` + +## Global Constraints + +- Repo `/Users/larrygunteriv/github/remoteit/desktop`, branch `feature/agent-chat-interface`. NEVER commit to or push main. +- The repo has UNRELATED uncommitted changes (`.npmrc`, `frontend/package.json`, `frontend/src/components/Icon.tsx`) — never touch or stage them. +- Everything stays behind the existing `MODE === 'development'` gate; mobile (`browser.isMobile`) never shows the pop-out button. +- Move semantics: popping out hides the docked panel; hand-off payloads travel IN the BroadcastChannel messages, never via storage ordering (persistence is localForage/IndexedDB and both windows write the same key). +- Channel name `remoteit-chat-popout`; window name `remoteit-chat`; window features `popup=yes,width=520,height=780`; Electron override `{ width: 520, height: 780, minWidth: 360, minHeight: 500, autoHideMenuBar: true }`. +- Frontend verification is `cd frontend && npm run typecheck` (no unit-test infra). Electron verification is `cd electron && npm run typecheck`. +- Run `npx prettier --write ` (from `frontend/`) before each frontend commit. + +--- + +### Task 1: Extract `ChatBody` from `ChatPanel` + +**Files:** +- Create: `frontend/src/components/Chat/ChatBody.tsx` +- Modify: `frontend/src/components/Chat/ChatPanel.tsx` + +**Interfaces:** +- Consumes: existing `ChatOrgSelect`, `ChatMessages`, `ChatApproval`, `ChatInput`, `Notice` components; `state.chat` slice. +- Produces: `export const ChatBody: React.FC` (no props) — the org select, health notices, message list w/ approval + error, and input. Tasks 2–3 render it from `ChatWindow` and `ChatPanel`. + +- [ ] **Step 1: Create ChatBody** + +Create `frontend/src/components/Chat/ChatBody.tsx` — this is a pure move of ChatPanel's content below the header (currently `ChatPanel.tsx:69-110`): + +```tsx +import React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Button } from '@mui/material' +import { State, Dispatch } from '../../store' +import { ChatMessages } from './ChatMessages' +import { ChatApproval } from './ChatApproval' +import { ChatInput } from './ChatInput' +import { ChatOrgSelect } from './ChatOrgSelect' +import { Notice } from '../Notice' + +/* Everything below the chat header — shared by the docked panel and the + popout window */ +export const ChatBody: React.FC = () => { + const chat = useSelector((state: State) => state.chat) + const dispatch = useDispatch() + + return ( + <> + + {chat.health === 'unreachable' && ( + + Agent unreachable — is the dev service running on :3001? + + )} + {chat.health === 'unauthorized' && ( + + <> + The AI agent needs its own sign-in to act on your behalf. + + + + )} + + {chat.pendingConfirmation && ( + dispatch.chat.confirm(approved)} + /> + )} + {chat.error && ( + dispatch.chat.set({ error: null })}> + {chat.error} + + )} + + dispatch.chat.send(text)} + onStop={() => dispatch.chat.stop()} + /> + + ) +} +``` + +- [ ] **Step 2: Use it in ChatPanel** + +In `frontend/src/components/Chat/ChatPanel.tsx`, replace everything after the header `` (the ``, both `Notice` blocks, `…`, and `` — currently lines 69–110) with: + +```tsx + +``` + +and update imports: add `import { ChatBody } from './ChatBody'`; remove the now-unused imports `Button` (keep `Box`, `Typography` from @mui/material), `ChatMessages`, `ChatApproval`, `ChatInput`, `ChatOrgSelect`, and `Notice`. + +- [ ] **Step 3: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean — this is a pure extraction. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/components/Chat/ChatBody.tsx src/components/Chat/ChatPanel.tsx +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/components/Chat/ChatBody.tsx frontend/src/components/Chat/ChatPanel.tsx +git commit -m "refactor(chat): extract ChatBody shared by panel and popout" +``` + +--- + +### Task 2: Boot flag, popout service skeleton, and `ChatWindow` + +**Files:** +- Create: `frontend/src/services/chatPopout.ts` +- Create: `frontend/src/components/Chat/ChatWindow.tsx` +- Modify: `frontend/src/components/App.tsx` + +**Interfaces:** +- Consumes: `ChatBody` from Task 1; `MODE` from `../constants`; `store` (components only, never the service). +- Produces (Task 3 relies on these exact names): + - `chatPopout.ts`: `isChatPopout: boolean`, `CHAT_POPOUT_FLAG = 'chatPopout'`, `type ChatHandoff = { messages: ChatTranscriptMessage[]; conversationId: string; orgId: string | null }`. + - `ChatWindow: React.FC` — full-page chat for the popout. + +- [ ] **Step 1: Create the service with the boot flag** + +Create `frontend/src/services/chatPopout.ts`: + +```ts +// import type only: the chat model value-imports this service (signout +// broadcast), so a value import here would create a runtime cycle +import type { ChatTranscriptMessage } from '../models/chat' + +/** + * Chat popout: the panel moves into its own window (same bundle, boot flag) + * and the conversation hands off over a BroadcastChannel. This module owns + * the flag, the channel, and the protocol; it never imports the store — + * callers inject handlers (avoids store/model import cycles). + */ +export const CHAT_POPOUT_FLAG = 'chatPopout' + +// Captured at module-evaluation time, before any routing can touch the URL +// (same pattern as the hydra ?code capture in services/hydra.ts) +export const isChatPopout = new URLSearchParams(window.location.search).has(CHAT_POPOUT_FLAG) + +export type ChatHandoff = { + messages: ChatTranscriptMessage[] + conversationId: string + orgId: string | null +} +``` + +- [ ] **Step 2: Create ChatWindow** + +Create `frontend/src/components/Chat/ChatWindow.tsx` (protocol wiring comes in Task 3 — this step renders a working standalone chat): + +```tsx +import React, { useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { Box, Typography } from '@mui/material' +import { Dispatch } from '../../store' +import { IconButton } from '../../buttons/IconButton' +import { ChatBody } from './ChatBody' + +/* Full-page chat for the popped-out window (?chatPopout boot flag). The + window chrome provides close; pop-in wiring lands with the protocol. */ +export const ChatWindow: React.FC = () => { + const dispatch = useDispatch() + + useEffect(() => { + document.title = 'remote.it chat' + dispatch.chat.resetTransient() + dispatch.chat.syncOrg() + dispatch.chat.checkHealth() + }, []) + + return ( + + + + New Chat + + dispatch.chat.clearConversation()} /> + + + + ) +} +``` + +- [ ] **Step 3: Branch in App.tsx** + +In `frontend/src/components/App.tsx`: + +1. Add imports: + +```tsx +import { ChatWindow } from './Chat/ChatWindow' +import { isChatPopout } from '../services/chatPopout' +``` + +2. Replace the final `return` block's PersistGate content (currently the layout `` + `{showBottomMenu && }`) so the popout renders only the chat: + +```tsx + return ( + + + }> + {MODE === 'development' && isChatPopout ? ( + + ) : ( + <> + + {hideSidebar ? : } + + {MODE === 'development' && } + + {showBottomMenu && } + + )} + + + ) +``` + +All pre-auth gates above the final return stay untouched (sign-in still works in the popout if needed). + +- [ ] **Step 4: Typecheck and verify render** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +If the vite dev server is running, open `http://localhost:3003/?chatPopout` in a browser tab — the bare chat should render (transcript rehydrates from persistence), no sidebar/router. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/services/chatPopout.ts src/components/Chat/ChatWindow.tsx src/components/App.tsx +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/services/chatPopout.ts frontend/src/components/Chat/ChatWindow.tsx frontend/src/components/App.tsx +git commit -m "feat(chat): standalone chat window behind ?chatPopout boot flag" +``` + +--- + +### Task 3: Hand-off protocol, pop-out/pop-in buttons, crash resilience + +**Files:** +- Modify: `frontend/src/services/chatPopout.ts` +- Modify: `frontend/src/models/chat.ts` +- Modify: `frontend/src/components/Chat/ChatPanel.tsx` +- Modify: `frontend/src/components/Chat/ChatWindow.tsx` + +**Interfaces:** +- Consumes: Task 2's `ChatHandoff`, `CHAT_POPOUT_FLAG`, `isChatPopout`; chat model reducers `set`, `adoptTranscript` (new). +- Produces: + - Service: `openChatPopout(): boolean`, `initChatPopoutMain(handlers: PopoutMainHandlers): void`, `checkPopoutPresence(handlers: PopoutMainHandlers): void`, `initChatPopoutWindow(handlers: PopoutWindowHandlers): void`, `popIn(payload: ChatHandoff): void`, `broadcastChatSignout(): void`. + - Model: `IChatState.poppedOut: boolean`; reducer `adoptTranscript(state, payload: ChatHandoff)`. + +- [ ] **Step 1: Model additions** + +In `frontend/src/models/chat.ts`: + +1. Add to `IChatState` (after `orgId`) and to `defaultChatState` (`poppedOut: false`): + +```ts + /** Conversation currently lives in the popout window (main window only) */ + poppedOut: boolean +``` + +2. Add the import at the top: `import { ChatHandoff, broadcastChatSignout } from '../services/chatPopout'` + +3. Add reducer (next to `clearConversation`): + +```ts + /* Hand-off: replace the conversation with the other window's copy */ + adoptTranscript(state: IChatState, payload: ChatHandoff) { + state.messages = payload.messages + state.conversationId = payload.conversationId + state.orgId = payload.orgId + return state + }, +``` + +4. In the `signOut` effect, broadcast to the popout FIRST (it closes without a handback; sign-out clears the transcript anyway): + +```ts + async signOut() { + broadcastChatSignout() + abortController?.abort() + abortController = null + dispatch.chat.reset() + await agentSignOut() + }, +``` + +Note: `services/chatPopout.ts` must not import the store or any model — the import direction is model → service only. + +- [ ] **Step 2: Protocol implementation in the service** + +Append to `frontend/src/services/chatPopout.ts`: + +```ts +type PopoutMessage = + | { type: 'hello' } + | { type: 'adopt'; payload: ChatHandoff } + | { type: 'handback'; payload: ChatHandoff } + | { type: 'ping' } + | { type: 'alive' } + | { type: 'signout' } + +export type PopoutMainHandlers = { + getHandoff: () => ChatHandoff + /** handback arrived: apply the transcript and reopen the dock */ + adopt: (payload: ChatHandoff) => void + /** popout said hello: hide the dock */ + onPopoutOpened: () => void + /** popout vanished without a handback: reopen the dock as-is */ + onPopoutLost: () => void + /** boot reconciliation: does a popout exist right now? */ + onPresence: (present: boolean) => void +} + +export type PopoutWindowHandlers = { + adopt: (payload: ChatHandoff) => void + getHandoff: () => ChatHandoff + onSignout: () => void +} + +const CHANNEL = 'remoteit-chat-popout' +const WINDOW_NAME = 'remoteit-chat' +const WINDOW_FEATURES = 'popup=yes,width=520,height=780' +const POLL_INTERVAL = 2000 +const PRESENCE_TIMEOUT = 500 + +const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL) : null +const post = (message: PopoutMessage) => channel?.postMessage(message) + +let popoutWindow: Window | null = null +let pollTimer: number | undefined +let alivePending = false +let suppressHandback = false + +/* ---------- main-window side ---------- */ + +export function openChatPopout(): boolean { + const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_FLAG}`, WINDOW_NAME, WINDOW_FEATURES) + if (!opened) return false // popup blocked — dock stays; hello never arrives + popoutWindow = opened + return true +} + +export function initChatPopoutMain(handlers: PopoutMainHandlers): void { + if (!channel) return + channel.addEventListener('message', (event: MessageEvent) => { + switch (event.data.type) { + case 'hello': + post({ type: 'adopt', payload: handlers.getHandoff() }) + handlers.onPopoutOpened() + startPolling(handlers) + break + case 'handback': + stopPolling() + handlers.adopt(event.data.payload) + break + case 'alive': + alivePending = false + break + } + }) +} + +/* Ask whether a popout survives from a previous page load; corrects a stale + persisted poppedOut flag either way */ +export function checkPopoutPresence(handlers: PopoutMainHandlers): void { + if (!channel) { + handlers.onPresence(false) + return + } + alivePending = true + post({ type: 'ping' }) + window.setTimeout(() => { + if (alivePending) { + handlers.onPresence(false) + } else { + handlers.onPresence(true) + startPolling(handlers) + } + }, PRESENCE_TIMEOUT) +} + +export function broadcastChatSignout(): void { + post({ type: 'signout' }) +} + +/* Crash net: a popout that dies without beforeunload still restores the + dock. Uses the window handle when we have one (same page load), pings + otherwise (main was reloaded while popped out). */ +function startPolling(handlers: PopoutMainHandlers) { + if (pollTimer) return + pollTimer = window.setInterval(() => { + if (popoutWindow) { + if (popoutWindow.closed) lost(handlers) + return + } + alivePending = true + post({ type: 'ping' }) + window.setTimeout(() => { + if (alivePending && pollTimer) lost(handlers) + }, PRESENCE_TIMEOUT) + }, POLL_INTERVAL) +} + +function stopPolling() { + if (pollTimer) window.clearInterval(pollTimer) + pollTimer = undefined + popoutWindow = null +} + +function lost(handlers: PopoutMainHandlers) { + stopPolling() + handlers.onPopoutLost() +} + +/* ---------- popout-window side ---------- */ + +export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void { + if (!channel) return + channel.addEventListener('message', (event: MessageEvent) => { + switch (event.data.type) { + case 'adopt': + // Main's copy is authoritative at hand-off; until it arrives the + // window shows its own rehydrated (persisted) transcript + handlers.adopt(event.data.payload) + break + case 'ping': + post({ type: 'alive' }) + break + case 'signout': + suppressHandback = true // sign-out clears the transcript; nothing to hand back + handlers.onSignout() + break + } + }) + window.addEventListener('beforeunload', () => { + if (!suppressHandback) post({ type: 'handback', payload: handlers.getHandoff() }) + }) + post({ type: 'hello' }) +} + +export function popIn(payload: ChatHandoff): void { + post({ type: 'handback', payload }) + suppressHandback = true // beforeunload would duplicate it (harmless but noisy) + window.close() +} +``` + +- [ ] **Step 3: Wire the main window (ChatPanel)** + +In `frontend/src/components/Chat/ChatPanel.tsx`: + +1. Imports: add `browser` service, popout service, and store: + +```tsx +import browser from '../../services/browser' +import { store, State, Dispatch } from '../../store' +import { openChatPopout, initChatPopoutMain, checkPopoutPresence, PopoutMainHandlers, ChatHandoff } from '../../services/chatPopout' +``` + +2. Above the component, the handoff snapshot helper: + +```tsx +const currentHandoff = (): ChatHandoff => { + const c = store.getState().chat + return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId } +} +``` + +3. Inside the component, replace the existing mount effect (the one calling `handleSignInCallback`) with one that also wires the protocol: + +```tsx + // Completes a Hydra sign-in redirect if this page load carries ?code — + // runs on mount regardless of whether the panel is open + useEffect(() => { + dispatch.chat.handleSignInCallback() + const handlers: PopoutMainHandlers = { + getHandoff: currentHandoff, + adopt: payload => { + dispatch.chat.adoptTranscript(payload) + dispatch.chat.set({ poppedOut: false, open: true }) + }, + onPopoutOpened: () => dispatch.chat.set({ open: false, poppedOut: true }), + onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }), + onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }), + } + initChatPopoutMain(handlers) + checkPopoutPresence(handlers) + }, []) +``` + +4. Add the pop-out button to the header, before the New Chat button (browser/Electron only — never mobile): + +```tsx + {!browser.isMobile && ( + openChatPopout()} /> + )} +``` + +The dock hides when the popout's `hello` arrives — a blocked popup therefore changes nothing. + +- [ ] **Step 4: Wire the popout window (ChatWindow)** + +In `frontend/src/components/Chat/ChatWindow.tsx`: + +1. Imports: add `import { store } from '../../store'` (extend the existing store import) and `import { initChatPopoutWindow, popIn, ChatHandoff } from '../../services/chatPopout'`. + +2. Above the component: + +```tsx +const currentHandoff = (): ChatHandoff => { + const c = store.getState().chat + return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId } +} +``` + +3. In the mount effect, after `checkHealth()`: + +```tsx + initChatPopoutWindow({ + adopt: payload => dispatch.chat.adoptTranscript(payload), + getHandoff: currentHandoff, + onSignout: () => window.close(), + }) +``` + +4. Add the pop-in button after the New Chat button (stop any stream first — the open message is marked interrupted by the existing stop path): + +```tsx + { + await dispatch.chat.stop() + popIn(currentHandoff()) + }} + /> +``` + +- [ ] **Step 5: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +- [ ] **Step 6: Manual verification (browser)** + +With vite (`:3003`) and the ai-agent service (`:3001`) running, signed in, chat open with some transcript: + +1. Click **Pop out** → window opens with the transcript and org selection; docked panel hides. +2. Send a message in the popout (org scoping still applies), click **Pop back in** → window closes, dock returns with the full conversation. +3. Pop out again, close the popout with the window's X → dock returns with the conversation. +4. Pop out, then reload the MAIN window → dock stays hidden (presence ping); popout unaffected. +5. Kill the popout without unload (e.g. from a task manager, or fake it: DevTools on the popout → `window.stop()` won't do it — acceptable to skip if awkward; the `window.closed` poll path is exercised by step 3 when beforeunload is raced). +6. Sign out of the app in the main window → popout closes. + +Record what you verified in your report; note any step you could not perform. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/services/chatPopout.ts src/models/chat.ts src/components/Chat/ChatPanel.tsx src/components/Chat/ChatWindow.tsx +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/services/chatPopout.ts frontend/src/models/chat.ts frontend/src/components/Chat/ChatPanel.tsx frontend/src/components/Chat/ChatWindow.tsx +git commit -m "feat(chat): pop the chat out to its own window with transcript hand-off" +``` + +--- + +### Task 4: Electron window-open allow-branch + +**Files:** +- Modify: `electron/src/ElectronApp.ts:248-252` (the `setWindowOpenHandler` block) + +**Interfaces:** +- Consumes: the popout URL shape from Task 2 (`/?chatPopout`); `this.getStartUrl()` (`ElectronApp.ts:339`). +- Produces: popout opens as a native BrowserWindow in Electron; all other URLs keep opening externally. + +- [ ] **Step 1: Implement the branch** + +Replace the current handler (`ElectronApp.ts:248-252`): + +```ts + this.window.webContents.setWindowOpenHandler(({ url }) => { + // The dev chat panel pops out into its own window (?chatPopout on our + // own origin); every other window.open goes to the system browser. + try { + const parsed = new URL(url) + if (parsed.origin === new URL(this.getStartUrl()).origin && parsed.searchParams.has('chatPopout')) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + width: 520, + height: 780, + minWidth: 360, + minHeight: 500, + autoHideMenuBar: true, + }, + } + } + } catch {} + Logger.info('OPEN EXTERNAL URL', { url }) + electron.shell.openExternal(url) + return { action: 'deny' } + }) +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/electron && npm run typecheck` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add electron/src/ElectronApp.ts +git commit -m "feat(electron): open the chat popout as a native window" +``` From 99d2ae0af4c8604cc22743d39854ffe20b175689 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 11:23:35 -0400 Subject: [PATCH 016/238] refactor(chat): extract ChatBody shared by panel and popout --- frontend/src/components/Chat/ChatBody.tsx | 63 ++++++++++++++++++++++ frontend/src/components/Chat/ChatPanel.tsx | 51 ++---------------- 2 files changed, 66 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/Chat/ChatBody.tsx diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx new file mode 100644 index 000000000..fa06caa52 --- /dev/null +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Button } from '@mui/material' +import { State, Dispatch } from '../../store' +import { ChatMessages } from './ChatMessages' +import { ChatApproval } from './ChatApproval' +import { ChatInput } from './ChatInput' +import { ChatOrgSelect } from './ChatOrgSelect' +import { Notice } from '../Notice' + +/* Everything below the chat header — shared by the docked panel and the + popout window */ +export const ChatBody: React.FC = () => { + const chat = useSelector((state: State) => state.chat) + const dispatch = useDispatch() + + return ( + <> + + {chat.health === 'unreachable' && ( + + Agent unreachable — is the dev service running on :3001? + + )} + {chat.health === 'unauthorized' && ( + + <> + The AI agent needs its own sign-in to act on your behalf. + + + + )} + + {chat.pendingConfirmation && ( + dispatch.chat.confirm(approved)} + /> + )} + {chat.error && ( + dispatch.chat.set({ error: null })}> + {chat.error} + + )} + + dispatch.chat.send(text)} + onStop={() => dispatch.chat.stop()} + /> + + ) +} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index caf8061aa..9cf764fb9 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -1,14 +1,10 @@ import React, { useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' -import { Box, Button, Typography } from '@mui/material' +import { Box, Typography } from '@mui/material' import { State, Dispatch } from '../../store' import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants' import { IconButton } from '../../buttons/IconButton' -import { ChatMessages } from './ChatMessages' -import { ChatApproval } from './ChatApproval' -import { ChatInput } from './ChatInput' -import { ChatOrgSelect } from './ChatOrgSelect' -import { Notice } from '../Notice' +import { ChatBody } from './ChatBody' export const ChatPanel: React.FC = () => { const chat = useSelector((state: State) => state.chat) @@ -66,48 +62,7 @@ export const ChatPanel: React.FC = () => { dispatch.chat.clearConversation()} /> dispatch.chat.set({ open: false })} /> - - {chat.health === 'unreachable' && ( - - Agent unreachable — is the dev service running on :3001? - - )} - {chat.health === 'unauthorized' && ( - - <> - The AI agent needs its own sign-in to act on your behalf. - - - - )} - - {chat.pendingConfirmation && ( - dispatch.chat.confirm(approved)} - /> - )} - {chat.error && ( - dispatch.chat.set({ error: null })}> - {chat.error} - - )} - - dispatch.chat.send(text)} - onStop={() => dispatch.chat.stop()} - /> + ) } From 8a93e028dfe2a3a8ab33d122d869f3ae7f49910a Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 11:26:56 -0400 Subject: [PATCH 017/238] feat(chat): standalone chat window behind ?chatPopout boot flag --- frontend/src/components/App.tsx | 40 ++++++++++++--------- frontend/src/components/Chat/ChatWindow.tsx | 40 +++++++++++++++++++++ frontend/src/services/chatPopout.ts | 21 +++++++++++ 3 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/Chat/ChatWindow.tsx create mode 100644 frontend/src/services/chatPopout.ts diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 74220ca2f..52f1cab1a 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -31,12 +31,14 @@ import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' import { ChatPanel } from './Chat/ChatPanel' +import { ChatWindow } from './Chat/ChatWindow' import { Router } from '../routers/Router' import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' import { ViewAsBanner } from './ViewAsBanner' import { AnnouncementDialog } from './AnnouncementDialog' import { AnnouncementBanner } from './AnnouncementBanner' +import { isChatPopout } from '../services/chatPopout' export const App: React.FC = () => { // Subscribe the whole app to i18next language changes and lazy-locale loads, so @@ -133,22 +135,28 @@ export const App: React.FC = () => { }> - - {hideSidebar ? : } - - {MODE === 'development' && } - - {showBottomMenu && } + {MODE === 'development' && isChatPopout ? ( + + ) : ( + <> + + {hideSidebar ? : } + + {MODE === 'development' && } + + {showBottomMenu && } + + )} diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx new file mode 100644 index 000000000..19ad8a846 --- /dev/null +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -0,0 +1,40 @@ +import React, { useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { Box, Typography } from '@mui/material' +import { Dispatch } from '../../store' +import { IconButton } from '../../buttons/IconButton' +import { ChatBody } from './ChatBody' + +/* Full-page chat for the popped-out window (?chatPopout boot flag). The + window chrome provides close; pop-in wiring lands with the protocol. */ +export const ChatWindow: React.FC = () => { + const dispatch = useDispatch() + + useEffect(() => { + document.title = 'remote.it chat' + dispatch.chat.resetTransient() + dispatch.chat.syncOrg() + dispatch.chat.checkHealth() + }, []) + + return ( + + + + New Chat + + dispatch.chat.clearConversation()} /> + + + + ) +} diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts new file mode 100644 index 000000000..e951830d6 --- /dev/null +++ b/frontend/src/services/chatPopout.ts @@ -0,0 +1,21 @@ +// import type only: the chat model value-imports this service (signout +// broadcast), so a value import here would create a runtime cycle +import type { ChatTranscriptMessage } from '../models/chat' + +/** + * Chat popout: the panel moves into its own window (same bundle, boot flag) + * and the conversation hands off over a BroadcastChannel. This module owns + * the flag, the channel, and the protocol; it never imports the store — + * callers inject handlers (avoids store/model import cycles). + */ +export const CHAT_POPOUT_FLAG = 'chatPopout' + +// Captured at module-evaluation time, before any routing can touch the URL +// (same pattern as the hydra ?code capture in services/hydra.ts) +export const isChatPopout = new URLSearchParams(window.location.search).has(CHAT_POPOUT_FLAG) + +export type ChatHandoff = { + messages: ChatTranscriptMessage[] + conversationId: string + orgId: string | null +} From 62027bcffc2c69ee921a3955aa6d895cee28598e Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 11:32:12 -0400 Subject: [PATCH 018/238] feat(chat): pop the chat out to its own window with transcript hand-off --- frontend/src/components/Chat/ChatPanel.tsx | 30 +++- frontend/src/components/Chat/ChatWindow.tsx | 21 ++- frontend/src/models/chat.ts | 16 +- frontend/src/services/chatPopout.ts | 153 ++++++++++++++++++++ 4 files changed, 217 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 9cf764fb9..65279b7c0 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -1,10 +1,23 @@ import React, { useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' import { Box, Typography } from '@mui/material' -import { State, Dispatch } from '../../store' +import { store, State, Dispatch } from '../../store' import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants' import { IconButton } from '../../buttons/IconButton' import { ChatBody } from './ChatBody' +import browser from '../../services/browser' +import { + openChatPopout, + initChatPopoutMain, + checkPopoutPresence, + PopoutMainHandlers, + ChatHandoff, +} from '../../services/chatPopout' + +const currentHandoff = (): ChatHandoff => { + const c = store.getState().chat + return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId } +} export const ChatPanel: React.FC = () => { const chat = useSelector((state: State) => state.chat) @@ -15,6 +28,18 @@ export const ChatPanel: React.FC = () => { // runs on mount regardless of whether the panel is open useEffect(() => { dispatch.chat.handleSignInCallback() + const handlers: PopoutMainHandlers = { + getHandoff: currentHandoff, + adopt: payload => { + dispatch.chat.adoptTranscript(payload) + dispatch.chat.set({ poppedOut: false, open: true }) + }, + onPopoutOpened: () => dispatch.chat.set({ open: false, poppedOut: true }), + onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }), + onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }), + } + initChatPopoutMain(handlers) + checkPopoutPresence(handlers) }, []) useEffect(() => { @@ -59,6 +84,9 @@ export const ChatPanel: React.FC = () => { onClick={() => dispatch.chat.set({ expanded: !chat.expanded })} /> )} + {!browser.isMobile && ( + openChatPopout()} /> + )} dispatch.chat.clearConversation()} /> dispatch.chat.set({ open: false })} /> diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 19ad8a846..944fae773 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -1,9 +1,15 @@ import React, { useEffect } from 'react' import { useDispatch } from 'react-redux' import { Box, Typography } from '@mui/material' -import { Dispatch } from '../../store' +import { store, Dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { ChatBody } from './ChatBody' +import { initChatPopoutWindow, popIn, ChatHandoff } from '../../services/chatPopout' + +const currentHandoff = (): ChatHandoff => { + const c = store.getState().chat + return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId } +} /* Full-page chat for the popped-out window (?chatPopout boot flag). The window chrome provides close; pop-in wiring lands with the protocol. */ @@ -15,6 +21,11 @@ export const ChatWindow: React.FC = () => { dispatch.chat.resetTransient() dispatch.chat.syncOrg() dispatch.chat.checkHealth() + initChatPopoutWindow({ + adopt: payload => dispatch.chat.adoptTranscript(payload), + getHandoff: currentHandoff, + onSignout: () => window.close(), + }) }, []) return ( @@ -33,6 +44,14 @@ export const ChatWindow: React.FC = () => { New Chat dispatch.chat.clearConversation()} /> + { + await dispatch.chat.stop() + popIn(currentHandoff()) + }} + /> diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index dde57d85c..8ceab542c 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -12,6 +12,7 @@ import { OrgSelection, } from '../services/agent' import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra' +import { ChatHandoff, broadcastChatSignout } from '../services/chatPopout' export type ChatToolCall = { id: string @@ -32,6 +33,8 @@ export type IChatState = { conversationId: string /** Org the agent is scoped to; null = uninitialized, user id = personal */ orgId: string | null + /** Conversation currently lives in the popout window (main window only) */ + poppedOut: boolean streaming: boolean pendingConfirmation: { toolUseId: string; toolName: string; input: Record } | null error: string | null @@ -44,6 +47,7 @@ export const defaultChatState: IChatState = { messages: [], conversationId: '', orgId: null, + poppedOut: false, streaming: false, pendingConfirmation: null, error: null, @@ -130,7 +134,9 @@ export default createModel()({ // present; fall back to the name carried on the membership itself so // org scope never silently degrades to personal. const name = ( - state.organization.accounts[orgId]?.name || state.accounts.membership.find(m => m.account.id === orgId)?.name || '' + state.organization.accounts[orgId]?.name || + state.accounts.membership.find(m => m.account.id === orgId)?.name || + '' ).trim() const isMember = state.accounts.membership.some(m => m.account.id === orgId) if (name && isMember) org = { id: orgId, name } @@ -222,6 +228,7 @@ export default createModel()({ /* App sign-out tears the agent session down with it: revoke + clear the Hydra credentials and drop the transcript */ async signOut() { + broadcastChatSignout() abortController?.abort() abortController = null dispatch.chat.reset() @@ -248,6 +255,13 @@ export default createModel()({ state.health = 'unknown' return state }, + /* Hand-off: replace the conversation with the other window's copy */ + adoptTranscript(state: IChatState, payload: ChatHandoff) { + state.messages = payload.messages + state.conversationId = payload.conversationId + state.orgId = payload.orgId + return state + }, clearConversation(state: IChatState) { state.messages = [] state.conversationId = '' diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index e951830d6..8f2c1a452 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -19,3 +19,156 @@ export type ChatHandoff = { conversationId: string orgId: string | null } + +type PopoutMessage = + | { type: 'hello' } + | { type: 'adopt'; payload: ChatHandoff } + | { type: 'handback'; payload: ChatHandoff } + | { type: 'ping' } + | { type: 'alive' } + | { type: 'signout' } + +export type PopoutMainHandlers = { + getHandoff: () => ChatHandoff + /** handback arrived: apply the transcript and reopen the dock */ + adopt: (payload: ChatHandoff) => void + /** popout said hello: hide the dock */ + onPopoutOpened: () => void + /** popout vanished without a handback: reopen the dock as-is */ + onPopoutLost: () => void + /** boot reconciliation: does a popout exist right now? */ + onPresence: (present: boolean) => void +} + +export type PopoutWindowHandlers = { + adopt: (payload: ChatHandoff) => void + getHandoff: () => ChatHandoff + onSignout: () => void +} + +const CHANNEL = 'remoteit-chat-popout' +const WINDOW_NAME = 'remoteit-chat' +const WINDOW_FEATURES = 'popup=yes,width=520,height=780' +const POLL_INTERVAL = 2000 +const PRESENCE_TIMEOUT = 500 + +const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL) : null +const post = (message: PopoutMessage) => channel?.postMessage(message) + +let popoutWindow: Window | null = null +let pollTimer: number | undefined +let alivePending = false +let suppressHandback = false + +/* ---------- main-window side ---------- */ + +export function openChatPopout(): boolean { + const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_FLAG}`, WINDOW_NAME, WINDOW_FEATURES) + if (!opened) return false // popup blocked — dock stays; hello never arrives + popoutWindow = opened + return true +} + +export function initChatPopoutMain(handlers: PopoutMainHandlers): void { + if (!channel) return + channel.addEventListener('message', (event: MessageEvent) => { + switch (event.data.type) { + case 'hello': + post({ type: 'adopt', payload: handlers.getHandoff() }) + handlers.onPopoutOpened() + startPolling(handlers) + break + case 'handback': + stopPolling() + handlers.adopt(event.data.payload) + break + case 'alive': + alivePending = false + break + } + }) +} + +/* Ask whether a popout survives from a previous page load; corrects a stale + persisted poppedOut flag either way */ +export function checkPopoutPresence(handlers: PopoutMainHandlers): void { + if (!channel) { + handlers.onPresence(false) + return + } + alivePending = true + post({ type: 'ping' }) + window.setTimeout(() => { + if (alivePending) { + handlers.onPresence(false) + } else { + handlers.onPresence(true) + startPolling(handlers) + } + }, PRESENCE_TIMEOUT) +} + +export function broadcastChatSignout(): void { + post({ type: 'signout' }) +} + +/* Crash net: a popout that dies without beforeunload still restores the + dock. Uses the window handle when we have one (same page load), pings + otherwise (main was reloaded while popped out). */ +function startPolling(handlers: PopoutMainHandlers) { + if (pollTimer) return + pollTimer = window.setInterval(() => { + if (popoutWindow) { + if (popoutWindow.closed) lost(handlers) + return + } + alivePending = true + post({ type: 'ping' }) + window.setTimeout(() => { + if (alivePending && pollTimer) lost(handlers) + }, PRESENCE_TIMEOUT) + }, POLL_INTERVAL) +} + +function stopPolling() { + if (pollTimer) window.clearInterval(pollTimer) + pollTimer = undefined + popoutWindow = null +} + +function lost(handlers: PopoutMainHandlers) { + stopPolling() + handlers.onPopoutLost() +} + +/* ---------- popout-window side ---------- */ + +export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void { + if (!channel) return + channel.addEventListener('message', (event: MessageEvent) => { + switch (event.data.type) { + case 'adopt': + // Main's copy is authoritative at hand-off; until it arrives the + // window shows its own rehydrated (persisted) transcript + handlers.adopt(event.data.payload) + break + case 'ping': + post({ type: 'alive' }) + break + case 'signout': + suppressHandback = true // sign-out clears the transcript; nothing to hand back + handlers.onSignout() + break + } + }) + window.addEventListener('beforeunload', () => { + if (!suppressHandback) post({ type: 'handback', payload: handlers.getHandoff() }) + }) + post({ type: 'hello' }) +} + +export function popIn(payload: ChatHandoff): void { + post({ type: 'handback', payload }) + suppressHandback = true // beforeunload would duplicate it (harmless but noisy) + window.close() +} From 7909fcc5f0ebc1871d6cd17864da1bc2fa932499 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 11:37:39 -0400 Subject: [PATCH 019/238] feat(electron): open the chat popout as a native window --- electron/src/ElectronApp.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index f23b110e4..a153cf3d9 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -248,6 +248,23 @@ export default class ElectronApp { }) this.window.webContents.setWindowOpenHandler(({ url }) => { + // The dev chat panel pops out into its own window (?chatPopout on our + // own origin); every other window.open goes to the system browser. + try { + const parsed = new URL(url) + if (parsed.origin === new URL(this.getStartUrl()).origin && parsed.searchParams.has('chatPopout')) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + width: 520, + height: 780, + minWidth: 360, + minHeight: 500, + autoHideMenuBar: true, + }, + } + } + } catch {} Logger.info('OPEN EXTERNAL URL', { url }) electron.shell.openExternal(url) return { action: 'deny' } From 202506a723c49d208fb1f29b0d01ffce394fba0c Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 11:51:19 -0400 Subject: [PATCH 020/238] =?UTF-8?q?fix(chat):=20final=20review=20=E2=80=94?= =?UTF-8?q?=20popout=20stream,=20sign-in,=20and=20lifecycle=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Chat/ChatBody.tsx | 23 ++++++----- frontend/src/components/Chat/ChatPanel.tsx | 8 +++- frontend/src/components/Chat/ChatWindow.tsx | 3 +- frontend/src/components/Header/Header.tsx | 13 +++++- frontend/src/models/chat.ts | 9 +++-- frontend/src/services/chatPopout.ts | 44 +++++++++++++++------ 6 files changed, 71 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index fa06caa52..c976d1ae6 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -7,6 +7,7 @@ import { ChatApproval } from './ChatApproval' import { ChatInput } from './ChatInput' import { ChatOrgSelect } from './ChatOrgSelect' import { Notice } from '../Notice' +import { isChatPopout } from '../../services/chatPopout' /* Everything below the chat header — shared by the docked panel and the popout window */ @@ -26,15 +27,19 @@ export const ChatBody: React.FC = () => { <> The AI agent needs its own sign-in to act on your behalf. - + {isChatPopout ? ( + ' Sign in from the main app window.' + ) : ( + + )} )} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 65279b7c0..2e48acdce 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -34,12 +34,16 @@ export const ChatPanel: React.FC = () => { dispatch.chat.adoptTranscript(payload) dispatch.chat.set({ poppedOut: false, open: true }) }, - onPopoutOpened: () => dispatch.chat.set({ open: false, poppedOut: true }), + onPopoutOpened: () => { + dispatch.chat.stop() + dispatch.chat.set({ open: false, poppedOut: true }) + }, onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }), onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }), } - initChatPopoutMain(handlers) + const unsubscribe = initChatPopoutMain(handlers) checkPopoutPresence(handlers) + return unsubscribe }, []) useEffect(() => { diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 944fae773..46f89e0a6 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -21,11 +21,12 @@ export const ChatWindow: React.FC = () => { dispatch.chat.resetTransient() dispatch.chat.syncOrg() dispatch.chat.checkHealth() - initChatPopoutWindow({ + const unsubscribe = initChatPopoutWindow({ adopt: payload => dispatch.chat.adoptTranscript(payload), getHandoff: currentHandoff, onSignout: () => window.close(), }) + return unsubscribe }, []) return ( diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 3362dc64a..f9de4d42e 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -30,6 +30,7 @@ export const Header: React.FC = ({ panels = 1 }) => { const { searched } = useSelector(selectDeviceModelAttributes) const permissions = useSelector(selectPermissions) const chatOpen = useSelector((state: State) => state.chat.open) + const chatPoppedOut = useSelector((state: State) => state.chat.poppedOut) const layout = useSelector((state: State) => state.ui.layout) const overlapHeader = layout.hideSidebar && browser.isElectron && browser.isMac @@ -45,7 +46,15 @@ export const Header: React.FC = ({ panels = 1 }) => { const menu = location.pathname.match(REGEX_FIRST_PATH)?.[0] // Admin pages have two-level roots: /admin/users and /admin/partners (without IDs) - const adminRootPages = ['/admin/users', '/admin/admins', '/admin/partners', '/admin/enterprise-licenses', '/admin/devices', '/admin/notices', '/partner-stats'] + const adminRootPages = [ + '/admin/users', + '/admin/admins', + '/admin/partners', + '/admin/enterprise-licenses', + '/admin/devices', + '/admin/notices', + '/partner-stats', + ] const isAdminRootPage = adminRootPages.includes(location.pathname) const isRootMenu = menu === location.pathname || isAdminRootPage @@ -81,7 +90,7 @@ export const Header: React.FC = ({ panels = 1 }) => { color="grayDarker" /> )} - {MODE === 'development' && ( + {MODE === 'development' && !chatPoppedOut && ( ()({ } }, /* Complete a sign-in redirect if this page load carries one */ - async handleSignInCallback() { + async handleSignInCallback(_: void, state) { const result = await handleAgentSignInCallback() if (!result) return - if (result.ok) dispatch.chat.set({ error: null, open: true }) - else dispatch.chat.set({ error: `Agent sign-in failed — ${result.error}`, open: true }) + // Don't yank the dock open if the conversation currently lives in the + // popout window — the popout is the active surface, not the panel. + const openIfDocked = state.chat.poppedOut ? {} : { open: true } + if (result.ok) dispatch.chat.set({ error: null, ...openIfDocked }) + else dispatch.chat.set({ error: `Agent sign-in failed — ${result.error}`, ...openIfDocked }) await dispatch.chat.checkHealth() }, // Dev fallback: token pasted from the ai-agent harness (devtools: diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index 8f2c1a452..1f9e933c1 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -58,6 +58,7 @@ const post = (message: PopoutMessage) => channel?.postMessage(message) let popoutWindow: Window | null = null let pollTimer: number | undefined let alivePending = false +let missedPings = 0 let suppressHandback = false /* ---------- main-window side ---------- */ @@ -69,9 +70,9 @@ export function openChatPopout(): boolean { return true } -export function initChatPopoutMain(handlers: PopoutMainHandlers): void { - if (!channel) return - channel.addEventListener('message', (event: MessageEvent) => { +export function initChatPopoutMain(handlers: PopoutMainHandlers): () => void { + if (!channel) return () => {} + const listener = (event: MessageEvent) => { switch (event.data.type) { case 'hello': post({ type: 'adopt', payload: handlers.getHandoff() }) @@ -84,9 +85,12 @@ export function initChatPopoutMain(handlers: PopoutMainHandlers): void { break case 'alive': alivePending = false + missedPings = 0 break } - }) + } + channel.addEventListener('message', listener) + return () => channel.removeEventListener('message', listener) } /* Ask whether a popout survives from a previous page load; corrects a stale @@ -110,13 +114,19 @@ export function checkPopoutPresence(handlers: PopoutMainHandlers): void { export function broadcastChatSignout(): void { post({ type: 'signout' }) + // The popout is being closed deliberately — a lagging poll must not + // race in afterward and force `open: true` into freshly-reset state. + stopPolling() } /* Crash net: a popout that dies without beforeunload still restores the dock. Uses the window handle when we have one (same page load), pings - otherwise (main was reloaded while popped out). */ + otherwise (main was reloaded while popped out). Two consecutive missed + replies are required before declaring it lost, so one slow reply doesn't + false-positive. */ function startPolling(handlers: PopoutMainHandlers) { if (pollTimer) return + missedPings = 0 pollTimer = window.setInterval(() => { if (popoutWindow) { if (popoutWindow.closed) lost(handlers) @@ -125,7 +135,10 @@ function startPolling(handlers: PopoutMainHandlers) { alivePending = true post({ type: 'ping' }) window.setTimeout(() => { - if (alivePending && pollTimer) lost(handlers) + if (alivePending && pollTimer) { + missedPings += 1 + if (missedPings >= 2) lost(handlers) + } }, PRESENCE_TIMEOUT) }, POLL_INTERVAL) } @@ -134,6 +147,7 @@ function stopPolling() { if (pollTimer) window.clearInterval(pollTimer) pollTimer = undefined popoutWindow = null + missedPings = 0 } function lost(handlers: PopoutMainHandlers) { @@ -143,9 +157,9 @@ function lost(handlers: PopoutMainHandlers) { /* ---------- popout-window side ---------- */ -export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void { - if (!channel) return - channel.addEventListener('message', (event: MessageEvent) => { +export function initChatPopoutWindow(handlers: PopoutWindowHandlers): () => void { + if (!channel) return () => {} + const messageListener = (event: MessageEvent) => { switch (event.data.type) { case 'adopt': // Main's copy is authoritative at hand-off; until it arrives the @@ -160,11 +174,17 @@ export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void { handlers.onSignout() break } - }) - window.addEventListener('beforeunload', () => { + } + const beforeUnloadListener = () => { if (!suppressHandback) post({ type: 'handback', payload: handlers.getHandoff() }) - }) + } + channel.addEventListener('message', messageListener) + window.addEventListener('beforeunload', beforeUnloadListener) post({ type: 'hello' }) + return () => { + channel.removeEventListener('message', messageListener) + window.removeEventListener('beforeunload', beforeUnloadListener) + } } export function popIn(payload: ChatHandoff): void { From 51eb3398b889709b8c7b9e1a2a50078b17dc37fe Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 28 Jul 2026 12:04:07 -0400 Subject: [PATCH 021/238] feat(chat): name the chat Mycal and heighten the org selector Co-Authored-By: Claude Fable 5 --- frontend/src/components/Chat/ChatOrgSelect.tsx | 2 +- frontend/src/components/Chat/ChatPanel.tsx | 2 +- frontend/src/components/Chat/ChatWindow.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx index 0181a8dd1..be6bd0c46 100644 --- a/frontend/src/components/Chat/ChatOrgSelect.tsx +++ b/frontend/src/components/Chat/ChatOrgSelect.tsx @@ -28,7 +28,7 @@ export const ChatOrgSelect: React.FC = () => { label="Organization" value={orgId || userId} onChange={event => dispatch.chat.set({ orgId: event.target.value })} - sx={{ '& .MuiSelect-select': { paddingY: '12px' } }} + sx={{ '& .MuiSelect-select': { paddingY: '14px' } }} > Personal {options.map(o => ( diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 2e48acdce..01c4d854b 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -79,7 +79,7 @@ export const ChatPanel: React.FC = () => { > - New Chat + Mycal {!singlePanel && ( { > - New Chat + Mycal dispatch.chat.clearConversation()} /> Date: Fri, 31 Jul 2026 19:26:53 -0400 Subject: [PATCH 022/238] feat(chat): builds resolve the agent via VITE_AGENT_URL, dev stays on the proxy --- frontend/src/services/agent.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 5724fc1d9..9bef0dbb8 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -3,9 +3,11 @@ * the client holds the transcript and resends it each turn. */ -// Dev: relative path served by the vite proxy (same-origin, CSP-clean). -// Staging/prod: set VITE_AGENT_URL to the deployed agent service domain. -export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent' +// Dev: relative path served by the vite proxy (same-origin, CSP-clean) — +// always, even when VITE_AGENT_URL is set, so dev stays on the proxy and +// out of CORS. Builds have no proxy: VITE_AGENT_URL names the deployed +// agent domain (https — the app's CSP only allows https: connections). +export const AGENT_URL = import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent' // Hydra credentials for the agent service (AUTH_MODE=hydra), written by the // in-app sign-in flow (services/hydra.ts) — or a token pasted from the From bc39422530dc5ef2c29eea0d58b1c69128bfab1b Mon Sep 17 00:00:00 2001 From: thelg4 Date: Fri, 31 Jul 2026 20:00:27 -0400 Subject: [PATCH 023/238] feat(chat): zero-state sign-in prompt matching app empty-state pattern --- frontend/src/components/Chat/ChatBody.tsx | 67 +++++++++++----------- frontend/src/components/Chat/ChatInput.tsx | 5 +- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index c976d1ae6..7f22927de 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -1,12 +1,14 @@ import React from 'react' import { useSelector, useDispatch } from 'react-redux' -import { Button } from '@mui/material' +import { Button, Typography } from '@mui/material' import { State, Dispatch } from '../../store' import { ChatMessages } from './ChatMessages' import { ChatApproval } from './ChatApproval' import { ChatInput } from './ChatInput' import { ChatOrgSelect } from './ChatOrgSelect' import { Notice } from '../Notice' +import { Body } from '../Body' +import { Icon } from '../Icon' import { isChatPopout } from '../../services/chatPopout' /* Everything below the chat header — shared by the docked panel and the @@ -14,6 +16,7 @@ import { isChatPopout } from '../../services/chatPopout' export const ChatBody: React.FC = () => { const chat = useSelector((state: State) => state.chat) const dispatch = useDispatch() + const signedOut = chat.health === 'unauthorized' return ( <> @@ -23,42 +26,38 @@ export const ChatBody: React.FC = () => { Agent unreachable — is the dev service running on :3001? )} - {chat.health === 'unauthorized' && ( - - <> + {signedOut ? ( + + + The AI agent needs its own sign-in to act on your behalf. - {isChatPopout ? ( - ' Sign in from the main app window.' - ) : ( - - )} - - + {isChatPopout && ' Sign in from the main app window.'} + + {!isChatPopout && ( + + )} + + ) : ( + + {chat.pendingConfirmation && ( + dispatch.chat.confirm(approved)} + /> + )} + {chat.error && ( + dispatch.chat.set({ error: null })}> + {chat.error} + + )} + )} - - {chat.pendingConfirmation && ( - dispatch.chat.confirm(approved)} - /> - )} - {chat.error && ( - dispatch.chat.set({ error: null })}> - {chat.error} - - )} - dispatch.chat.send(text)} onStop={() => dispatch.chat.stop()} diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx index eda003026..d714bc751 100644 --- a/frontend/src/components/Chat/ChatInput.tsx +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -4,12 +4,13 @@ import { IconButton } from '../../buttons/IconButton' type Props = { disabled: boolean + placeholder?: string streaming: boolean onSend: (text: string) => void onStop: () => void } -export const ChatInput: React.FC = ({ disabled, streaming, onSend, onStop }) => { +export const ChatInput: React.FC = ({ disabled, placeholder, streaming, onSend, onStop }) => { const [text, setText] = useState('') const submit = () => { const trimmed = text.trim() @@ -34,7 +35,7 @@ export const ChatInput: React.FC = ({ disabled, streaming, onSend, onStop fullWidth multiline maxRows={6} - placeholder={disabled ? 'Waiting for approval…' : ''} + placeholder={disabled ? placeholder : ''} value={text} disabled={disabled} sx={{ fontSize: 14, paddingY: 0.75 }} From 62793bc26c919fbff6e20b64a4d2b50f6bdb2985 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 4 Aug 2026 13:31:32 -0400 Subject: [PATCH 024/238] style(chat): match the org dropdown to the app's filled select pattern --- frontend/src/components/Chat/ChatOrgSelect.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx index be6bd0c46..3e5ce61fe 100644 --- a/frontend/src/components/Chat/ChatOrgSelect.tsx +++ b/frontend/src/components/Chat/ChatOrgSelect.tsx @@ -24,11 +24,10 @@ export const ChatOrgSelect: React.FC = () => { dispatch.chat.set({ orgId: event.target.value })} - sx={{ '& .MuiSelect-select': { paddingY: '14px' } }} > Personal {options.map(o => ( From 4bf2cbfb0d243f97ea627f4669eb27aef9d57b9e Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 4 Aug 2026 13:31:32 -0400 Subject: [PATCH 025/238] feat(chat): configurable agent proxy target for internal testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENT_PROXY_TARGET in frontend/.env points the same-origin /agent proxy at a deployed agent (e.g. http://dev-ai-agent.remote.it) instead of the local dev service — the proxy hop keeps CSP satisfied while the ALB is HTTP-only. --- frontend/vite.config.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0367f8fbd..6b8695026 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' @@ -25,12 +25,15 @@ export default defineConfig(({ mode }) => ({ alias: { '@common': path.resolve(__dirname, '../common/src') }, }, server: { - // Dev-only: same-origin path to the local ai-agent service, so the app's - // CSP ('self') passes without loosening. Staging/prod set VITE_AGENT_URL - // to the deployed agent domain instead — this proxy does not exist in builds. + // Dev-only: same-origin path to the ai-agent service, so the app's CSP + // ('self') passes without loosening. Defaults to the local dev service; + // set AGENT_PROXY_TARGET in frontend/.env to point at a deployed agent + // (e.g. http://dev-ai-agent.remote.it — its ALB is HTTP-only for now, so + // the same-origin proxy also sidesteps the CSP https:-only rule). + // Staging/prod builds set VITE_AGENT_URL instead — no proxy in builds. proxy: { '/agent': { - target: 'http://localhost:3001', + target: loadEnv(mode, __dirname, '').AGENT_PROXY_TARGET || 'http://localhost:3001', changeOrigin: true, rewrite: p => p.replace(/^\/agent/, ''), }, From 617275d1ee042419cfb874175a30f170bdcfd61b Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 4 Aug 2026 13:31:32 -0400 Subject: [PATCH 026/238] feat(chat): user-friendly unavailable state Replaces the dev-facing ':3001' banner: zero-state layout (matching the sign-in state) when the chat is empty, compact notice above an existing transcript, and the input disabled while Mycal is unreachable. --- frontend/src/components/Chat/ChatBody.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 7f22927de..99c546036 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -11,19 +11,22 @@ import { Body } from '../Body' import { Icon } from '../Icon' import { isChatPopout } from '../../services/chatPopout' +const UNAVAILABLE_MESSAGE = 'Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.' + /* Everything below the chat header — shared by the docked panel and the popout window */ export const ChatBody: React.FC = () => { const chat = useSelector((state: State) => state.chat) const dispatch = useDispatch() const signedOut = chat.health === 'unauthorized' + const unreachable = chat.health === 'unreachable' return ( <> - {chat.health === 'unreachable' && ( + {unreachable && !!chat.messages.length && ( - Agent unreachable — is the dev service running on :3001? + {UNAVAILABLE_MESSAGE} )} {signedOut ? ( @@ -39,6 +42,13 @@ export const ChatBody: React.FC = () => { )} + ) : unreachable && !chat.messages.length ? ( + + + + {UNAVAILABLE_MESSAGE} + + ) : ( {chat.pendingConfirmation && ( @@ -56,7 +66,7 @@ export const ChatBody: React.FC = () => { )} dispatch.chat.send(text)} From 94c25831ce5530275621462ef225f6749ea02436 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Tue, 4 Aug 2026 14:18:26 -0400 Subject: [PATCH 027/238] feat(chat): follow the app's active org instead of a chat-local selector The org sidebar (bottom-left avatar stack) is now the single org selector: the chat mirrors accounts.activeId and shows a read-only 'Current Org' label where the dropdown was. The popout window keeps the org handed off with its conversation. The request to the agent is unchanged (org { id, name }, omitted for Personal). --- frontend/src/components/Chat/ChatBody.tsx | 7 ++-- frontend/src/components/Chat/ChatOrgLabel.tsx | 29 +++++++++++++ .../src/components/Chat/ChatOrgSelect.tsx | 41 ------------------- frontend/src/components/Chat/ChatPanel.tsx | 7 +++- frontend/src/components/Chat/ChatWindow.tsx | 3 +- frontend/src/models/chat.ts | 22 ++-------- 6 files changed, 45 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/Chat/ChatOrgLabel.tsx delete mode 100644 frontend/src/components/Chat/ChatOrgSelect.tsx diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 99c546036..22c367108 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -5,13 +5,14 @@ import { State, Dispatch } from '../../store' import { ChatMessages } from './ChatMessages' import { ChatApproval } from './ChatApproval' import { ChatInput } from './ChatInput' -import { ChatOrgSelect } from './ChatOrgSelect' +import { ChatOrgLabel } from './ChatOrgLabel' import { Notice } from '../Notice' import { Body } from '../Body' import { Icon } from '../Icon' import { isChatPopout } from '../../services/chatPopout' -const UNAVAILABLE_MESSAGE = 'Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.' +const UNAVAILABLE_MESSAGE = + 'Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.' /* Everything below the chat header — shared by the docked panel and the popout window */ @@ -23,7 +24,7 @@ export const ChatBody: React.FC = () => { return ( <> - + {unreachable && !!chat.messages.length && ( {UNAVAILABLE_MESSAGE} diff --git a/frontend/src/components/Chat/ChatOrgLabel.tsx b/frontend/src/components/Chat/ChatOrgLabel.tsx new file mode 100644 index 000000000..d5ead07e1 --- /dev/null +++ b/frontend/src/components/Chat/ChatOrgLabel.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Box, Typography } from '@mui/material' +import { State } from '../../store' + +/* Read-only display of the org the agent is scoped to. The chat follows the + app's active org (the sidebar org selector); the popout window shows the + org handed off with the conversation. */ +export const ChatOrgLabel: React.FC = () => { + const orgId = useSelector((state: State) => state.chat.orgId) + const userId = useSelector((state: State) => state.user.id) + const memberships = useSelector((state: State) => state.accounts.membership) + const organizations = useSelector((state: State) => state.organization.accounts) + + const orgName = + !orgId || orgId === userId + ? 'Personal' + : (organizations[orgId]?.name || memberships.find(m => m.account.id === orgId)?.name || '').trim() || + 'Organization' + + return ( + + + Current Org + + {orgName} + + ) +} diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx deleted file mode 100644 index 3e5ce61fe..000000000 --- a/frontend/src/components/Chat/ChatOrgSelect.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { useSelector, useDispatch } from 'react-redux' -import { Box, TextField, MenuItem } from '@mui/material' -import { State, Dispatch } from '../../store' - -/* Org the agent is scoped to — defaults to the app's active org (models/chat - syncOrg) but diverges freely; a change applies from the next turn */ -export const ChatOrgSelect: React.FC = () => { - const dispatch = useDispatch() - const orgId = useSelector((state: State) => state.chat.orgId) - const userId = useSelector((state: State) => state.user.id) - const memberships = useSelector((state: State) => state.accounts.membership) - const organizations = useSelector((state: State) => state.organization.accounts) - - const options = memberships - .map(m => ({ id: m.account.id, name: (organizations[m.account.id]?.name || m.name || '').trim() })) - .filter(o => o.name) - .sort((a, b) => a.name.localeCompare(b.name)) - - if (!options.length) return null - - return ( - - dispatch.chat.set({ orgId: event.target.value })} - > - Personal - {options.map(o => ( - - {o.name} - - ))} - - - ) -} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 01c4d854b..fdb68e28c 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -22,6 +22,7 @@ const currentHandoff = (): ChatHandoff => { export const ChatPanel: React.FC = () => { const chat = useSelector((state: State) => state.chat) const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel) + const activeId = useSelector((state: State) => state.accounts.activeId) const dispatch = useDispatch() // Completes a Hydra sign-in redirect if this page load carries ?code — @@ -49,11 +50,15 @@ export const ChatPanel: React.FC = () => { useEffect(() => { if (chat.open) { dispatch.chat.resetTransient() - dispatch.chat.syncOrg() dispatch.chat.checkHealth() } }, [chat.open]) + // The chat follows the app's active org from the sidebar selector + useEffect(() => { + dispatch.chat.syncOrg() + }, [activeId]) + if (!chat.open) return null return ( diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 9ab9f2daf..4bf8ad307 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -19,7 +19,8 @@ export const ChatWindow: React.FC = () => { useEffect(() => { document.title = 'remote.it chat' dispatch.chat.resetTransient() - dispatch.chat.syncOrg() + // No syncOrg here: the popout keeps the org handed off with the + // conversation (it has no sidebar to change it with) dispatch.chat.checkHealth() const unsubscribe = initChatPopoutWindow({ adopt: payload => dispatch.chat.adoptTranscript(payload), diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index bf08c3e85..4c343c32a 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -111,11 +111,6 @@ function toMessageParams(messages: ChatTranscriptMessage[]): AgentMessageParam[] } let abortController: AbortController | null = null -// orgId is redux-persisted but must not survive a reload (spec: "not -// persisted"); on the first syncOrg after load, force-adopt the app's active -// org regardless of what was rehydrated. After that, intra-session -// divergence (the user picking a different org in the panel) is left alone. -let orgSynced = false export default createModel()({ state: { ...defaultChatState }, @@ -160,20 +155,11 @@ export default createModel()({ dispatch.chat.set({ streaming: false }) } }, - /* Default the chat org to the app's active org when unset or no longer valid. - orgId is not persisted across reloads: the first sync after load always - adopts the app's active org, discarding whatever was rehydrated. */ + /* The chat follows the app's active org (the sidebar selector) — the main + window mirrors it here whenever it changes. The popout window never + calls this: it keeps the org handed off with the conversation. */ async syncOrg(_: void, state) { - const userId = state.user.id - if (!orgSynced) { - orgSynced = true - dispatch.chat.set({ orgId: state.accounts.activeId || userId }) - return - } - const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)]) - if (!state.chat.orgId || !validIds.has(state.chat.orgId)) { - dispatch.chat.set({ orgId: state.accounts.activeId || userId }) - } + dispatch.chat.set({ orgId: state.accounts.activeId || state.user.id }) }, async confirm(approved: boolean, state) { const pending = state.chat.pendingConfirmation From c84253e5bb60d26b0dbe8ac89208d290142ae026 Mon Sep 17 00:00:00 2001 From: thelg4 Date: Wed, 5 Aug 2026 15:01:51 -0400 Subject: [PATCH 028/238] feat(chat): agent service override in test settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Settings gains an 'Override agent service' toggle with agent URL and MCP audience fields (persisted like the existing API overrides). agentURL() and mcpAudience() resolve per request: the override wins when enabled (https only — CSP blocks plain http), otherwise dev rides the vite proxy and builds use VITE_AGENT_URL. Changing the audience busts the OAuth client cache, so a fresh agent sign-in just works. --- frontend/src/models/ui.ts | 4 ++++ frontend/src/pages/TestPage.tsx | 39 +++++++++++++++++++++++++++++++++ frontend/src/services/agent.ts | 24 ++++++++++++-------- frontend/src/services/hydra.ts | 16 ++++++++++---- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index ea192ea3d..4a125b9cd 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -49,6 +49,10 @@ export type UIState = { apiGraphqlURL?: IPreferences['apiGraphqlURL'] webSocketURL?: IPreferences['webSocketURL'] apiURL?: IPreferences['apiURL'] + // Test UI: point the Mycal chat at a deployed agent (https only) + switchAgent?: boolean + agentURL?: string + mcpAudience?: string } layout: ILayout silent: string | null diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 058db8070..274cf7b86 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -14,6 +14,7 @@ import { PortalUI } from '../components/PortalUI' import { Title } from '../components/Title' import { Quote } from '../components/Quote' import { emit } from '../services/Controller' +import { MCP_AUDIENCE } from '../services/hydra' export const TestPage: React.FC = () => { const { t } = useTranslation() @@ -31,6 +32,12 @@ export const TestPage: React.FC = () => { emit('preferences', { ...preferences, [key]: value }) } + // Agent overrides are browser-only (the chat never touches the desktop + // backend), so no preference emit + async function setAgentPreference(key: string, value: string | boolean) { + await dispatch.ui.setPersistent({ apis: { ...apis, [key]: value } }) + } + return ( { + setAgentPreference('switchAgent', !apis.switchAgent)} + toggle={!!apis.switchAgent} + /> + + + + setAgentPreference('agentURL', url.toString().trim())} + hideIcon + /> + setAgentPreference('mcpAudience', value.toString().trim())} + hideIcon + /> + + + {t('testPage.features', 'Features')} diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 9bef0dbb8..85b980cd9 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -2,12 +2,18 @@ * Client for the ai-agent service (REST + SSE). The service is stateless: * the client holds the transcript and resends it each turn. */ - -// Dev: relative path served by the vite proxy (same-origin, CSP-clean) — -// always, even when VITE_AGENT_URL is set, so dev stays on the proxy and -// out of CORS. Builds have no proxy: VITE_AGENT_URL names the deployed -// agent domain (https — the app's CSP only allows https: connections). -export const AGENT_URL = import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent' +import { store } from '../store' + +/* Base URL for the agent service, resolved per request. The Test UI override + wins (Test Settings → Override agent service; https only — the app's CSP + blocks plain http). Otherwise dev rides the vite proxy (same-origin, + CSP-clean) even when VITE_AGENT_URL is set, staying out of CORS; builds + have no proxy and use the deployed agent domain from VITE_AGENT_URL. */ +export function agentURL(): string { + const { switchAgent, agentURL: override } = store.getState().ui.apis + if (switchAgent && override?.startsWith('https://')) return override.replace(/\/+$/, '') + return import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent' +} // Hydra credentials for the agent service (AUTH_MODE=hydra), written by the // in-app sign-in flow (services/hydra.ts) — or a token pasted from the @@ -78,7 +84,7 @@ export async function streamChat(options: { onEvent: (event: AgentEvent) => void }): Promise { const { conversationId, messages, org, signal, onEvent } = options - const response = await fetch(`${AGENT_URL}/api/chat`, { + const response = await fetch(`${agentURL()}/api/chat`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }), @@ -115,7 +121,7 @@ export async function confirmTool(options: { toolUseId: string approved: boolean }): Promise { - const response = await fetch(`${AGENT_URL}/api/chat/confirm`, { + const response = await fetch(`${agentURL()}/api/chat/confirm`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(options), @@ -128,7 +134,7 @@ export type AgentHealth = 'ok' | 'unauthorized' | 'unreachable' export async function agentHealth(): Promise { try { - const response = await fetch(`${AGENT_URL}/api/health`, { headers: agentHeaders(false) }) + const response = await fetch(`${agentURL()}/api/health`, { headers: agentHeaders(false) }) if (response.status === 401) return 'unauthorized' if (!response.ok) return 'unreachable' const body = (await response.json()) as { ok?: boolean } diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts index 4fae19cc2..ba2093f83 100644 --- a/frontend/src/services/hydra.ts +++ b/frontend/src/services/hydra.ts @@ -15,6 +15,7 @@ * process. */ import { getAgentSession, getAgentToken, setAgentSession, setAgentToken } from './agent' +import { store } from '../store' export const HYDRA_ISSUER = import.meta.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it' export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.beta.remote.it/mcp' @@ -35,8 +36,7 @@ const FLOW_KEY = 'agentOauthFlow' // started an agent sign-in (flow state present), so a genuine Cognito // callback is left untouched. const bootParams = new URLSearchParams(window.location.search) -const isAgentCallback = - !!window.sessionStorage.getItem(FLOW_KEY) && (bootParams.has('code') || bootParams.has('error')) +const isAgentCallback = !!window.sessionStorage.getItem(FLOW_KEY) && (bootParams.has('code') || bootParams.has('error')) if (isAgentCallback) { // Strip immediately: hides the single-use code from Amplify's listener and // from any reload. The hash route is preserved. @@ -66,7 +66,15 @@ type StoredClient = { client_id: string; key: string } // One public client per (issuer, origin, scope, audience) — the cache key // busts when the requested grant changes, like the demo SPA. -const clientCacheKey = (): string => `${HYDRA_ISSUER}|${window.location.origin}|${SCOPE}|${MCP_AUDIENCE}` +/* Effective audience for agent tokens: the Test UI override wins (Test + Settings → Override agent service) so tokens match the deployment the + tester pointed the chat at; changing it busts the client cache below. */ +const mcpAudience = (): string => { + const { switchAgent, mcpAudience: override } = store.getState().ui.apis + return (switchAgent && override?.trim()) || MCP_AUDIENCE +} + +const clientCacheKey = (): string => `${HYDRA_ISSUER}|${window.location.origin}|${SCOPE}|${mcpAudience()}` async function ensureClient(): Promise { try { @@ -135,7 +143,7 @@ export async function startAgentSignIn(): Promise { auth.searchParams.set('code_challenge', await sha256(verifier)) auth.searchParams.set('code_challenge_method', 'S256') // RFC 8707: binds the access token's audience to the MCP resource - auth.searchParams.set('resource', MCP_AUDIENCE) + auth.searchParams.set('resource', mcpAudience()) window.location.assign(auth.toString()) } From 2814ff4166a6c25f23c011756c2372c3c7e776bc Mon Sep 17 00:00:00 2001 From: thelg4 Date: Wed, 5 Aug 2026 15:47:26 -0400 Subject: [PATCH 029/238] feat(chat): soft-launch Mycal behind the Test UI in deployed builds MODE === 'development' gates become useChatEnabled(): always on in local dev, and on in deployed builds when the hidden Test UI is enabled (shift+option on the avatar menu). Also documents the dev-ai-agent/demo-audience pairing in Test Settings for staging testers, whose build default is the beta audience. --- frontend/src/components/App.tsx | 13 +++++-------- frontend/src/components/Header/Header.tsx | 6 ++++-- frontend/src/hooks/useChatEnabled.ts | 10 ++++++++++ frontend/src/pages/TestPage.tsx | 2 +- 4 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 frontend/src/hooks/useChatEnabled.ts diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 52f1cab1a..04d67e477 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -19,7 +19,6 @@ import { SHOW_TRIPLE_PANEL_WIDTH, CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED, - MODE, } from '../constants' import { State, Dispatch } from '../store' import { useMediaQuery, Box } from '@mui/material' @@ -31,6 +30,7 @@ import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' import { ChatPanel } from './Chat/ChatPanel' +import { useChatEnabled } from '../hooks/useChatEnabled' import { ChatWindow } from './Chat/ChatWindow' import { Router } from '../routers/Router' import { Page } from '../pages/Page' @@ -56,6 +56,7 @@ export const App: React.FC = () => { const waitMessage = useSelector((state: State) => state.ui.waitMessage) const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) const chatOpen = useSelector((state: State) => state.chat.open) + const chatEnabled = useChatEnabled() const chatExpanded = useSelector((state: State) => state.chat.expanded) const reseller = useSelector(selectResellerRef) const dispatch = useDispatch() @@ -66,11 +67,7 @@ export const App: React.FC = () => { // The open chat column reserves layout space the same way the sidebar does, // so Panel/DoublePanel/TriplePanel all reflow and clamp their resize math to it const chatPanelWidth = - MODE === 'development' && chatOpen && !singlePanel - ? chatExpanded - ? CHAT_PANEL_WIDTH_EXPANDED - : CHAT_PANEL_WIDTH - : 0 + chatEnabled && chatOpen && !singlePanel ? (chatExpanded ? CHAT_PANEL_WIDTH_EXPANDED : CHAT_PANEL_WIDTH) : 0 const sidePanelWidth = (hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0)) + chatPanelWidth const isRootMenu = location.pathname.match(REGEX_FIRST_PATH)?.[0] === location.pathname const showBottomMenu = (mobile || browser.isMobile) && isRootMenu && hideSidebar @@ -135,7 +132,7 @@ export const App: React.FC = () => { }> - {MODE === 'development' && isChatPopout ? ( + {chatEnabled && isChatPopout ? ( ) : ( <> @@ -152,7 +149,7 @@ export const App: React.FC = () => { > {hideSidebar ? : } - {MODE === 'development' && } + {chatEnabled && } {showBottomMenu && } diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index f9de4d42e..48ee8d421 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,4 +1,5 @@ -import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH, MODE } from '../../constants' +import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH } from '../../constants' +import { useChatEnabled } from '../../hooks/useChatEnabled' import React, { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import useNavigationUp from '../../hooks/useNavigationUp' @@ -31,6 +32,7 @@ export const Header: React.FC = ({ panels = 1 }) => { const permissions = useSelector(selectPermissions) const chatOpen = useSelector((state: State) => state.chat.open) const chatPoppedOut = useSelector((state: State) => state.chat.poppedOut) + const chatEnabled = useChatEnabled() const layout = useSelector((state: State) => state.ui.layout) const overlapHeader = layout.hideSidebar && browser.isElectron && browser.isMac @@ -90,7 +92,7 @@ export const Header: React.FC = ({ panels = 1 }) => { color="grayDarker" /> )} - {MODE === 'development' && !chatPoppedOut && ( + {chatEnabled && !chatPoppedOut && ( diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index c3e40ac37..150060f11 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -86,6 +86,13 @@ export async function oidcStart(): Promise { code_challenge: b64u(new Uint8Array(digest)), code_challenge_method: 'S256', scope: 'openid email full', + // First-party clients declare their own details (no consent screen — skipConsent): + // the passport-audience token minted later via refresh carries this slice, gating the + // native security settings (credentials.write); the graphql audience stays pure + // scope-`full` (an uncovered resource yields audience-only tokens). + authorization_details: JSON.stringify([ + { type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, + ]), state: flow.state, nonce: flow.nonce, } diff --git a/frontend/src/services/passportSelf.ts b/frontend/src/services/passportSelf.ts new file mode 100644 index 000000000..ef24e9b29 --- /dev/null +++ b/frontend/src/services/passportSelf.ts @@ -0,0 +1,41 @@ +import { oidcAccessToken } from './oidc' +import { OAUTH_PASSPORT_RESOURCE } from '../constants' + +/** + * Passport's self API (door C) — the account's OWN credential surface, called with a + * token minted for the passport audience (`resource` on refresh; the client requests the + * passport_account details at authorize). Every WRITE carries its own proof of + * possession — the current password or the relayed second-factor code — mirroring the + * console's re-authentication (permitteer docs/remoteit-desktop-login.md Phase 2b). + */ + +export type SelfContinuation = { + status?: 'ok' | 'mfa' | 'confirm' + challenge?: string + hint?: string + secret?: string + otpauth?: string + recovery_codes?: string[] + error?: string + error_description?: string +} + +const call = async (path: string, body?: Record): Promise => { + const token = await oidcAccessToken(OAUTH_PASSPORT_RESOURCE) + if (!token) return { httpStatus: 401, error: 'unauthorized' } + const response = await fetch(OAUTH_PASSPORT_RESOURCE + path, { + method: body ? 'POST' : 'GET', + headers: { authorization: 'Bearer ' + token, ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + const parsed = (await response.json().catch(() => ({}))) as SelfContinuation + return { ...parsed, httpStatus: response.status } +} + +export const selfMe = () => call('') +export const selfChangePassword = (current_password: string, new_password: string) => + call('/password', { current_password, new_password }) +export const selfChallenge = (challenge: string, code: string) => call('/challenge', { challenge, code }) +export const selfMfaEnroll = (password: string) => call('/mfa/enroll', { password }) +export const selfMfaConfirm = (challenge: string, code: string) => call('/mfa/confirm', { challenge, code }) +export const selfMfaDisable = (password: string) => call('/mfa/disable', { password }) From 428842a852d4807bbbeb2a3671ae18d21c5db4ea Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 19 Aug 2026 21:26:05 -0700 Subject: [PATCH 044/238] Security page: honest empty-state for federated-only accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user who has only ever signed in through an identity provider has no credential account at the IdP — door C answers a clean 403, and the panel now says what that MEANS (password and two-factor live at the provider) and points at the set-password flow that creates a Remote.It credential, instead of showing a misleading "Off" with a generic error behind it. Co-Authored-By: Claude Fable 5 --- frontend/src/components/MFA/MFASettings.tsx | 31 ++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/MFA/MFASettings.tsx b/frontend/src/components/MFA/MFASettings.tsx index 77afb0549..2a6da6a93 100644 --- a/frontend/src/components/MFA/MFASettings.tsx +++ b/frontend/src/components/MFA/MFASettings.tsx @@ -6,6 +6,7 @@ import { Gutters } from '../Gutters' import { selfMe, selfMfaEnroll, selfMfaConfirm, selfMfaDisable, selfChallenge, SelfContinuation, } from '../../services/passportSelf' +import { OAUTH_PASSPORT_RESOURCE } from '../../constants' /** * Two-factor settings over the Passport self-API (Phase 2b) — replaces the Cognito-era @@ -16,6 +17,7 @@ import { type Step = | { at: 'loading' } + | { at: 'none' } // no credential account here: the user signs in federated (e.g. Google) | { at: 'view'; enabled: boolean } | { at: 'password'; mode: 'enroll' | 'disable'; error?: string } | { at: 'relay'; mode: 'enroll' | 'disable'; challenge: string; hint?: string; error?: string } @@ -30,7 +32,11 @@ export const MFASettings: React.FC = () => { const [busy, setBusy] = useState(false) const refresh = async () => { - const me = (await selfMe()) as SelfContinuation & { mfaEnabled?: boolean } + const me = (await selfMe()) as SelfContinuation & { mfaEnabled?: boolean; httpStatus: number } + // 403 = the verified identity has NO credential account at the IdP: a federated-only + // user (Google). Their password and second factor live at their provider — say so, + // and point at the flow that creates a Remote.It credential if they want one. + if (me.httpStatus === 403) return setStep({ at: 'none' }) setStep({ at: 'view', enabled: !!me.mfaEnabled }) } useEffect(() => { @@ -84,6 +90,29 @@ export const MFASettings: React.FC = () => { if (step.at === 'loading') return title + if (step.at === 'none') + return ( + <> + {title} + + + {t( + 'mfa.federated', + 'You sign in with an identity provider (like Google), so your password and two-factor are managed there. To add a Remote.It password — usable alongside your provider — set one up first.' + )} + + + + + ) + if (step.at === 'view') return ( <> From 639c520de9657bad231b24e17d1dcb81a476e89f Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 19 Aug 2026 21:41:56 -0700 Subject: [PATCH 045/238] Explicit sign-out ends the AS session; web goes straight to login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two refinements from live use. Sign-out policy completes its split: an EXPLICIT sign-out performs RP-initiated logout (the end-session URL is built before teardown clears the id_token, and the browser leaves after local teardown lands), while failure-driven teardown remains local-only — the earlier rule protected the AS session from error cascades, not from the user's own intent. And on the web there is nothing to show a signed-out user: the AS login page IS the sign-in surface, so the app leaves for it immediately — an error return renders the launcher with the message so a cancel at the AS cannot loop. Desktop keeps the launcher panel; its window hosts the waiting state while the system browser runs the journey. Co-Authored-By: Claude Fable 5 --- frontend/src/components/SignInApp.tsx | 23 ++++++++++++++++++++++- frontend/src/models/auth.ts | 13 +++++++++++-- frontend/src/services/oidc.ts | 17 +++++++++-------- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index 46d116434..a5dd832d1 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -1,7 +1,8 @@ -import React from 'react' +import React, { useEffect } from 'react' import { Box, Button, Typography, CircularProgress } from '@mui/material' import { useDispatch, useSelector } from 'react-redux' import { Dispatch, State } from '../store' +import browser from '../services/browser' import brand from '@common/brand/config' /** @@ -14,6 +15,26 @@ export function SignInApp() { const { signInError, signingIn } = useSelector((state: State) => state.auth) const { auth } = useDispatch() + // On the WEB there is nothing to show a signed-out user — the AS login page IS the + // sign-in surface, so leave for it immediately (once per landing; an error return + // stays here so a cancel at the AS can't loop). Desktop keeps the launcher: its + // window must show something while the SYSTEM browser hosts the journey. + const autoStart = !browser.isElectron && !signingIn && !signInError + useEffect(() => { + if (autoStart) auth.signIn() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoStart]) + + if (autoStart || (!browser.isElectron && signingIn)) + return ( + + + + Taking you to sign in… + + + ) + return ( diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 8d871d0d5..97ced00fd 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -255,23 +255,32 @@ export default createModel()({ if (!browser.hasBackend) dispatch.auth.appReady() }, async signOut(_: void, state) { + // EXPLICIT sign-out ends the AS session too (RP-initiated logout) — the user asked + // to leave. Build the end-session URL BEFORE any teardown clears the id_token; + // failure-driven teardown (signedOut via the error paths) stays local-only. + const { oidcEndSessionUrl } = await import('../services/oidc') + const endSession = await oidcEndSessionUrl().catch(() => undefined) + const leave = () => { + if (endSession) window.location.assign(endSession) + } // emit returns false when the local socket isn't connected, and // backendAuthenticated can still be true at that moment - the flag is only // cleared once the socket's disconnect event lands. Without checking the // return value, sign out in that window did nothing at all: no purge, no // teardown, no redirect, and the user stayed signed in with no feedback. if (state.auth.backendAuthenticated) { - if (emit('user/sign-out')) return + if (emit('user/sign-out')) { leave(); return } // Don't tear down behind the backend's back if it's only momentarily // unreachable. It owns cli.signOut() and the connection pool, and a // frontend-only sign out leaves the CLI admin registered - which makes // the helper reject a different account until someone runs a manual // 'remoteit signout'. Force the socket back rather than wait out // socket.io's 20s retry, then send it for real. - if ((await Controller.reconnectNow(SIGN_OUT_BACKEND_TIMEOUT)) && emit('user/sign-out')) return + if ((await Controller.reconnectNow(SIGN_OUT_BACKEND_TIMEOUT)) && emit('user/sign-out')) { leave(); return } console.warn('SIGN OUT: local backend unreachable, signing the app out only') } await dispatch.auth.signedOut() + leave() }, /** * Gets called when the backend signs the user out diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 150060f11..78d1d199c 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -165,24 +165,25 @@ async function refresh(resource: string): Promise { } } -/** RP-initiated logout: clear locally FIRST, then send the browser to end the AS - * session (web departs; desktop bounces to the system browser and the window stays). */ -export async function oidcSignOut(): Promise { +/** The RP-initiated-logout URL for the CURRENT session, or undefined when there is + * nothing to end. Built BEFORE local teardown clears the id_token — the caller decides + * when to navigate (EXPLICIT sign-out only; failure paths never end the AS session). */ +export async function oidcEndSessionUrl(): Promise { const idToken = stored()?.id_token - clearLocal() - if (!idToken) return + if (!idToken) return undefined try { const d = await discover() - if (!d.end_session_endpoint) return + if (!d.end_session_endpoint) return undefined const url = new URL(d.end_session_endpoint) url.searchParams.set('id_token_hint', idToken) url.searchParams.set( 'post_logout_redirect_uri', browser.isElectron ? PROTOCOL + 'signoutCallback' : window.location.origin + '/signoutCallback' ) - window.location.assign(url.toString()) + return url.toString() } catch (error) { - console.warn('OIDC SIGN OUT (AS side) FAILED', error) + console.warn('OIDC END-SESSION URL FAILED', error) + return undefined } } From 31a8d8350fd7c4477b3fde9eaf1460a656003ade Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 19 Aug 2026 21:48:10 -0700 Subject: [PATCH 046/238] Boot restore validates the session is ALIVE before trusting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AS-side sign-out was working (session revoked on the row) but a boot with leftover local tokens rendered the app authenticated from storage alone — stored tokens are a claim of a session, not proof. Restore now forces one token mint: a dead refresh family (revoked session, family revocation) comes back empty and the app boots signed out, flowing straight to the login page. Co-Authored-By: Claude Fable 5 --- frontend/src/models/auth.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 97ced00fd..70233f107 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -66,8 +66,15 @@ export default createModel()({ // the desktop deep-link reload); otherwise restore a stored session. const claims = await oidcCompleteFromUrl() if (claims) await dispatch.auth.handleSignInSuccess(claims) - else if (oidcSignedIn()) await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) - else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') + else if (oidcSignedIn()) { + // Stored tokens are a CLAIM of a session, not proof of one: the AS may have + // revoked it (sign-out elsewhere, admin action, family revocation). Force one + // token mint — a dead refresh family clears itself and we boot signed OUT + // instead of rendering an authenticated shell over a corpse. + const alive = await getToken() + if (alive) await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) + else invalidateOidcToken() + } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') } catch (error: any) { console.error('AUTH INIT: sign-in completion failed', error) if (!options.silent) dispatch.auth.set({ signInError: error?.message || 'Sign in failed, please try again.' }) From dc621ad5158b6c26b36572a52ee9344db9523f49 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 19 Aug 2026 21:56:53 -0700 Subject: [PATCH 047/238] Sign-out journey wins the navigation; post-signout forces the login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the boomerang. The sign-in panel's auto-start assign RACED the end_session assign after teardown and won — the AS was never told, and a live session SSO'd straight back in: a signingOut gate now holds auto-start while the logout journey is in flight. And the return from an explicit sign-out (/signoutCallback boot) sends prompt=login on its authorize, so the user always lands on the LOGIN PAGE — never a silent SSO into another account's live chip in the multi-account cookie (which is what "still signed in" actually was: the OTHER account, evanrbowers@gmail.com, riding its live session). Co-Authored-By: Claude Fable 5 --- frontend/src/components/SignInApp.tsx | 6 +++--- frontend/src/models/auth.ts | 5 +++++ frontend/src/services/oidc.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index a5dd832d1..e8dd13888 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -12,20 +12,20 @@ import brand from '@common/brand/config' * the flow; this panel starts it and waits. */ export function SignInApp() { - const { signInError, signingIn } = useSelector((state: State) => state.auth) + const { signInError, signingIn, signingOut } = useSelector((state: State) => state.auth) const { auth } = useDispatch() // On the WEB there is nothing to show a signed-out user — the AS login page IS the // sign-in surface, so leave for it immediately (once per landing; an error return // stays here so a cancel at the AS can't loop). Desktop keeps the launcher: its // window must show something while the SYSTEM browser hosts the journey. - const autoStart = !browser.isElectron && !signingIn && !signInError + const autoStart = !browser.isElectron && !signingIn && !signInError && !signingOut useEffect(() => { if (autoStart) auth.signIn() // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoStart]) - if (autoStart || (!browser.isElectron && signingIn)) + if (autoStart || (!browser.isElectron && (signingIn || signingOut))) return ( diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 70233f107..98b8e5cbd 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -34,6 +34,7 @@ export interface AuthState { backendAuthenticated: boolean signInError?: string signingIn?: boolean + signingOut?: boolean passwordChallenge?: { challenge: string; hint?: string } user?: IUser mfaMethod: string @@ -267,6 +268,10 @@ export default createModel()({ // failure-driven teardown (signedOut via the error paths) stays local-only. const { oidcEndSessionUrl } = await import('../services/oidc') const endSession = await oidcEndSessionUrl().catch(() => undefined) + // Gate the sign-in panel's auto-start while the sign-out journey is in flight: + // its authorize assign RACED the end_session assign and won, so the AS was never + // told — a live session then SSO'd straight back over the "sign-out". + if (endSession) dispatch.auth.set({ signingOut: true }) const leave = () => { if (endSession) window.location.assign(endSession) } diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 78d1d199c..456f9591e 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -28,6 +28,15 @@ export type OidcClaims = { const FLOW_KEY = 'oidc.flow' const TOKENS_KEY = 'oidc.tokens' +// A boot on /signoutCallback is the RETURN from an explicit sign-out: the next authorize +// must show the LOGIN PAGE (prompt=login), never silently SSO into another account's +// live session in the multi-account cookie. +let promptLogin = false +if (window.location.pathname === '/signoutCallback') { + promptLogin = true + window.history.replaceState({}, '', window.location.origin + '/') +} + type Flow = { verifier: string; state: string; nonce: string; redirectUri: string } type Stored = { refresh_token: string; id_token?: string } @@ -96,6 +105,10 @@ export async function oidcStart(): Promise { state: flow.state, nonce: flow.nonce, } + if (promptLogin) { + params.prompt = 'login' + promptLogin = false + } for (const key in params) url.searchParams.set(key, params[key]) window.location.assign(url.toString()) } From d41ae76efea5eeeb7181b85f271c70de59f4b2f3 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 19 Aug 2026 22:17:42 -0700 Subject: [PATCH 048/238] Sign-out goes silent: fetch the AS logout, one hop to the login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end_session redirect parade (app spinner -> AS logout -> full SPA reboot -> spinner -> login form) collapses: explicit sign-out POSTs the AS's new silent lane (end_session_api_endpoint, same id_token trust) before teardown, then the sign-in auto-start carries prompt=login — the user's whole visible journey is app -> login form. The navigation race gate and the /signoutCallback bounce machinery die with the parade (the boot check stays for any front-channel return). Desktop sign-out now touches no browser at all. Co-Authored-By: Claude Fable 5 --- frontend/src/components/SignInApp.tsx | 6 ++--- frontend/src/models/auth.ts | 26 ++++++++----------- frontend/src/services/oidc.ts | 37 +++++++++++++++------------ 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index e8dd13888..a5dd832d1 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -12,20 +12,20 @@ import brand from '@common/brand/config' * the flow; this panel starts it and waits. */ export function SignInApp() { - const { signInError, signingIn, signingOut } = useSelector((state: State) => state.auth) + const { signInError, signingIn } = useSelector((state: State) => state.auth) const { auth } = useDispatch() // On the WEB there is nothing to show a signed-out user — the AS login page IS the // sign-in surface, so leave for it immediately (once per landing; an error return // stays here so a cancel at the AS can't loop). Desktop keeps the launcher: its // window must show something while the SYSTEM browser hosts the journey. - const autoStart = !browser.isElectron && !signingIn && !signInError && !signingOut + const autoStart = !browser.isElectron && !signingIn && !signInError useEffect(() => { if (autoStart) auth.signIn() // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoStart]) - if (autoStart || (!browser.isElectron && (signingIn || signingOut))) + if (autoStart || (!browser.isElectron && signingIn)) return ( diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 98b8e5cbd..32d9edd93 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -34,7 +34,6 @@ export interface AuthState { backendAuthenticated: boolean signInError?: string signingIn?: boolean - signingOut?: boolean passwordChallenge?: { challenge: string; hint?: string } user?: IUser mfaMethod: string @@ -263,36 +262,31 @@ export default createModel()({ if (!browser.hasBackend) dispatch.auth.appReady() }, async signOut(_: void, state) { - // EXPLICIT sign-out ends the AS session too (RP-initiated logout) — the user asked - // to leave. Build the end-session URL BEFORE any teardown clears the id_token; - // failure-driven teardown (signedOut via the error paths) stays local-only. - const { oidcEndSessionUrl } = await import('../services/oidc') - const endSession = await oidcEndSessionUrl().catch(() => undefined) - // Gate the sign-in panel's auto-start while the sign-out journey is in flight: - // its authorize assign RACED the end_session assign and won, so the AS was never - // told — a live session then SSO'd straight back over the "sign-out". - if (endSession) dispatch.auth.set({ signingOut: true }) - const leave = () => { - if (endSession) window.location.assign(endSession) - } + // EXPLICIT sign-out ends the AS session too — SILENTLY (fetch, before teardown + // clears the id_token): no end_session redirect parade, no navigation race with + // the sign-in auto-start. The next authorize carries prompt=login so the user + // lands on the LOGIN PAGE, never a silent SSO into another chip's live session. + // Failure-driven teardown (signedOut via the error paths) stays local-only. + const { oidcEndSessionSilently, oidcRequireLoginPrompt } = await import('../services/oidc') + await oidcEndSessionSilently() + oidcRequireLoginPrompt() // emit returns false when the local socket isn't connected, and // backendAuthenticated can still be true at that moment - the flag is only // cleared once the socket's disconnect event lands. Without checking the // return value, sign out in that window did nothing at all: no purge, no // teardown, no redirect, and the user stayed signed in with no feedback. if (state.auth.backendAuthenticated) { - if (emit('user/sign-out')) { leave(); return } + if (emit('user/sign-out')) return // Don't tear down behind the backend's back if it's only momentarily // unreachable. It owns cli.signOut() and the connection pool, and a // frontend-only sign out leaves the CLI admin registered - which makes // the helper reject a different account until someone runs a manual // 'remoteit signout'. Force the socket back rather than wait out // socket.io's 20s retry, then send it for real. - if ((await Controller.reconnectNow(SIGN_OUT_BACKEND_TIMEOUT)) && emit('user/sign-out')) { leave(); return } + if ((await Controller.reconnectNow(SIGN_OUT_BACKEND_TIMEOUT)) && emit('user/sign-out')) return console.warn('SIGN OUT: local backend unreachable, signing the app out only') } await dispatch.auth.signedOut() - leave() }, /** * Gets called when the backend signs the user out diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 456f9591e..704b92b03 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -32,6 +32,11 @@ const TOKENS_KEY = 'oidc.tokens' // must show the LOGIN PAGE (prompt=login), never silently SSO into another account's // live session in the multi-account cookie. let promptLogin = false +/** The NEXT authorize must land on the login page (no silent SSO into another chip) — + * set by the silent sign-out just before the app re-enters the sign-in flow. */ +export function oidcRequireLoginPrompt() { + promptLogin = true +} if (window.location.pathname === '/signoutCallback') { promptLogin = true window.history.replaceState({}, '', window.location.origin + '/') @@ -42,7 +47,7 @@ type Stored = { refresh_token: string; id_token?: string } let access: { [resource: string]: { token: string; exp: number } } = {} let refreshing: Promise | undefined -let discovery: { authorization_endpoint: string; token_endpoint: string; end_session_endpoint?: string } | undefined +let discovery: { authorization_endpoint: string; token_endpoint: string; end_session_endpoint?: string; end_session_api_endpoint?: string } | undefined const b64u = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') @@ -178,25 +183,25 @@ async function refresh(resource: string): Promise { } } -/** The RP-initiated-logout URL for the CURRENT session, or undefined when there is - * nothing to end. Built BEFORE local teardown clears the id_token — the caller decides - * when to navigate (EXPLICIT sign-out only; failure paths never end the AS session). */ -export async function oidcEndSessionUrl(): Promise { +/** SILENT AS logout (EXPLICIT sign-out only — failure paths never end the AS session): + * the same RP-initiated logout, over fetch. Same trust (the id_token hint), no + * navigation — the parade of redirect hops was the only thing the front-channel bought + * us. Best-effort: an unreachable AS must not block local teardown; the session gate + * kills the tokens lazily anyway. */ +export async function oidcEndSessionSilently(): Promise { const idToken = stored()?.id_token - if (!idToken) return undefined + if (!idToken) return try { const d = await discover() - if (!d.end_session_endpoint) return undefined - const url = new URL(d.end_session_endpoint) - url.searchParams.set('id_token_hint', idToken) - url.searchParams.set( - 'post_logout_redirect_uri', - browser.isElectron ? PROTOCOL + 'signoutCallback' : window.location.origin + '/signoutCallback' - ) - return url.toString() + if (!d.end_session_api_endpoint) return + const response = await fetch(d.end_session_api_endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id_token_hint: idToken }), + }) + if (!response.ok && response.status !== 204) console.warn('OIDC SILENT LOGOUT', response.status) } catch (error) { - console.warn('OIDC END-SESSION URL FAILED', error) - return undefined + console.warn('OIDC SILENT LOGOUT FAILED', error) } } From e3a7fbb77f7d7149d425bf73ebe36bafac8d2fda Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 19 Aug 2026 23:22:46 -0700 Subject: [PATCH 049/238] Two-factor panel speaks the methods model (Phase 2c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both factors listed with enrolled/preferred standing — Set Up, Turn Off, and Make Preferred per method, each behind the password proof. SMS enrollment takes the phone number and verifies its texted code; the select continuation (a store holding both factors unpreferred) renders as a factor choice that chains into the real code step. The federated empty-state and recovery-codes display carry over. Co-Authored-By: Claude Fable 5 --- frontend/src/components/MFA/MFASettings.tsx | 237 ++++++++++++++------ frontend/src/models/auth.ts | 2 +- frontend/src/services/passportSelf.ts | 21 +- 3 files changed, 183 insertions(+), 77 deletions(-) diff --git a/frontend/src/components/MFA/MFASettings.tsx b/frontend/src/components/MFA/MFASettings.tsx index 2a6da6a93..d8d816721 100644 --- a/frontend/src/components/MFA/MFASettings.tsx +++ b/frontend/src/components/MFA/MFASettings.tsx @@ -1,59 +1,86 @@ import React, { useEffect, useState } from 'react' import { QRCodeSVG } from 'qrcode.react' import { useTranslation } from 'react-i18next' -import { Box, Button, Chip, TextField, Typography } from '@mui/material' +import { Box, Button, Chip, Radio, RadioGroup, FormControlLabel, TextField, Typography } from '@mui/material' import { Gutters } from '../Gutters' import { - selfMe, selfMfaEnroll, selfMfaConfirm, selfMfaDisable, selfChallenge, SelfContinuation, + selfMfaStanding, selfMfaEnroll, selfMfaConfirm, selfMfaPrefer, selfMfaDisable, selfChallenge, + MfaMethod, SelfContinuation, } from '../../services/passportSelf' import { OAUTH_PASSPORT_RESOURCE } from '../../constants' /** - * Two-factor settings over the Passport self-API (Phase 2b) — replaces the Cognito-era - * MFAPreference. Every step re-proves possession: the password starts enroll/disable, - * the authenticator code lands it; a store that already challenges relays its code - * first. One component, one small state machine mirroring the API's continuations. + * Two-factor settings over the Passport self-API (plan Phase 2c): BOTH methods can be + * enrolled with exactly one preferred — the preferred factor drives every sign-in + * challenge. Every step re-proves possession (password, or the relayed code), and a + * store that challenges mid-management relays first — including the factor CHOICE + * (select) when a store holds both factors unpreferred. */ +type Mode = 'enroll' | 'disable' | 'prefer' + type Step = | { at: 'loading' } | { at: 'none' } // no credential account here: the user signs in federated (e.g. Google) - | { at: 'view'; enabled: boolean } - | { at: 'password'; mode: 'enroll' | 'disable'; error?: string } - | { at: 'relay'; mode: 'enroll' | 'disable'; challenge: string; hint?: string; error?: string } - | { at: 'scan'; challenge: string; secret: string; otpauth: string; error?: string } + | { at: 'view'; methods: MfaMethod[]; preferred?: MfaMethod; available: MfaMethod[] } + | { at: 'password'; mode: Mode; method?: MfaMethod; error?: string } + | { at: 'relay'; pending: { mode: Mode; method?: MfaMethod }; challenge: string; hint?: string; error?: string } + | { at: 'select'; pending: { mode: Mode; method?: MfaMethod }; challenge: string; options: MfaMethod[] } + | { at: 'scan'; challenge: string; secret?: string; otpauth?: string; delivery?: 'sms'; error?: string } | { at: 'codes'; codes: string[] } +const METHOD_LABEL: Record = { totp: 'Authenticator app', sms: 'Text message' } + export const MFASettings: React.FC = () => { const { t } = useTranslation() const [step, setStep] = useState({ at: 'loading' }) const [password, setPassword] = useState('') + const [phone, setPhone] = useState('') const [code, setCode] = useState('') + const [choice, setChoice] = useState('totp') const [busy, setBusy] = useState(false) const refresh = async () => { - const me = (await selfMe()) as SelfContinuation & { mfaEnabled?: boolean; httpStatus: number } - // 403 = the verified identity has NO credential account at the IdP: a federated-only - // user (Google). Their password and second factor live at their provider — say so, - // and point at the flow that creates a Remote.It credential if they want one. - if (me.httpStatus === 403) return setStep({ at: 'none' }) - setStep({ at: 'view', enabled: !!me.mfaEnabled }) + const standing = await selfMfaStanding() + if (standing.httpStatus === 403) return setStep({ at: 'none' }) + setStep({ at: 'view', methods: standing.methods ?? [], preferred: standing.preferred, available: standing.available ?? ['totp'] }) } useEffect(() => { refresh() }, []) - const submitPassword = async (mode: 'enroll' | 'disable') => { + const followContinuation = (r: SelfContinuation & { httpStatus: number }, pending: { mode: Mode; method?: MfaMethod }): boolean => { + if (r.status === 'ok') { + if (r.recovery_codes?.length) setStep({ at: 'codes', codes: r.recovery_codes }) + else refresh() + return true + } + if (r.status === 'confirm' && r.challenge) { + setStep({ at: 'scan', challenge: r.challenge, secret: r.secret, otpauth: r.otpauth, delivery: r.delivery }) + return true + } + if (r.status === 'mfa' && r.challenge) { + setStep({ at: 'relay', pending, challenge: r.challenge, hint: r.hint }) + return true + } + if (r.status === 'select' && r.challenge) { + setStep({ at: 'select', pending, challenge: r.challenge, options: (r.options ?? []) as MfaMethod[] }) + return true + } + return false + } + + const submitPassword = async (mode: Mode, method?: MfaMethod) => { setBusy(true) - const r = mode === 'enroll' ? await selfMfaEnroll(password) : await selfMfaDisable(password) + const r = + mode === 'enroll' ? await selfMfaEnroll(password, method ?? 'totp', method === 'sms' ? phone : undefined) + : mode === 'prefer' ? await selfMfaPrefer(password, method ?? 'totp') + : await selfMfaDisable(password, method) setBusy(false) setPassword('') - if (r.status === 'ok') return refresh() - if (r.status === 'confirm' && r.challenge && r.secret && r.otpauth) - return setStep({ at: 'scan', challenge: r.challenge, secret: r.secret, otpauth: r.otpauth }) - if (r.status === 'mfa' && r.challenge) return setStep({ at: 'relay', mode, challenge: r.challenge, hint: r.hint }) + if (followContinuation(r, { mode, method })) return setStep({ - at: 'password', mode, + at: 'password', mode, method, error: r.error === 'invalid_password' ? t('mfa.wrongPassword', "That password didn't match.") : r.error_description || t('mfa.failed', 'Something went wrong — try again.'), @@ -63,23 +90,28 @@ export const MFASettings: React.FC = () => { const submitCode = async () => { setBusy(true) const current = step as Extract - const r = current.at === 'scan' ? await selfMfaConfirm(current.challenge, code) : await selfChallenge(current.challenge, code) + const r = current.at === 'scan' + ? await selfMfaConfirm(current.challenge, code) + : await selfChallenge(current.challenge, { code }) setBusy(false) setCode('') - if (r.status === 'ok') { - if (r.recovery_codes?.length) return setStep({ at: 'codes', codes: r.recovery_codes }) - return refresh() - } - if (r.status === 'confirm' && r.challenge && r.secret && r.otpauth) - return setStep({ at: 'scan', challenge: r.challenge, secret: r.secret, otpauth: r.otpauth }) - // invalid_code re-arms the same step under a fresh handle + const pending = current.at === 'relay' ? current.pending : { mode: 'enroll' as Mode } + if (followContinuation(r, pending)) return if (r.challenge) { const error = t('mfa.wrongCode', "That code didn't match — try again.") if (current.at === 'scan') setStep({ ...current, challenge: r.challenge, error }) else setStep({ ...current, challenge: r.challenge, error }) return } - setStep({ at: 'view', enabled: false }) + refresh() + } + + const submitChoice = async () => { + const current = step as Extract + setBusy(true) + const r = await selfChallenge(current.challenge, { choice }) + setBusy(false) + if (!followContinuation(r, current.pending)) refresh() } const title = ( @@ -101,12 +133,7 @@ export const MFASettings: React.FC = () => { 'You sign in with an identity provider (like Google), so your password and two-factor are managed there. To add a Remote.It password — usable alongside your provider — set one up first.' )} - @@ -118,28 +145,43 @@ export const MFASettings: React.FC = () => { <> {title} - - - - {step.enabled - ? t('mfa.protects', 'Your authenticator protects every sign-in with this account.') - : t('mfa.suggest', 'Protect your account with an authenticator app.')} - - - - - + {step.available.map(method => { + const enrolled = step.methods.includes(method) + const preferred = step.preferred === method + return ( + + + {t(`mfa.method.${method}`, METHOD_LABEL[method])} + + + {enrolled ? ( + <> + {!preferred && ( + + )} + + + ) : ( + + )} + + ) + })} + + {step.methods.length + ? t('mfa.protects', 'The preferred method challenges every sign-in with this account.') + : t('mfa.suggest', 'Protect your account with an authenticator app or text messages.')} + ) @@ -152,8 +194,18 @@ export const MFASettings: React.FC = () => { {t('mfa.confirmPassword', 'Confirm your password to continue — changing a credential re-proves the one you hold.')} + {step.mode === 'enroll' && step.method === 'sms' && ( + setPhone(e.target.value.trim())} + /> + )} { )} - + + + + + ) + + if (step.at === 'select') + return ( + <> + {title} + + + {t('mfa.choose', 'How would you like to get your code?')} + + setChoice(e.target.value as MfaMethod)}> + {step.options.map(o => ( + } label={t(`mfa.method.${o}`, METHOD_LABEL[o] ?? o)} /> + ))} + + + + + ))} + + {t( + 'passkeys.explainer', + 'A passkey signs you in here with a touch instead of a code. Text or authenticator codes still protect sign-ins from older apps.' + )} + + + + + ) + + if (step.at === 'password') + return ( + <> + {title} + + + {t('mfa.confirmPassword', 'Confirm your password to continue — changing a credential re-proves the one you hold.')} + + setPassword(e.target.value)} /> + {step.error && ( + + {step.error} + + )} + + + + + + + ) + + if (step.at === 'relay') + return ( + <> + {title} + + + {step.isSelect + ? t('mfa.choose', 'How would you like to get your code? (totp or sms)') + : step.hint + ? t('mfa.relayHint', 'Enter the code sent to {{hint}}.', { hint: step.hint }) + : t('mfa.relay', 'Enter the 6-digit code from your current second factor.')} + + setCode(e.target.value.trim())} /> + {step.error && ( + + {step.error} + + )} + + + + + + + ) + + return ( + <> + {title} + + + {t('passkeys.added', 'Passkey added — next sign-in, use it instead of typing a code.')} + + {step.codes?.length ? ( + <> + + {t('mfa.codesTitle', 'Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.')} + + + {step.codes.join('\n')} + + + ) : null} + + + + ) +} diff --git a/frontend/src/pages/SecurityPage.tsx b/frontend/src/pages/SecurityPage.tsx index 28a9c1a6d..c5530d8cb 100644 --- a/frontend/src/pages/SecurityPage.tsx +++ b/frontend/src/pages/SecurityPage.tsx @@ -6,6 +6,7 @@ import { Title } from '../components/Title' import { Gutters } from '../components/Gutters' import { ChangePassword } from '../components/ChangePassword' import { MFASettings } from '../components/MFA/MFASettings' +import { PasskeysSettings } from '../components/MFA/PasskeysSettings' import { Dispatch } from '../store' import { useDispatch } from 'react-redux' @@ -24,6 +25,8 @@ export const SecurityPage: React.FC = () => { + + ) diff --git a/frontend/src/services/passportSelf.ts b/frontend/src/services/passportSelf.ts index 0971c79a8..a748262ab 100644 --- a/frontend/src/services/passportSelf.ts +++ b/frontend/src/services/passportSelf.ts @@ -10,13 +10,14 @@ import { OAUTH_PASSPORT_RESOURCE } from '../constants' */ export type SelfContinuation = { - status?: 'ok' | 'mfa' | 'confirm' | 'select' + status?: 'ok' | 'mfa' | 'confirm' | 'select' | 'register' challenge?: string hint?: string secret?: string otpauth?: string delivery?: 'sms' - options?: string[] + options?: string[] | Record + name?: string methods?: string[] preferred?: string recovery_codes?: string[] @@ -52,3 +53,7 @@ export const selfMfaConfirm = (challenge: string, code: string) => call('/mfa/co export const selfMfaPrefer = (password: string, method: MfaMethod) => call('/mfa/prefer', { password, method }) export const selfMfaDisable = (password: string, method?: MfaMethod) => call('/mfa/disable', { password, ...(method ? { method } : {}) }) +export const selfPasskeyRegister = (password: string) => call('/passkeys/register', { password }) +export const selfPasskeyConfirm = (challenge: string, attestation: { attestationObject: string; clientDataJSON: string }, name: string) => + call('/passkeys/confirm', { challenge, ...attestation, name }) +export const selfPasskeyDelete = (password: string, id: string) => call('/passkeys/delete', { password, id }) From c4063603662935a24892486ebe9fa2fb4e6a4865 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 19 Aug 2026 23:51:42 -0700 Subject: [PATCH 051/238] Fix the creation-options cast (tsc was masked by the pipe) Co-Authored-By: Claude Fable 5 --- frontend/src/components/MFA/PasskeysSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/MFA/PasskeysSettings.tsx b/frontend/src/components/MFA/PasskeysSettings.tsx index 649ad8c52..3054e228d 100644 --- a/frontend/src/components/MFA/PasskeysSettings.tsx +++ b/frontend/src/components/MFA/PasskeysSettings.tsx @@ -52,7 +52,7 @@ export const PasskeysSettings: React.FC = () => { challenge: b64uToBuf(options.challenge), user: { ...options.user, id: b64uToBuf(options.user.id) }, excludeCredentials: (options.excludeCredentials ?? []).map((c: any) => ({ ...c, id: b64uToBuf(c.id) })), - } as PublicKeyCredentialCreationOptions, + } as unknown as PublicKeyCredentialCreationOptions, })) as PublicKeyCredential const response = cred.response as AuthenticatorAttestationResponse const name = t('passkeys.defaultName', 'This device') From db76a6308d784005e1b88ee70656d2ac45e2d30b Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 15:14:56 -0700 Subject: [PATCH 052/238] Packaged shell: origin-keyed browser bounce + human launcher copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit will-navigate now bounces ANY navigation off the app's own origin to the system browser, keyed on origin rather than configuration — the packaged main process has no .env, so the issuer-keyed match failed closed into the window; an origin rule fails open to the browser (where SSO, passkeys and the password manager live). Launcher copy loses the feature-list voice: 'We'll open your browser to sign you in — your saved passwords and accounts are already there.' Co-Authored-By: Claude Fable 5 --- electron/src/ElectronApp.ts | 17 +++++++++++------ frontend/src/components/SignInApp.tsx | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index ac530c22d..e3a0be7d1 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -12,7 +12,6 @@ import { EventBus, Logger, } from './backend' -import { OAUTH_ISSUER } from './backend/constants' const URL_REGEX = new RegExp('^https?://') const IP_PRIVATE = '127.0.0.1' @@ -257,11 +256,17 @@ export default class ElectronApp { }) this.window.webContents.on('will-navigate', (event, url) => { - // Auth journeys belong in the SYSTEM browser (its password manager, its session), - // never inside this window. Keyed on the configured issuer, not a hard-coded host. - const authOrigin = OAUTH_ISSUER && url.startsWith(OAUTH_ISSUER) - if (authOrigin || url.includes('auth.remote.it')) { - Logger.info('AUTH NAVIGATION DETECTED') + // This window hosts exactly ONE origin: the app's own UI. Any other navigation — + // the auth journey above all — belongs in the SYSTEM browser, where the user's + // password manager, passkeys and single sign-on session live. Keyed on origin, + // not configuration: the packaged main process has no .env, so an issuer-based + // match fails CLOSED into this window; an origin rule fails open to the browser. + let external = false + try { + external = new URL(url).origin !== new URL(this.getStartUrl()).origin + } catch {} + if (external) { + Logger.info('EXTERNAL NAVIGATION -> SYSTEM BROWSER', { url }) event.preventDefault() electron.shell.openExternal(url) } diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index a5dd832d1..f9c4d26d8 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -41,7 +41,7 @@ export function SignInApp() { Sign in to {brand.appName} - Sign-in opens in your browser — password managers, single sign-on, and multi-account switching all work there. + We'll open your browser to sign you in — your saved passwords and accounts are already there. {signingIn ? ( From cdfa3404ce9b83dbdabf53d38bbf8a4e7b193145 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 15:22:57 -0700 Subject: [PATCH 053/238] =?UTF-8?q?Launcher=20copy:=20name=20the=20mechani?= =?UTF-8?q?sm=20=E2=80=94=20Remote.It=20Single=20Sign-On?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- frontend/src/components/SignInApp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index f9c4d26d8..89fa12b34 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -41,7 +41,7 @@ export function SignInApp() { Sign in to {brand.appName} - We'll open your browser to sign you in — your saved passwords and accounts are already there. + We'll open your browser to sign you in with Remote.It Single Sign-On. {signingIn ? ( From 201d301add5717236855d62e5729b1e10b1b971f Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 15:29:18 -0700 Subject: [PATCH 054/238] Switch account: select_account lane + AS-owned chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The avatar menu gains 'Switch account': authorize re-runs with prompt=select_account (already supported by the AS — the login page's chips select directly), so the account list lives where the truth is; the app never shadows it. Nothing tears down locally until the new code completes, so a canceled chooser costs nothing. A successful exchange that replaces an existing session revokes the old refresh family best-effort via /revoke. Co-Authored-By: Claude Fable 5 --- frontend/src/components/AvatarMenu.tsx | 8 ++++++++ frontend/src/models/auth.ts | 10 ++++++++++ frontend/src/services/oidc.ts | 14 ++++++++++++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/AvatarMenu.tsx b/frontend/src/components/AvatarMenu.tsx index 83bc73698..833c7b6a9 100644 --- a/frontend/src/components/AvatarMenu.tsx +++ b/frontend/src/components/AvatarMenu.tsx @@ -178,6 +178,14 @@ export const AvatarMenu: React.FC = () => { }} /> + { + handleClose() + await dispatch.auth.switchAccount() + }} + /> ()({ // Leave for the AS (the whole login UX — email-first, org SSO, MFA, signup, forgot — // lives there). On web the page departs; on desktop the window shows the waiting // panel until the deep link reloads it with the code. + /** Account switch: re-run authorize with select_account — the AS chooser shows the + * real session chips; nothing is torn down locally, so a canceled chooser costs + * nothing. Completion replaces the session like any sign-in (old family revoked). */ + async switchAccount(_: void) { + try { + await oidcStart({ prompt: 'select_account' }) + } catch (error) { + dispatch.auth.set({ signInError: error?.message || 'Could not open the account chooser.' }) + } + }, async signIn(_: void) { dispatch.auth.set({ signingIn: true, signInError: undefined }) try { diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 704b92b03..a706e8272 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -86,7 +86,7 @@ const redirectUri = () => /** Leave for the AS. On web the page departs; on desktop the main process bounces the * issuer origin to the system browser and the window stays on the waiting panel. */ -export async function oidcStart(): Promise { +export async function oidcStart(opts: { prompt?: 'login' | 'select_account' } = {}): Promise { const d = await discover() const verifier = randomB64u(48) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) @@ -110,7 +110,9 @@ export async function oidcStart(): Promise { state: flow.state, nonce: flow.nonce, } - if (promptLogin) { + if (opts.prompt) { + params.prompt = opts.prompt + } else if (promptLogin) { params.prompt = 'login' promptLogin = false } @@ -143,6 +145,14 @@ export async function oidcCompleteFromUrl(): Promise { }) const claims = decodeJwt(body.id_token) if (claims?.nonce !== flow.nonce) throw new Error('Sign-in nonce mismatch — try again.') + const previous = stored()?.refresh_token + if (previous && previous !== body.refresh_token) { + fetch(`${OAUTH_ISSUER}/revoke`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: previous, token_type_hint: 'refresh_token', client_id: OAUTH_CLIENT_ID }), + }).catch(() => {}) + } persist({ refresh_token: body.refresh_token, id_token: body.id_token }) const at = decodeJwt(body.access_token) access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0 } From f87d2e1be58f3386ab5f18b7d3365de150a406bf Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 16:09:17 -0700 Subject: [PATCH 055/238] Auth journey opens a NEW browser window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openExternal fronts the browser on whatever tab it already had — a flash of unrelated content before the sign-in page. The default handler's name comes from the OS; Chromium-family and Firefox take a new-window flag, Safari and unknown browsers keep the plain open (driving them needs Apple-Events consent). Regular external links stay on openExternal — normal tab behavior is right for them. Co-Authored-By: Claude Fable 5 --- electron/src/ElectronApp.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index e3a0be7d1..906fc460e 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -1,4 +1,5 @@ import electron, { Menu, dialog } from 'electron' +import { execFile } from 'child_process' import path from 'path' import AutoUpdater from './AutoUpdater' import TrayMenu from './TrayMenu' @@ -192,6 +193,24 @@ export default class ElectronApp { } } + /** The auth journey gets a NEW browser window. Plain openExternal fronts the browser + * on whatever tab it already had — a flash of unrelated content before the sign-in + * page. Chromium-family and Firefox take a new-window flag; Safari and unknown + * browsers would need Apple-Events permission for the same, so they keep the plain + * open. Regular external links (setWindowOpenHandler, deep-linked URLs) deliberately + * stay on openExternal — normal tab behavior is right for them. */ + private openAuthWindow(url: string) { + const name = environment.isMac ? this.app.getApplicationNameForProtocol('https://') : '' + const flag = /chrome|chromium|edge|brave|vivaldi|opera/i.test(name) ? '--new-window' : /firefox/i.test(name) ? '-new-window' : '' + if (!flag) { + electron.shell.openExternal(url) + return + } + execFile('open', ['-na', name, '--args', flag, url], error => { + if (error) electron.shell.openExternal(url) + }) + } + private setDeepLink(url?: string) { if (!url) return const scheme = this.protocol + '://' @@ -268,7 +287,7 @@ export default class ElectronApp { if (external) { Logger.info('EXTERNAL NAVIGATION -> SYSTEM BROWSER', { url }) event.preventDefault() - electron.shell.openExternal(url) + this.openAuthWindow(url) } }) From 170b39b6a9dbec1b47b4904fa79bbba2115b6941 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 16:18:17 -0700 Subject: [PATCH 056/238] Auth window: open in background, front once painted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open -g creates the browser window without focus; a beat later the browser is activated — the window-creation jitter happens out of sight and the user lands on a finished sign-in page. Heuristic delay: there is no cross-process paint signal. Co-Authored-By: Claude Fable 5 --- electron/src/ElectronApp.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index 906fc460e..bb7aeda02 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -206,8 +206,16 @@ export default class ElectronApp { electron.shell.openExternal(url) return } - execFile('open', ['-na', name, '--args', flag, url], error => { - if (error) electron.shell.openExternal(url) + // Two-step: create the window WITHOUT focus (-g), let the page load and paint out of + // sight, then front the browser — the user lands on a finished sign-in page instead + // of watching a window be born. The delay is a heuristic; there is no cross-process + // signal for the browser's paint. + execFile('open', ['-g', '-na', name, '--args', flag, url], error => { + if (error) { + electron.shell.openExternal(url) + return + } + setTimeout(() => execFile('open', ['-a', name], () => {}), 900) }) } From 3aa46967de6b6a682c849ea34012e41def3f167a Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 16:39:53 -0700 Subject: [PATCH 057/238] Connected Apps rides the AS's account API (plan D6, direct-to-AS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graphql façade this page queried was deleted with Hydra; the AS's own /account/api/apps is the source of truth — names, logos, per-action detail, lastUsedAt (now stamped on refresh) and honest revocation reach, one grant per row. services/permitteerAccount.ts is the whole client; the agents model keeps its shape minus the reach lanes (tag-level reach was deferred by D6 — AgentReachEditor and the reach types leave with it). Authorize now requests the permitteer_account apps slice; sessions that predate it get a sign-in-again notice instead of a broken list. Co-Authored-By: Claude Fable 5 --- .../components/ConnectedApps/AgentAvatar.tsx | 10 +- .../ConnectedApps/AgentListItem.tsx | 22 +-- .../ConnectedApps/AgentReachEditor.tsx | 173 ------------------ .../ConnectedApps/ConnectedApps.tsx | 21 ++- .../src/components/ConnectedApps/helpers.ts | 59 +----- frontend/src/models/agents.ts | 61 ++---- frontend/src/pages/ConnectedAppDetailPage.tsx | 90 ++++----- frontend/src/services/graphQLAgents.ts | 70 ------- frontend/src/services/oidc.ts | 2 + frontend/src/services/permitteerAccount.ts | 37 ++++ types.d.ts | 47 +++-- 11 files changed, 166 insertions(+), 426 deletions(-) delete mode 100644 frontend/src/components/ConnectedApps/AgentReachEditor.tsx delete mode 100644 frontend/src/services/graphQLAgents.ts create mode 100644 frontend/src/services/permitteerAccount.ts diff --git a/frontend/src/components/ConnectedApps/AgentAvatar.tsx b/frontend/src/components/ConnectedApps/AgentAvatar.tsx index 278a954b3..a7c3844e9 100644 --- a/frontend/src/components/ConnectedApps/AgentAvatar.tsx +++ b/frontend/src/components/ConnectedApps/AgentAvatar.tsx @@ -10,16 +10,16 @@ type Props = { inline?: boolean } -// The agent's logo from its OAuth client metadata (logo_uri), loaded on demand, with a colored -// monogram fallback like user avatars. No per-app code — any client that registers a logo_uri -// renders automatically. +// The app's logo from its OAuth client branding, loaded on demand, with a colored +// monogram fallback like user avatars. No per-app code — any client that registers a +// logo renders automatically. export const AgentAvatar: React.FC = ({ agent, size = 24, inline }) => { - const name = agent.clientName || agent.clientId + const name = agent.app || agent.clientId return ( ({ height: size, width: size, diff --git a/frontend/src/components/ConnectedApps/AgentListItem.tsx b/frontend/src/components/ConnectedApps/AgentListItem.tsx index 7ac9bbb00..75fc0068c 100644 --- a/frontend/src/components/ConnectedApps/AgentListItem.tsx +++ b/frontend/src/components/ConnectedApps/AgentListItem.tsx @@ -4,13 +4,11 @@ import { ListItemLocation } from '../ListItemLocation' import { AgentAvatar } from './AgentAvatar' import { Timestamp } from '../Timestamp' import { spacing } from '../../styling' -import { reachSummary, useAccountLabel } from './helpers' // One authorized app — a compact two-line row; the full breakdown and all // actions live on the detail page it links to. export const AgentListItem: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) => { - const accountLabel = useAccountLabel() - const name = agent.clientName || agent.clientId + const name = agent.app || agent.clientId return ( @@ -20,17 +18,13 @@ export const AgentListItem: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) - {reachSummary(agent.reach, accountLabel)} - {' · '} - {agent.lastActive ? ( - <> - Active - - ) : ( - 'No activity yet' - )} - + agent.lastUsedAt ? ( + <> + Last used + + ) : ( + 'Not used yet' + ) } /> diff --git a/frontend/src/components/ConnectedApps/AgentReachEditor.tsx b/frontend/src/components/ConnectedApps/AgentReachEditor.tsx deleted file mode 100644 index c1a74d3b8..000000000 --- a/frontend/src/components/ConnectedApps/AgentReachEditor.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import React, { useEffect, useRef } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { State, Dispatch } from '../../store' -import { Chip, Collapse, List, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { ListItemSetting } from '../ListItemSetting' -import { TagEditor } from '../TagEditor' -import { Gutters } from '../Gutters' -import { Avatar } from '../Avatar' -import { Tags } from '../Tags' -import { spacing } from '../../styling' -import { useAccountLabel } from './helpers' - -// Inline editor for an agent's device reach: each account gets an on/off toggle and the -// standard tag picker. Changes apply optimistically, like tag edits elsewhere in the app. -export const AgentReachEditor: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) => { - const dispatch = useDispatch() - const { t } = useTranslation() - const accountLabel = useAccountLabel() - const allTags = useSelector((state: State) => state.tags.all) - const meId = useSelector((state: State) => state.auth.user?.id || state.user.id) - const meEmail = useSelector((state: State) => state.auth.user?.email || state.user.email) - const membership = useSelector((state: State) => state.accounts.membership) - - const accountIds = [meId, ...membership.map(m => m.account.id)].filter(Boolean) - - // Remember each account's last active rule so its settings keep rendering through the - // collapse-out transition after it's toggled off (by then it's gone from `rules`). - const lastRules = useRef<{ [id: string]: IAccountReach }>({}) - - // Avatar keyed on the account email with the org name as its initial fallback — the same - // colored circle the organization picker shows. - const accountAvatar = (id: string) => { - const org = membership.find(m => m.account.id === id) - const email = id === meId ? meEmail : org?.account.email - return - } - - // Load each account's own tags for its picker (no-op when already cached). - useEffect(() => { - accountIds.forEach(id => dispatch.tags.fetchIfEmpty(id)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [accountIds.join()]) - - // null reach = no limit; render that as every account checked with no tags. - const unlimited = agent.reach == null - const rules: IAccountReach[] = unlimited - ? accountIds.map(id => ({ account: id, tags: null, operator: 'ANY' })) - : agent.reach || [] - - // Persist a new rule set; full reach (every account, no tags) is stored as "no limit". - const apply = (next: IAccountReach[]) => { - const full = accountIds.length > 0 && accountIds.every(id => next.some(r => r.account === id && !r.tags?.length)) - dispatch.agents.setLimit({ clientId: agent.clientId, accounts: full ? null : next }) - } - - const toggleAccount = (id: string, checked: boolean) => { - const base = rules.filter(r => r.account !== id) - apply(checked ? [...base, { account: id, tags: null, operator: 'ANY' }] : base) - } - - const updateRule = (id: string, patch: Partial) => - apply(rules.map(r => (r.account === id ? { ...r, ...patch } : r))) - - const name = agent.clientName || t('agentReachEditor.defaultAppName', 'This app') - const tagged = rules.filter(r => r.tags?.length).length - let summary - if (unlimited) - summary = t('agentReachEditor.summaryUnlimited', { - name, - defaultValue: '{{name}} can reach all devices in every organization you belong to.', - }) - else if (!rules.length) - summary = t('agentReachEditor.summaryNone', { name, defaultValue: '{{name}} cannot reach any devices.' }) - else { - const scope = - rules.length >= accountIds.length - ? t('agentReachEditor.scopeAll', 'every organization you belong to') - : t('agentReachEditor.scopeSome', { - count: rules.length, - total: accountIds.length, - defaultValue: '{{count}} of {{total}} organizations', - }) - const limits = tagged - ? t('agentReachEditor.limitsSuffix', { - count: tagged, - defaultValue_one: ', limited to tagged devices in one of them', - defaultValue_other: ', limited to tagged devices in {{count}} of them', - }) - : '' - summary = t('agentReachEditor.summary', { name, scope, limits, defaultValue: '{{name}} can reach {{scope}}{{limits}}.' }) - } - - return ( - <> - - - {summary} - - - {t( - 'agentReachEditor.instructions', - 'Turn on the organizations it may access. Add tags to limit an organization to matching devices only.' - )} - - - - {accountIds.map(id => { - const rule = rules.find(r => r.account === id) - if (rule) lastRules.current[id] = rule - // Render from the retained rule so the settings persist through the collapse-out. - const shown = rule || lastRules.current[id] - const accountTags = allTags[id] || [] - const selected: ITag[] = (shown?.tags || []).map( - tagName => accountTags.find(t => t.name === tagName) || { name: tagName, color: 0 } - ) - return ( - - toggleAccount(id, !rule)} - /> - - {shown && ( - - {shown.tags?.length ? ( - { - const tags = (shown.tags || []).filter(name => name !== tag.name) - updateRule(id, { tags: tags.length ? tags : null }) - }} - /> - ) : ( - - )} - updateRule(id, { tags: [...(shown.tags || []), tag.name] })} - /> - {(shown.tags?.length || 0) > 1 && ( - updateRule(id, { operator: shown.operator === 'ALL' ? 'ANY' : 'ALL' })} - /> - )} - - )} - - - ) - })} - - - ) -} diff --git a/frontend/src/components/ConnectedApps/ConnectedApps.tsx b/frontend/src/components/ConnectedApps/ConnectedApps.tsx index b952eaa7c..eec456672 100644 --- a/frontend/src/components/ConnectedApps/ConnectedApps.tsx +++ b/frontend/src/components/ConnectedApps/ConnectedApps.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { List, ListItem, ListItemIcon, ListItemText, Typography } from '@mui/material' +import { Button, List, ListItem, ListItemIcon, ListItemText, Typography } from '@mui/material' import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { State, Dispatch } from '../../store' @@ -9,7 +9,7 @@ import { Notice } from '../Notice' import { Icon } from '../Icon' export const ConnectedApps: React.FC = () => { - const { agents, fetching, init } = useSelector((state: State) => state.agents) + const { agents, fetching, init, needsReauth } = useSelector((state: State) => state.agents) const dispatch = useDispatch() const { t } = useTranslation() @@ -17,13 +17,28 @@ export const ConnectedApps: React.FC = () => { dispatch.agents.init() }, []) + if (needsReauth) + return ( + + + {t( + 'connectedApps.reauth', + 'Sign in again to see your connected apps — your current session started before this page could ask for them.' + )} + + + + ) + return ( <> {t('connectedApps.title', 'Apps & AI agents')} {agents.length ? ( {agents.map(agent => ( - + ))} ) : init && !fetching ? ( diff --git a/frontend/src/components/ConnectedApps/helpers.ts b/frontend/src/components/ConnectedApps/helpers.ts index 4d70d648c..144a2ddfe 100644 --- a/frontend/src/components/ConnectedApps/helpers.ts +++ b/frontend/src/components/ConnectedApps/helpers.ts @@ -1,55 +1,10 @@ -import { useMemo } from 'react' -import { useSelector } from 'react-redux' -import { State } from '../../store' - -// Friendly labels for the device-capability scopes an agent may hold. -export const CAPABILITY_LABEL: { [scope: string]: string } = { - 'device:read': 'View devices', - 'device:write': 'Manage devices', - 'device:connect': 'Connect', - 'device:execute': 'Run scripts', - 'user:read': 'View account', - 'org:read': 'View organization', -} - -export function capabilityLabel(scope: string): string { - return CAPABILITY_LABEL[scope] || scope -} - -export const agentIsLimited = (agent: IAuthorizedAgent): boolean => agent.reach != null - -// Resolve an account id to a human label: the org name for organizations you belong to (falling -// back to their account email), or "you" for the signed-in user's own account. -// A hook so the list/detail share one implementation; memoized to keep the map reference stable. -export function useAccountLabel(): (id: string) => string { - const membership = useSelector((state: State) => state.accounts.membership) - const meId = useSelector((state: State) => state.auth.user?.id || state.user.id) - const meEmail = useSelector((state: State) => state.auth.user?.email || state.user.email) - - return useMemo(() => { - const map: { [id: string]: string } = {} - if (meId) map[meId] = meEmail ? `${meEmail} (you)` : 'Your devices' - membership.forEach(m => (map[m.account.id] = m.name || m.account.email)) - return (id: string) => map[id] || id - }, [membership, meId, meEmail]) -} - -// A concise one-liner for the row; the detail page renders the full per-account breakdown. -export function reachSummary(reach: IAccountReach[] | null | undefined, accountLabel: (id: string) => string): string { - if (reach == null) return 'All devices' - if (!reach.length) return 'No devices' - if (reach.length === 1) { - const rule = reach[0] - const tags = rule.tags?.length ? ` (tags ${rule.tags.join(', ')})` : '' - return `Limited to ${accountLabel(rule.account)}${tags}` - } - return `Limited to ${reach.length} organizations` +// The AS's revokeReach tells the truth per API: which surfaces stop honouring tokens +// immediately, and which let an already-minted access token live out its TTL. +export function revokeWindow(minutes: number): string { + return `${minutes} minute${minutes === 1 ? '' : 's'}` } -// How long a revoked agent's in-flight access token still works (stateless JWT verification): -// revoke kills refresh immediately, but the last access token lives out its TTL. -export function accessWindow(seconds: number): string { - const mins = Math.round(seconds / 60) - if (mins >= 1) return `${mins} minute${mins === 1 ? '' : 's'}` - return `${seconds} seconds` +// Enabled actions across every permission group — the chips the pages render. +export function enabledActions(agent: IAuthorizedAgent): IGrantAction[] { + return (agent.groups ?? []).flatMap(g => g.actions).filter(a => a.enabled) } diff --git a/frontend/src/models/agents.ts b/frontend/src/models/agents.ts index 92bc10d25..a050e648b 100644 --- a/frontend/src/models/agents.ts +++ b/frontend/src/models/agents.ts @@ -1,18 +1,15 @@ import { createModel } from '@rematch/core' -import { - graphQLGetConnectedApps, - graphQLRevokeAgent, - graphQLSetAgentScope, - graphQLClearAgentScope, -} from '../services/graphQLAgents' +import { accountApps, revokeAccountApp } from '../services/permitteerAccount' import { RootModel } from '.' type IAgentsState = { init: boolean fetching: boolean - updating?: string // the clientId currently being revoked (drives the revoke button spinner) + updating?: string // the grant id currently being revoked (drives the revoke button spinner) agents: IAuthorizedAgent[] - accessTokenTtlSeconds: number // how long a revoked agent's in-flight token still works + // The session's token predates the connected-apps permission slice: one fresh sign-in + // (silent SSO — the AS session is alive) re-mints the grant with it. Drives the notice. + needsReauth: boolean } const defaultState: IAgentsState = { @@ -20,7 +17,7 @@ const defaultState: IAgentsState = { fetching: false, updating: undefined, agents: [], - accessTokenTtlSeconds: 300, + needsReauth: false, } export default createModel()({ @@ -34,15 +31,15 @@ export default createModel()({ async fetch() { dispatch.agents.set({ fetching: true }) try { - // One call: graphql's Connected Apps façade returns the agent list (it queries the Hydra - // front on our behalf) already merged with reach + last-active. - const result = await graphQLGetConnectedApps() - if (result && result !== 'ERROR') { - const connectedApps = result.data?.data?.login?.connectedApps - dispatch.agents.set({ - agents: connectedApps?.agents || [], - accessTokenTtlSeconds: connectedApps?.accessTokenTtlSeconds || 300, - }) + // Direct to the AS's account API (desktop-login plan D6): the grant list IS the + // connected apps list — names, logos, per-action detail and revocation reach + // included. Only apps that were actually granted appear; first-party skip-consent + // surfaces (this app itself) rightly do not list themselves. + const result = await accountApps() + if (result.status === 200 && result.body) { + dispatch.agents.set({ agents: result.body.items ?? [], needsReauth: false }) + } else if (result.status === 401 || result.status === 403) { + dispatch.agents.set({ needsReauth: true }) } } catch (error) { console.error('CONNECTED APPS: fetch failed', error) @@ -50,40 +47,18 @@ export default createModel()({ dispatch.agents.set({ fetching: false }) } }, - async revoke(clientId: string) { - dispatch.agents.set({ updating: clientId }) - await graphQLRevokeAgent(clientId) + async revoke(grantId: string) { + dispatch.agents.set({ updating: grantId }) + await revokeAccountApp(grantId) await dispatch.agents.fetch() dispatch.agents.set({ updating: undefined }) }, - // Optimistic: the mutation is a full replacement, so on success the local value IS the - // server value — no refetch, and no `updating` flag (the UI updates instantly). On error, - // revert to the previous reach (the graphql layer has already surfaced the error). - // Known edge: with rapid toggles, a request that fails *after* a later one succeeds reverts - // to its own stale `previous`, briefly diverging from the server until the next fetch - // reconciles it. Accepted — a failure interleaved with rapid edits is rare and self-heals. - async setLimit(params: { clientId: string; accounts: IAccountReach[] | null }, globalState) { - const previous = globalState.agents.agents.find(a => a.clientId === params.clientId)?.reach ?? null - dispatch.agents.setReach({ clientId: params.clientId, reach: params.accounts }) - const result = await graphQLSetAgentScope(params.clientId, params.accounts) - if (result === 'ERROR') dispatch.agents.setReach({ clientId: params.clientId, reach: previous }) - }, - async clearLimit(clientId: string, globalState) { - const previous = globalState.agents.agents.find(a => a.clientId === clientId)?.reach ?? null - dispatch.agents.setReach({ clientId, reach: null }) - const result = await graphQLClearAgentScope(clientId) - if (result === 'ERROR') dispatch.agents.setReach({ clientId, reach: previous }) - }, }), reducers: { reset(state: IAgentsState) { state = { ...defaultState } return state }, - setReach(state: IAgentsState, params: { clientId: string; reach: IAccountReach[] | null }) { - state.agents = state.agents.map(a => (a.clientId === params.clientId ? { ...a, reach: params.reach } : a)) - return state - }, set(state: IAgentsState, params: Partial) { Object.keys(params).forEach(key => (state[key] = params[key])) return state diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index b8c133378..2f3240eb9 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -13,8 +13,7 @@ import { Notice } from '../components/Notice' import { Icon } from '../components/Icon' import { Timestamp } from '../components/Timestamp' import { AgentAvatar } from '../components/ConnectedApps/AgentAvatar' -import { AgentReachEditor } from '../components/ConnectedApps/AgentReachEditor' -import { capabilityLabel, accessWindow } from '../components/ConnectedApps/helpers' +import { enabledActions, revokeWindow } from '../components/ConnectedApps/helpers' import { spacing } from '../styling' export const ConnectedAppDetailPage: React.FC = () => { @@ -25,10 +24,9 @@ export const ConnectedAppDetailPage: React.FC = () => { const dispatch = useDispatch() const agent = useSelector((state: State) => state.agents.agents.find(a => a.clientId === decoded)) - const ttl = useSelector((state: State) => state.agents.accessTokenTtlSeconds) const fetching = useSelector((state: State) => state.agents.fetching) const init = useSelector((state: State) => state.agents.init) - const revoking = useSelector((state: State) => state.agents.updating === decoded) + const revoking = useSelector((state: State) => state.agents.updating === agent?.id) useEffect(() => { dispatch.agents.init() @@ -61,7 +59,9 @@ export const ConnectedAppDetailPage: React.FC = () => { ) } - const name = agent.clientName || agent.clientId + const name = agent.app || agent.clientId + const actions = enabledActions(agent) + const reach = agent.revokeReach return ( { children: ( <> - {name} {t('connectedAppDetailPage.signOutBefore', 'will be signed out. New access is blocked immediately; any session already in progress ends within')}{' '} - {accessWindow(ttl)}. + {name}{' '} + {t('connectedAppDetailPage.signOutBefore', 'will be signed out and can no longer get new access.')} + {reach?.delayed?.length ? ( + <> + {' '} + {t('connectedAppDetailPage.delayedReach', { + apis: reach.delayed.join(', '), + window: revokeWindow(reach.delayMinutes), + defaultValue: 'Access already in progress at {{apis}} ends within {{window}}.', + })} + + ) : null} {t('connectedAppDetailPage.requestAgain', 'It can request access again by signing in.')} @@ -97,7 +107,7 @@ export const ConnectedAppDetailPage: React.FC = () => { ), }} onClick={async () => { - await dispatch.agents.revoke(agent.clientId) + await dispatch.agents.revoke(agent.id) back() }} /> @@ -106,23 +116,29 @@ export const ConnectedAppDetailPage: React.FC = () => { > {t('connectedAppDetailPage.permissions', 'Permissions')} - {agent.capabilities.length ? ( + {actions.length ? ( <> {t('connectedAppDetailPage.grantedWhenSignedIn', { name, - defaultValue: 'Granted when {{name}} signed in. To change them, revoke access and have it sign in again.', + defaultValue: 'Granted when {{name}} signed in. Manage or trim them from your account page.', })} - {agent.capabilities.map(scope => ( - + {actions.map(action => ( + ))} ) : ( {t( - 'connectedAppDetailPage.noDeviceAccess', - 'No device access — it can confirm your identity, but cannot see or control any devices.' + 'connectedAppDetailPage.signInOnly', + 'Sign-in only — it can confirm your identity, but was granted nothing else.' )} )} @@ -130,50 +146,40 @@ export const ConnectedAppDetailPage: React.FC = () => { {t('connectedAppDetailPage.details', 'Details')} - {agent.audience.length > 0 && ( - ( - - {a.label} - - {a.url} - - - ))} - displayOnly - /> - )} - {agent.grantedAt && ( + {agent.givenAt && ( } + displayValue={} displayOnly /> )} + agent.lastUsedAt ? ( + ) : ( t('connectedAppDetailPage.noActivityYet', 'No activity yet') ) } displayOnly /> + {agent.links?.map(link => ( + + {link.url} + + } + displayOnly + /> + ))} - - {t('connectedAppDetailPage.deviceAccess', 'Device access')} - ) } diff --git a/frontend/src/services/graphQLAgents.ts b/frontend/src/services/graphQLAgents.ts deleted file mode 100644 index fb99aa74b..000000000 --- a/frontend/src/services/graphQLAgents.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { graphQLBasicRequest } from './graphQL' - -// Connected Apps data — ALL via graphql (the façade): login.connectedApps merges the agent list -// (graphql calls the Hydra front's admin surface service-to-service on our behalf) with the reach -// policies and last-active timestamps, and revokeAgent revokes through the same path. One host, -// one token (the normal access token) — the desktop no longer talks to the OAuth front directly -// or handles the Cognito ID token. - -export async function graphQLGetConnectedApps() { - return await graphQLBasicRequest( - ` query ConnectedApps { - login { - connectedApps { - accessTokenTtlSeconds - agents { - clientId - clientName - logoUri - capabilities - audience { - url - label - } - grantedAt - reach { - account - tags - operator - } - lastActive - } - } - } - }` - ) -} - -export async function graphQLRevokeAgent(clientId: string) { - return await graphQLBasicRequest( - ` mutation RevokeAgent($clientId: String!) { - revokeAgent(clientId: $clientId) - }`, - { clientId } - ) -} - -// `accounts` = the per-account reach rules. null clears the limit (full reach); an empty -// array is a real value — an empty allowlist, i.e. no device access. -export async function graphQLSetAgentScope(clientId: string, accounts: IAccountReach[] | null) { - return await graphQLBasicRequest( - ` mutation SetAgentScope($clientId: String!, $accounts: [AgentAccountReachInput!]) { - setAgentScope(clientId: $clientId, accounts: $accounts) - }`, - { - clientId, - accounts: accounts - ? accounts.map(rule => ({ account: rule.account, tags: rule.tags, operator: rule.operator })) - : null, - } - ) -} - -export async function graphQLClearAgentScope(clientId: string) { - return await graphQLBasicRequest( - ` mutation ClearAgentScope($clientId: String!) { - clearAgentScope(clientId: $clientId) - }`, - { clientId } - ) -} diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index a706e8272..4b0fde35f 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -106,6 +106,8 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account' } = // scope-`full` (an uncovered resource yields audience-only tokens). authorization_details: JSON.stringify([ { type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, + // Connected Apps rides the AS's own account API (plan D6) — list + revoke only. + { type: 'permitteer_account', actions: ['apps.read', 'apps.write'] }, ]), state: flow.state, nonce: flow.nonce, diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts new file mode 100644 index 000000000..ae3dc4da1 --- /dev/null +++ b/frontend/src/services/permitteerAccount.ts @@ -0,0 +1,37 @@ +/* Direct-to-AS Connected Apps (desktop-login plan D6): the authorization server's own + * account API is the source of truth for what this person has authorized — the grant is + * the unit, and revoking it kills every refresh token minted from it. No graphql gateway: + * the deleted Hydra façade is not coming back, and the AS view already carries names, + * logos, per-action detail and honest revocation reach. */ +import { oidcAccessToken } from './oidc' +import { OAUTH_ISSUER } from '../constants' + +const RESOURCE = `${OAUTH_ISSUER}/account/api` + +export type AccountApiResult = { status: number; body?: T } + +async function call(path: string, init: RequestInit = {}): Promise> { + const token = await oidcAccessToken(RESOURCE) + if (!token) return { status: 401 } + const response = await fetch(`${RESOURCE}${path}`, { + ...init, + headers: { authorization: `Bearer ${token}`, ...(init.headers || {}) }, + }) + let body: T | undefined + try { + body = (await response.json()) as T + } catch { + body = undefined + } + return { status: response.status, body } +} + +/** The person's connected apps — the AS account API's own view rows, unreshaped. */ +export async function accountApps(): Promise> { + return await call('/apps') +} + +/** Revoke one grant. Instant at the AS — the grant dies and every refresh token with it. */ +export async function revokeAccountApp(grantId: string): Promise { + return await call(`/apps/${encodeURIComponent(grantId)}`, { method: 'DELETE' }) +} diff --git a/types.d.ts b/types.d.ts index 81aeab8ea..62a1d689a 100644 --- a/types.d.ts +++ b/types.d.ts @@ -816,32 +816,31 @@ declare global { lastUsed: Date } - // A surface an agent's token is valid for: the raw resource URL + the friendly catalog label. - type IAgentAudience = { - url: string - label: string - } - - // An OAuth app / AI agent the user has authorized (a Hydra consent), from graphql's - // login.connectedApps façade (list + reach + lastActive pre-merged). null reach = full reach. + // A connected app as the AS's account API reports it (GET {issuer}/account/api/apps). + // The GRANT is the unit — one row per authorized app, and revoking the id kills every + // refresh token minted from it. Shape mirrors the AS view verbatim (no reshaping layer). type IAuthorizedAgent = { + id: string // grant id — the revocation handle clientId: string - clientName?: string - logoUri?: string - capabilities: string[] // device:read / device:write / … scopes granted - audience: IAgentAudience[] // surfaces the token is valid for (url + friendly label) - grantedAt?: string - expiresAt?: string - reach?: IAccountReach[] | null // per-account reach limit; null/absent = no limit (all devices) - lastActive?: string // last API request seen (merged from graphql login.agentActivity) - } - - // One account an agent may reach, limited to the given tags (owned by that account; null = all - // its devices) matched by the operator. - type IAccountReach = { - account: string // account id - tags: string[] | null - operator: ITagOperator // 'ANY' | 'ALL' + app: string // display name from the client's branding + logo: string | null + active: boolean + givenAt?: string + updatedAt?: string + lastUsedAt?: string | null + scopes: string[] + groups: { domain?: string; resourceLabel?: string; actions: IGrantAction[] }[] + links: { name: string; url: string }[] + revokeReach: { immediate: string[]; delayed: string[]; delayMinutes: number } + } + + type IGrantAction = { + key: string + label: string + description: string | null + limit: string | null + enabled: boolean + orgLimited: boolean } type IRouteType = 'failover' | 'p2p' | 'proxy' | 'public' From e7160afe7c2b6e2ff5d6a19e6fb8b3bfb7487781 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 16:47:24 -0700 Subject: [PATCH 058/238] Connected Apps: name what permissions apply to; revoke is a labeled action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list row names the permission areas beside last-used; the detail page groups chips under the API they reach (typeLabel + resource) — two identical 'Read everything' chips with no home was the tell. The revoke moves out of the header trash-can into its own bottom section with a labeled danger button and the same honest reach copy. Co-Authored-By: Claude Fable 5 --- .../ConnectedApps/AgentListItem.tsx | 19 ++- frontend/src/pages/ConnectedAppDetailPage.tsx | 123 +++++++++++------- types.d.ts | 2 +- 3 files changed, 86 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/ConnectedApps/AgentListItem.tsx b/frontend/src/components/ConnectedApps/AgentListItem.tsx index 75fc0068c..a076d2d9f 100644 --- a/frontend/src/components/ConnectedApps/AgentListItem.tsx +++ b/frontend/src/components/ConnectedApps/AgentListItem.tsx @@ -9,6 +9,8 @@ import { spacing } from '../../styling' // actions live on the detail page it links to. export const AgentListItem: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) => { const name = agent.app || agent.clientId + // The areas this grant reaches — the same names the detail page headers use. + const areas = [...new Set((agent.groups ?? []).map(g => g.typeLabel).filter(Boolean))] as string[] return ( @@ -18,13 +20,16 @@ export const AgentListItem: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) - Last used - - ) : ( - 'Not used yet' - ) + <> + {areas.length ? `${areas.join(', ')} · ` : ''} + {agent.lastUsedAt ? ( + <> + Last used + + ) : ( + 'Not used yet' + )} + } /> diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 2f3240eb9..554103101 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -6,7 +6,7 @@ import { useDispatch, useSelector } from 'react-redux' import { State, Dispatch } from '../store' import { Container } from '../components/Container' import { Title } from '../components/Title' -import { ConfirmIconButton } from '../buttons/ConfirmIconButton' +import { ConfirmButton } from '../buttons/ConfirmButton' import { FormDisplay } from '../components/FormDisplay' import { Gutters } from '../components/Gutters' import { Notice } from '../components/Notice' @@ -72,45 +72,6 @@ export const ConnectedAppDetailPage: React.FC = () => { {name} - - - {name}{' '} - {t('connectedAppDetailPage.signOutBefore', 'will be signed out and can no longer get new access.')} - {reach?.delayed?.length ? ( - <> - {' '} - {t('connectedAppDetailPage.delayedReach', { - apis: reach.delayed.join(', '), - window: revokeWindow(reach.delayMinutes), - defaultValue: 'Access already in progress at {{apis}} ends within {{window}}.', - })} - - ) : null} - - - {t('connectedAppDetailPage.requestAgain', 'It can request access again by signing in.')} - - - ), - }} - onClick={async () => { - await dispatch.agents.revoke(agent.id) - back() - }} - /> } > @@ -121,18 +82,32 @@ export const ConnectedAppDetailPage: React.FC = () => { {t('connectedAppDetailPage.grantedWhenSignedIn', { name, - defaultValue: 'Granted when {{name}} signed in. Manage or trim them from your account page.', + defaultValue: 'Granted when {{name}} signed in — grouped by what they apply to.', })} - {actions.map(action => ( - - ))} + {(agent.groups ?? []).map((group, i) => { + const enabled = group.actions.filter(a => a.enabled) + if (!enabled.length) return null + const where = + group.resourceLabel && group.resourceLabel !== '(all resources)' ? ` — ${group.resourceLabel}` : '' + return ( + + + {group.typeLabel} + {where} + + {enabled.map(action => ( + + ))} + + ) + })} ) : ( @@ -180,6 +155,54 @@ export const ConnectedAppDetailPage: React.FC = () => { /> ))} + + {t('connectedAppDetailPage.revokeSection', 'Revoke access')} + + + {t('connectedAppDetailPage.revokeExplain', { + name, + defaultValue: 'Signs {{name}} out of your account and blocks it from getting new access. It can request access again by signing in.', + })} + + + + {name}{' '} + {t('connectedAppDetailPage.signOutBefore', 'will be signed out and can no longer get new access.')} + {reach?.delayed?.length ? ( + <> + {' '} + {t('connectedAppDetailPage.delayedReach', { + apis: reach.delayed.join(', '), + window: revokeWindow(reach.delayMinutes), + defaultValue: 'Access already in progress at {{apis}} ends within {{window}}.', + })} + + ) : null} + + + {t('connectedAppDetailPage.requestAgain', 'It can request access again by signing in.')} + + + ), + }} + onClick={async () => { + await dispatch.agents.revoke(agent.id) + back() + }} + /> + ) } diff --git a/types.d.ts b/types.d.ts index 62a1d689a..05086123d 100644 --- a/types.d.ts +++ b/types.d.ts @@ -829,7 +829,7 @@ declare global { updatedAt?: string lastUsedAt?: string | null scopes: string[] - groups: { domain?: string; resourceLabel?: string; actions: IGrantAction[] }[] + groups: { typeLabel?: string; resourceLabel?: string; actions: IGrantAction[] }[] links: { name: string; url: string }[] revokeReach: { immediate: string[]; delayed: string[]; delayMinutes: number } } From a93eb181c2398914b4c07bfb0a547d46da2a2a74 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 17:28:22 -0700 Subject: [PATCH 059/238] Connected Apps renders the scope lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail page shows scopeGroups after the detail groups (API name header, scope labels as chips) and list rows count those APIs among their permission areas — a first-party app's device access finally shows where its permissions live. Co-Authored-By: Claude Fable 5 --- .../ConnectedApps/AgentListItem.tsx | 7 ++++++- frontend/src/pages/ConnectedAppDetailPage.tsx | 19 ++++++++++++++++++- types.d.ts | 3 +++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ConnectedApps/AgentListItem.tsx b/frontend/src/components/ConnectedApps/AgentListItem.tsx index a076d2d9f..1ee84ff03 100644 --- a/frontend/src/components/ConnectedApps/AgentListItem.tsx +++ b/frontend/src/components/ConnectedApps/AgentListItem.tsx @@ -10,7 +10,12 @@ import { spacing } from '../../styling' export const AgentListItem: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) => { const name = agent.app || agent.clientId // The areas this grant reaches — the same names the detail page headers use. - const areas = [...new Set((agent.groups ?? []).map(g => g.typeLabel).filter(Boolean))] as string[] + const areas = [ + ...new Set([ + ...(agent.groups ?? []).map(g => g.typeLabel), + ...(agent.scopeGroups ?? []).map(g => g.api), + ].filter(Boolean)), + ] as string[] return ( diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 554103101..689121bd1 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -109,7 +109,24 @@ export const ConnectedAppDetailPage: React.FC = () => { ) })} - ) : ( + ) : null} + {(agent.scopeGroups ?? []).map((group, i) => ( + + + {group.api} + + {group.actions.map(action => ( + + ))} + + ))} + {!actions.length && !(agent.scopeGroups ?? []).length && ( {t( 'connectedAppDetailPage.signInOnly', diff --git a/types.d.ts b/types.d.ts index 05086123d..14ed76e04 100644 --- a/types.d.ts +++ b/types.d.ts @@ -830,6 +830,9 @@ declare global { lastUsedAt?: string | null scopes: string[] groups: { typeLabel?: string; resourceLabel?: string; actions: IGrantAction[] }[] + // The SCOPE lane, read-only: APIs the client may bind whose granted scopes they declare + // (a first-party app's device access lives here, never in the detail groups). + scopeGroups?: { api: string; actions: { key: string; label: string; description: string | null }[] }[] links: { name: string; url: string }[] revokeReach: { immediate: string[]; delayed: string[]; delayMinutes: number } } From baaca7e51ff7a1c7f7a8ff2c622606ab5aa164ad Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 17:42:50 -0700 Subject: [PATCH 060/238] Detail groups name the owning API's address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'What resource is Identity provider administration?' now answers itself: each detail group header carries the API's host — concrete and deployment-neutral, no naming curation required. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 5 +++++ types.d.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 689121bd1..fb0e5edba 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -95,6 +95,11 @@ export const ConnectedAppDetailPage: React.FC = () => { {group.typeLabel} {where} + {group.apiHost ? ( + + {group.apiHost} + + ) : null} {enabled.map(action => ( Date: Thu, 20 Aug 2026 18:30:44 -0700 Subject: [PATCH 061/238] Connected Apps detail reads like the consent screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piece rows (DEVICES / NETWORKS / …) with verb chips inside each permission group — the same grammar consent sub-groups by — plus the API's address in the header and a limit every action shares stated once instead of stuttering on every chip. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 43 ++++++++++++++----- types.d.ts | 1 + 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index fb0e5edba..2236f6eb3 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useHistory, useParams } from 'react-router-dom' -import { Chip, List, Typography } from '@mui/material' +import { Box, Chip, List, Typography } from '@mui/material' import { useDispatch, useSelector } from 'react-redux' import { State, Dispatch } from '../store' import { Container } from '../components/Container' @@ -90,6 +90,21 @@ export const ConnectedAppDetailPage: React.FC = () => { if (!enabled.length) return null const where = group.resourceLabel && group.resourceLabel !== '(all resources)' ? ` — ${group.resourceLabel}` : '' + // Consent's grammar: one row per , verbs as chips; a limit every + // action shares reads once under the group instead of on every chip. + const limits = [...new Set(enabled.map(a => a.limit).filter(Boolean))] + const sharedLimit = limits.length === 1 && enabled.every(a => a.limit === limits[0]) ? limits[0] : null + const pieces = [...new Set(enabled.map(a => a.piece ?? null))] + const chips = (actions: IGrantAction[]) => + actions.map(action => ( + + )) return ( @@ -101,15 +116,23 @@ export const ConnectedAppDetailPage: React.FC = () => { ) : null} - {enabled.map(action => ( - - ))} + {pieces.length > 1 ? ( + pieces.map(piece => ( + + + {piece ?? 'General'} + + {chips(enabled.filter(a => (a.piece ?? null) === piece))} + + )) + ) : ( + chips(enabled) + )} + {sharedLimit ? ( + + {sharedLimit} + + ) : null} ) })} diff --git a/types.d.ts b/types.d.ts index de96aa0e4..d2e120ffe 100644 --- a/types.d.ts +++ b/types.d.ts @@ -840,6 +840,7 @@ declare global { type IGrantAction = { key: string label: string + piece?: string | null // consent's grammar (devices.connect → "Devices") description: string | null limit: string | null enabled: boolean From c2819c6c2f07b8e67c7cf4cec758ad53a9804038 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 18:52:10 -0700 Subject: [PATCH 062/238] Desktop sign-in offers the account chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live browser chip silently SSO'd whoever was last signed in; the Sign in button now sends prompt=select_account so the person picks. Deliberate selection also satisfies the post-signout never-silent rule. Web keeps the plain path — auto-start SSO is the point there. Co-Authored-By: Claude Fable 5 --- frontend/src/models/auth.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 19e5d5155..a31ea4e47 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -99,7 +99,13 @@ export default createModel()({ async signIn(_: void) { dispatch.auth.set({ signingIn: true, signInError: undefined }) try { - await oidcStart() + // Desktop sign-in always offers the CHOOSER (prompt=select_account): a live chip + // in the browser would otherwise silently SSO whoever was last signed in, and a + // button that says "Sign in" should let the person pick. This also covers the + // post-signout rule (never silently reuse a chip) — a deliberate selection is + // not silent. Web keeps the plain path: its auto-start SSO is the point there, + // and its signout-return lane still forces prompt=login. + await oidcStart(browser.isElectron ? { prompt: 'select_account' } : {}) } catch (error: any) { console.error('SIGN IN FAILED', error) dispatch.auth.set({ signingIn: false, signInError: error?.message || 'Sign in failed, please try again.' }) From 5eb60c45d286ae0c4cf78f6c65b6e0ee095821c7 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 19:00:03 -0700 Subject: [PATCH 063/238] Detail header shows the app's verifiable origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rule as the consent card: CIMD client-id host, else first redirect host, never for first-party — the name is self-asserted, the origin is what's checkable. 'Claude · claude.ai'. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 5 +++++ types.d.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 2236f6eb3..8fbd7034c 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -71,6 +71,11 @@ export const ConnectedAppDetailPage: React.FC = () => { <AgentAvatar agent={agent} size={spacing.xl} inline /> {name} + {agent.appOrigin ? ( + <Typography component="span" variant="body2" color="textSecondary" sx={{ marginLeft: 1.5 }}> + {agent.appOrigin} + </Typography> + ) : null} } diff --git a/types.d.ts b/types.d.ts index d2e120ffe..49b49bcd1 100644 --- a/types.d.ts +++ b/types.d.ts @@ -824,6 +824,7 @@ declare global { clientId: string app: string // display name from the client's branding logo: string | null + appOrigin?: string | null // the client's VERIFIABLE origin (claude.ai) — null for first-party active: boolean givenAt?: string updatedAt?: string From ba6aca7de91b28254654752593b75ef65a8534f5 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 19:15:32 -0700 Subject: [PATCH 064/238] Connected Apps detail becomes the grant editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every ceiling action renders as a toggle chip (disabled ones stay listed, re-enable later — the console PATCH's own semantics), sign-in scopes toggle too, and Save/Cancel appear when dirty. The ACCOUNTS reach renders like consent's section (all-flag or chosen ids) — display-only, since changing reach means a fresh consent. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 133 ++++++++++++++---- frontend/src/services/permitteerAccount.ts | 11 ++ types.d.ts | 10 +- 3 files changed, 129 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 8fbd7034c..74ed8c24c 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -1,7 +1,7 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useHistory, useParams } from 'react-router-dom' -import { Box, Chip, List, Typography } from '@mui/material' +import { Box, Button, Chip, List, Typography } from '@mui/material' import { useDispatch, useSelector } from 'react-redux' import { State, Dispatch } from '../store' import { Container } from '../components/Container' @@ -14,6 +14,7 @@ import { Icon } from '../components/Icon' import { Timestamp } from '../components/Timestamp' import { AgentAvatar } from '../components/ConnectedApps/AgentAvatar' import { enabledActions, revokeWindow } from '../components/ConnectedApps/helpers' +import { updateAccountApp } from '../services/permitteerAccount' import { spacing } from '../styling' export const ConnectedAppDetailPage: React.FC = () => { @@ -28,6 +29,13 @@ export const ConnectedAppDetailPage: React.FC = () => { const init = useSelector((state: State) => state.agents.init) const revoking = useSelector((state: State) => state.agents.updating === agent?.id) + // Editor state: null = pristine (mirror the server); a Set = the user's pending choice. + // The PATCH is the console editor's own: unlisted ceiling actions disable but stay + // listed, so anything unticked here can be re-ticked later. + const [keepEdit, setKeepEdit] = useState | null>(null) + const [scopeEdit, setScopeEdit] = useState | null>(null) + const [saving, setSaving] = useState(false) + useEffect(() => { dispatch.agents.init() }, []) @@ -62,6 +70,32 @@ export const ConnectedAppDetailPage: React.FC = () => { const name = agent.app || agent.clientId const actions = enabledActions(agent) const reach = agent.revokeReach + const allActions = (agent.groups ?? []).flatMap(g => g.actions) + const kept = keepEdit ?? new Set(allActions.filter(a => a.enabled).map(a => a.key)) + const scopesKept = scopeEdit ?? new Set(agent.scopes ?? []) + const dirty = + (keepEdit !== null && (keepEdit.size !== actions.length || actions.some(a => !keepEdit.has(a.key)))) || + (scopeEdit !== null && (scopeEdit.size !== (agent.scopes ?? []).length || (agent.scopes ?? []).some(sc => !scopeEdit.has(sc)))) + const toggleAction = (key: string) => { + if (!agent.active || saving) return + const next = new Set(kept) + next.has(key) ? next.delete(key) : next.add(key) + setKeepEdit(next) + } + const toggleScope = (sc: string) => { + if (!agent.active || saving) return + const next = new Set(scopesKept) + next.has(sc) ? next.delete(sc) : next.add(sc) + setScopeEdit(next) + } + const save = async () => { + setSaving(true) + await updateAccountApp(agent.id, [...kept], [...scopesKept]) + await dispatch.agents.fetch() + setKeepEdit(null) + setScopeEdit(null) + setSaving(false) + } return ( { > {t('connectedAppDetailPage.permissions', 'Permissions')} - {actions.length ? ( + {allActions.length ? ( <> - {t('connectedAppDetailPage.grantedWhenSignedIn', { + {t('connectedAppDetailPage.editHint', { name, - defaultValue: 'Granted when {{name}} signed in — grouped by what they apply to.', + defaultValue: 'Granted when {{name}} signed in. Tap a permission to disable it — it stays listed so you can re-enable it later.', })} {(agent.groups ?? []).map((group, i) => { - const enabled = group.actions.filter(a => a.enabled) - if (!enabled.length) return null + if (!group.actions.length) return null const where = group.resourceLabel && group.resourceLabel !== '(all resources)' ? ` — ${group.resourceLabel}` : '' - // Consent's grammar: one row per , verbs as chips; a limit every - // action shares reads once under the group instead of on every chip. - const limits = [...new Set(enabled.map(a => a.limit).filter(Boolean))] - const sharedLimit = limits.length === 1 && enabled.every(a => a.limit === limits[0]) ? limits[0] : null - const pieces = [...new Set(enabled.map(a => a.piece ?? null))] + // Consent's grammar: one row per , verbs as toggle chips; a limit + // every action shares reads once under the group instead of on every chip. + const limits = [...new Set(group.actions.map(a => a.limit).filter(Boolean))] + const sharedLimit = limits.length === 1 && group.actions.every(a => a.limit === limits[0]) ? limits[0] : null + const pieces = [...new Set(group.actions.map(a => a.piece ?? null))] const chips = (actions: IGrantAction[]) => - actions.map(action => ( - - )) + actions.map(action => { + const on = kept.has(action.key) + return ( + toggleAction(action.key)} + label={!sharedLimit && action.limit ? `${action.label} (${action.limit})` : action.label} + title={action.description || undefined} + sx={{ mr: 1, mb: 0.5, opacity: on ? 1 : 0.6 }} + /> + ) + }) return ( @@ -127,12 +167,25 @@ export const ConnectedAppDetailPage: React.FC = () => { {piece ?? 'General'} - {chips(enabled.filter(a => (a.piece ?? null) === piece))} + {chips(group.actions.filter(a => (a.piece ?? null) === piece))} )) ) : ( - chips(enabled) + chips(group.actions) )} + {group.reach ? ( + + + {t('connectedAppDetailPage.accounts', 'Accounts')} + + + {group.reach.all + ? t('connectedAppDetailPage.allAccounts', 'All accounts, including ones added later') + : group.reach.accounts.map(a => (a.filter ? `${a.id} (${a.filter})` : a.id)).join(' · ') || + t('connectedAppDetailPage.noAccounts', 'None')} + + + ) : null} {sharedLimit ? ( {sharedLimit} @@ -141,6 +194,38 @@ export const ConnectedAppDetailPage: React.FC = () => { ) })} + {(agent.scopes ?? []).length ? ( + <> + + {t('connectedAppDetailPage.signInScopes', 'Sign-in scopes')} + + {(agent.scopes ?? []).map(sc => { + const on = scopesKept.has(sc) + return ( + toggleScope(sc)} + label={sc} + sx={{ mr: 1, mb: 0.5, opacity: on ? 1 : 0.6 }} + /> + ) + })} + + ) : null} + {dirty ? ( + + + + + ) : null} ) : null} {(agent.scopeGroups ?? []).map((group, i) => ( @@ -159,7 +244,7 @@ export const ConnectedAppDetailPage: React.FC = () => { ))} ))} - {!actions.length && !(agent.scopeGroups ?? []).length && ( + {!allActions.length && !(agent.scopeGroups ?? []).length && ( {t( 'connectedAppDetailPage.signInOnly', diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index ae3dc4da1..292863977 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -35,3 +35,14 @@ export async function accountApps(): Promise { return await call(`/apps/${encodeURIComponent(grantId)}`, { method: 'DELETE' }) } + +/** Trim or re-enable a grant's permissions — the console editor's own PATCH: `keep` names + * the action keys that stay enabled (unlisted ceiling actions disable, stay listed, and + * can be re-enabled later), `keepScope` the sign-in scopes that survive. */ +export async function updateAccountApp(grantId: string, keep: string[], keepScope: string[]): Promise { + return await call(`/apps/${encodeURIComponent(grantId)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ keep, keepScope }), + }) +} diff --git a/types.d.ts b/types.d.ts index 49b49bcd1..9d8632c65 100644 --- a/types.d.ts +++ b/types.d.ts @@ -830,7 +830,15 @@ declare global { updatedAt?: string lastUsedAt?: string | null scopes: string[] - groups: { typeLabel?: string; resourceLabel?: string; api?: string | null; apiHost?: string | null; actions: IGrantAction[] }[] + groups: { + typeLabel?: string + resourceLabel?: string + api?: string | null + apiHost?: string | null + // consent's ACCOUNTS section replayed from the grant (ids only — names were picker data) + reach?: { all: boolean; accounts: { id: string; filter: string | null }[] } | null + actions: IGrantAction[] + }[] // The SCOPE lane, read-only: APIs the client may bind whose granted scopes they declare // (a first-party app's device access lives here, never in the detail groups). scopeGroups?: { api: string; actions: { key: string; label: string; description: string | null }[] }[] From d6a4b3fd5ed5b0cc6354f91c757247a7998806c3 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 19:26:37 -0700 Subject: [PATCH 065/238] Connected Apps honors the grant's revoked state The view always carried status/active; the console froze its editor but the desktop painted a revoked grant fully live with a Revoke button. Now: list rows say Revoked, the detail header wears the pill, chips render muted read-only with a for-the-record note, and the revoke section only exists for a living grant. Co-Authored-By: Claude Fable 5 --- .../ConnectedApps/AgentListItem.tsx | 1 + frontend/src/pages/ConnectedAppDetailPage.tsx | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/ConnectedApps/AgentListItem.tsx b/frontend/src/components/ConnectedApps/AgentListItem.tsx index 1ee84ff03..c1d87dc07 100644 --- a/frontend/src/components/ConnectedApps/AgentListItem.tsx +++ b/frontend/src/components/ConnectedApps/AgentListItem.tsx @@ -26,6 +26,7 @@ export const AgentListItem: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) primary={name} secondary={ <> + {!agent.active ? 'Revoked · ' : ''} {areas.length ? `${areas.join(', ')} · ` : ''} {agent.lastUsedAt ? ( <> diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 74ed8c24c..faaa0b745 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -110,6 +110,9 @@ export const ConnectedAppDetailPage: React.FC = () => { {agent.appOrigin} ) : null} + {!agent.active ? ( + + ) : null} } @@ -119,10 +122,15 @@ export const ConnectedAppDetailPage: React.FC = () => { {allActions.length ? ( <> - {t('connectedAppDetailPage.editHint', { - name, - defaultValue: 'Granted when {{name}} signed in. Tap a permission to disable it — it stays listed so you can re-enable it later.', - })} + {agent.active + ? t('connectedAppDetailPage.editHint', { + name, + defaultValue: 'Granted when {{name}} signed in. Tap a permission to disable it — it stays listed so you can re-enable it later.', + }) + : t('connectedAppDetailPage.revokedHint', { + name, + defaultValue: 'This access was revoked — shown for the record. {{name}} can request access again by signing in.', + })} {(agent.groups ?? []).map((group, i) => { if (!group.actions.length) return null @@ -141,7 +149,7 @@ export const ConnectedAppDetailPage: React.FC = () => { key={action.key} size="small" clickable={agent.active} - color={on ? 'primary' : undefined} + color={on && agent.active ? 'primary' : undefined} variant={on ? 'filled' : 'outlined'} onClick={() => toggleAction(action.key)} label={!sharedLimit && action.limit ? `${action.label} (${action.limit})` : action.label} @@ -206,7 +214,7 @@ export const ConnectedAppDetailPage: React.FC = () => { key={sc} size="small" clickable={agent.active} - color={on ? 'primary' : undefined} + color={on && agent.active ? 'primary' : undefined} variant={on ? 'filled' : 'outlined'} onClick={() => toggleScope(sc)} label={sc} @@ -291,6 +299,8 @@ export const ConnectedAppDetailPage: React.FC = () => { ))} + {agent.active ? ( + <> {t('connectedAppDetailPage.revokeSection', 'Revoke access')} @@ -338,6 +348,8 @@ export const ConnectedAppDetailPage: React.FC = () => { }} /> + + ) : null} ) } From f01dee654b80a27e2f1ee1347b1c5b03a151d4cb Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 19:41:00 -0700 Subject: [PATCH 066/238] Connected Apps: the ACCOUNTS reach is editable within consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reach renders like the consent picker — labeled accounts (the RS's own delegation-options names), a master all-accounts chip when consent granted all — and edits ride the same Save. The bound is the consented ceiling: an all-ceiling admits any selection, a fixed set only its own ids; the AS refuses wider (reach_exceeds_consent). Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 73 ++++++++++++++++--- frontend/src/services/permitteerAccount.ts | 9 ++- types.d.ts | 11 ++- 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index faaa0b745..63c3923ff 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -34,6 +34,7 @@ export const ConnectedAppDetailPage: React.FC = () => { // listed, so anything unticked here can be re-ticked later. const [keepEdit, setKeepEdit] = useState | null>(null) const [scopeEdit, setScopeEdit] = useState | null>(null) + const [reachEdit, setReachEdit] = useState<{ all: boolean; ids: Set } | null>(null) const [saving, setSaving] = useState(false) useEffect(() => { @@ -73,9 +74,18 @@ export const ConnectedAppDetailPage: React.FC = () => { const allActions = (agent.groups ?? []).flatMap(g => g.actions) const kept = keepEdit ?? new Set(allActions.filter(a => a.enabled).map(a => a.key)) const scopesKept = scopeEdit ?? new Set(agent.scopes ?? []) + // The grant's reach (one scope constraint per grant; every scoped group carries the same) + const reachGroup = (agent.groups ?? []).find(gr => gr.reach)?.reach ?? null + const reachNow = reachEdit ?? (reachGroup ? { all: reachGroup.all, ids: new Set(reachGroup.accounts.map(a => a.id)) } : null) + const reachDirty = + reachEdit !== null && reachGroup !== null && + (reachEdit.all !== reachGroup.all || + reachEdit.ids.size !== reachGroup.accounts.length || + reachGroup.accounts.some(a => !reachEdit.ids.has(a.id))) const dirty = (keepEdit !== null && (keepEdit.size !== actions.length || actions.some(a => !keepEdit.has(a.key)))) || - (scopeEdit !== null && (scopeEdit.size !== (agent.scopes ?? []).length || (agent.scopes ?? []).some(sc => !scopeEdit.has(sc)))) + (scopeEdit !== null && (scopeEdit.size !== (agent.scopes ?? []).length || (agent.scopes ?? []).some(sc => !scopeEdit.has(sc)))) || + reachDirty const toggleAction = (key: string) => { if (!agent.active || saving) return const next = new Set(kept) @@ -88,12 +98,29 @@ export const ConnectedAppDetailPage: React.FC = () => { next.has(sc) ? next.delete(sc) : next.add(sc) setScopeEdit(next) } + const toggleReachAll = () => { + if (!agent.active || saving || !reachNow || !reachGroup?.ceilingAll) return + setReachEdit({ all: !reachNow.all, ids: new Set(reachNow.ids) }) + } + const toggleReachId = (id: string) => { + if (!agent.active || saving || !reachNow || reachNow.all) return + if (!reachGroup?.ceilingAll && !reachGroup?.ceilingIds.includes(id)) return + const ids = new Set(reachNow.ids) + ids.has(id) ? ids.delete(id) : ids.add(id) + setReachEdit({ all: false, ids }) + } const save = async () => { setSaving(true) - await updateAccountApp(agent.id, [...kept], [...scopesKept]) + await updateAccountApp( + agent.id, + [...kept], + [...scopesKept], + reachDirty && reachNow ? (reachNow.all ? { all: true } : { accounts: [...reachNow.ids] }) : undefined + ) await dispatch.agents.fetch() setKeepEdit(null) setScopeEdit(null) + setReachEdit(null) setSaving(false) } @@ -181,17 +208,45 @@ export const ConnectedAppDetailPage: React.FC = () => { ) : ( chips(group.actions) )} - {group.reach ? ( + {group.reach && reachNow ? ( {t('connectedAppDetailPage.accounts', 'Accounts')} - - {group.reach.all - ? t('connectedAppDetailPage.allAccounts', 'All accounts, including ones added later') - : group.reach.accounts.map(a => (a.filter ? `${a.id} (${a.filter})` : a.id)).join(' · ') || - t('connectedAppDetailPage.noAccounts', 'None')} - + + {group.reach.ceilingAll ? ( + + ) : null} + {[...new Set([ + ...(group.reach.options ?? []).map(o => o.id), + ...group.reach.accounts.map(a => a.id), + ...(!group.reach.ceilingAll ? group.reach.ceilingIds : []), + ])].map(id => { + const label = (group.reach!.options ?? []).find(o => o.id === id)?.label ?? id + const on = reachNow.all || reachNow.ids.has(id) + const editable = agent.active && !reachNow.all && (group.reach!.ceilingAll || group.reach!.ceilingIds.includes(id)) + return ( + toggleReachId(id)} + label={label} + sx={{ mr: 1, mb: 0.5, opacity: on ? (reachNow.all ? 0.7 : 1) : 0.6 }} + /> + ) + })} + ) : null} {sharedLimit ? ( diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index 292863977..c53dd0b30 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -39,10 +39,15 @@ export async function revokeAccountApp(grantId: string): Promise { +export async function updateAccountApp( + grantId: string, + keep: string[], + keepScope: string[], + reach?: { all?: boolean; accounts?: string[] } +): Promise { return await call(`/apps/${encodeURIComponent(grantId)}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ keep, keepScope }), + body: JSON.stringify({ keep, keepScope, ...(reach ? { reach } : {}) }), }) } diff --git a/types.d.ts b/types.d.ts index 9d8632c65..561b51ba1 100644 --- a/types.d.ts +++ b/types.d.ts @@ -835,8 +835,15 @@ declare global { resourceLabel?: string api?: string | null apiHost?: string | null - // consent's ACCOUNTS section replayed from the grant (ids only — names were picker data) - reach?: { all: boolean; accounts: { id: string; filter: string | null }[] } | null + // consent's ACCOUNTS section replayed from the grant — ACTIVE selection plus the + // consented ceiling (the edit bound) and the RS's labeled account list when it answers + reach?: { + all: boolean + accounts: { id: string; filter: string | null }[] + ceilingAll: boolean + ceilingIds: string[] + options: { id: string; label: string }[] | null + } | null actions: IGrantAction[] }[] // The SCOPE lane, read-only: APIs the client may bind whose granted scopes they declare From b9e39a7dfd21bddd29ee0e29d2a04ddb1c50ed3f Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 19:57:42 -0700 Subject: [PATCH 067/238] Deselecting 'all accounts' keeps today's accounts selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowing starts from full coverage instead of zero — matching the console's master-toggle behavior. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 63c3923ff..1277ca30c 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -100,7 +100,18 @@ export const ConnectedAppDetailPage: React.FC = () => { } const toggleReachAll = () => { if (!agent.active || saving || !reachNow || !reachGroup?.ceilingAll) return - setReachEdit({ all: !reachNow.all, ids: new Set(reachNow.ids) }) + if (reachNow.all) { + // Leaving all-mode keeps today's accounts selected — deselecting "all, including + // ones added later" narrows from full coverage, it doesn't strip everything. + const known = new Set([ + ...(reachGroup.options ?? []).map(o => o.id), + ...reachGroup.accounts.map(a => a.id), + ...reachNow.ids, + ]) + setReachEdit({ all: false, ids: known }) + } else { + setReachEdit({ all: true, ids: new Set(reachNow.ids) }) + } } const toggleReachId = (id: string) => { if (!agent.active || saving || !reachNow || reachNow.all) return From 2df010eb829363ce782c12dc71f69568eba2d5d0 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 20 Aug 2026 23:26:24 -0700 Subject: [PATCH 068/238] DPoP module (plan D9): non-extractable key, proofs on every token call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keypair generates non-extractable and lives as a CryptoKey in IndexedDB — usable under XSS, never exfiltratable. tokenRequest is the one chokepoint: every mint and refresh carries a proof once a key exists (per-mint opt-in binding for the desktop client; the portal client requires it). Bound audiences present via oidcAuthHeaders — DPoP scheme + ath proof; unbound stay Bearer, graphql stays Bearer until its RS enforces. Explicit sign-out rotates the key with the tokens; no WebCrypto degrades to bearer and lets the AS decide. Co-Authored-By: Claude Fable 5 --- frontend/src/services/oidc.ts | 106 ++++++++++++++++++++- frontend/src/services/passportSelf.ts | 15 +-- frontend/src/services/permitteerAccount.ts | 13 ++- 3 files changed, 119 insertions(+), 15 deletions(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 4b0fde35f..473d0b522 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -45,7 +45,7 @@ if (window.location.pathname === '/signoutCallback') { type Flow = { verifier: string; state: string; nonce: string; redirectUri: string } type Stored = { refresh_token: string; id_token?: string } -let access: { [resource: string]: { token: string; exp: number } } = {} +let access: { [resource: string]: { token: string; exp: number; type?: string } } = {} let refreshing: Promise | undefined let discovery: { authorization_endpoint: string; token_endpoint: string; end_session_endpoint?: string; end_session_api_endpoint?: string } | undefined @@ -157,7 +157,7 @@ export async function oidcCompleteFromUrl(): Promise { } persist({ refresh_token: body.refresh_token, id_token: body.id_token }) const at = decodeJwt(body.access_token) - access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0 } + access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return claims } @@ -184,7 +184,7 @@ async function refresh(resource: string): Promise { // Rotated — persist the successor FIRST, before anything can race another mint. persist({ refresh_token: body.refresh_token || current.refresh_token, id_token: body.id_token || current.id_token }) const at = decodeJwt(body.access_token) - access[resource] = { token: body.access_token, exp: at?.exp ?? 0 } + access[resource] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return body.access_token } catch (error: any) { console.error('OIDC REFRESH FAILED', error?.message) @@ -230,6 +230,7 @@ export function oidcClearLocal() { } function clearLocal() { + void clearDpopKey() access = {} localStorage.removeItem(TOKENS_KEY) } @@ -245,11 +246,108 @@ function cleanUrl() { window.history.replaceState({}, '', url.toString()) } +// --- DPoP (plan D9): sender-constrained tokens -------------------------------------- +// The key is generated NON-EXTRACTABLE and lives as a CryptoKey in IndexedDB: an XSS can +// use it while running in-page, but can never exfiltrate it — which is the entire browser +// story. Every /token call carries a proof once a key exists (per-mint opt-in binding for +// the desktop client; the portal client REQUIRES it), and bound audiences present with +// the DPoP scheme + an ath proof. No WebCrypto/IndexedDB → no proof → the AS decides +// (desktop falls back to bearer; the portal client refuses, loudly). +const DPOP_DB = 'remoteit-oidc' +const DPOP_STORE = 'keys' +let dpopPair: Promise | undefined + +function idb(): Promise { + return new Promise((resolve, reject) => { + const open = indexedDB.open(DPOP_DB, 1) + open.onupgradeneeded = () => open.result.createObjectStore(DPOP_STORE) + open.onsuccess = () => resolve(open.result) + open.onerror = () => reject(open.error) + }) +} +async function idbReq(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest): Promise { + const db = await idb() + return new Promise((resolve, reject) => { + const req = fn(db.transaction(DPOP_STORE, mode).objectStore(DPOP_STORE)) + req.onsuccess = () => resolve(req.result as T) + req.onerror = () => reject(req.error) + }) +} + +async function dpopKey(): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle || typeof indexedDB === 'undefined') return null + if (!dpopPair) + dpopPair = (async () => { + try { + const existing = await idbReq('readonly', s => s.get('dpop')) + if (existing?.privateKey) return existing + const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']) + await idbReq('readwrite', s => s.put(pair, 'dpop')) + return pair + } catch { + return null + } + })() + return dpopPair +} + +/** Explicit sign-out rotates the key with the tokens (key loss ≡ session loss anyway). */ +async function clearDpopKey(): Promise { + dpopPair = undefined + try { + await idbReq('readwrite', s => s.delete('dpop')) + } catch { + /* no store, nothing to clear */ + } +} + +const dpopB64u = (bytes: ArrayBuffer | Uint8Array) => { + const a = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + let out = '' + for (let i = 0; i < a.length; i++) out += String.fromCharCode(a[i]) + return btoa(out).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} +const utf8 = (s: string) => new TextEncoder().encode(s) + +async function dpopProof(htm: string, htu: string, accessToken?: string): Promise { + const pair = await dpopKey() + if (!pair) return null + const jwk = (await crypto.subtle.exportKey('jwk', pair.publicKey)) as { kty: string; crv?: string; x?: string; y?: string } + const u = new URL(htu) + const header = dpopB64u(utf8(JSON.stringify({ alg: 'ES256', typ: 'dpop+jwt', jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } }))) + const payload = dpopB64u( + utf8( + JSON.stringify({ + htm, + htu: u.origin + u.pathname, + iat: Math.floor(Date.now() / 1000), + jti: crypto.randomUUID(), + ...(accessToken ? { ath: dpopB64u(await crypto.subtle.digest('SHA-256', utf8(accessToken))) } : {}), + }) + ) + ) + const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, utf8(`${header}.${payload}`)) + return `${header}.${payload}.${dpopB64u(sig)}` +} + +/** Auth headers for an API call: the DPoP scheme + an ath proof when this audience's + * token came back bound, plain Bearer otherwise. {} when signed out. */ +export async function oidcAuthHeaders(method: string, url: string, resource: string = OAUTH_GRAPHQL_RESOURCE): Promise> { + const token = await oidcAccessToken(resource) + if (!token) return {} + if (access[resource]?.type === 'DPoP') { + const proof = await dpopProof(method, url, token) + if (proof) return { authorization: `DPoP ${token}`, DPoP: proof } + } + return { authorization: `Bearer ${token}` } +} + async function tokenRequest(params: { [key: string]: string }): Promise { const d = await discover() + const proof = await dpopProof('POST', d.token_endpoint) const response = await fetch(d.token_endpoint, { method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, + headers: { 'content-type': 'application/x-www-form-urlencoded', ...(proof ? { DPoP: proof } : {}) }, body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, ...params }), }) const body: any = await response.json().catch(() => ({})) diff --git a/frontend/src/services/passportSelf.ts b/frontend/src/services/passportSelf.ts index a748262ab..01e226ccb 100644 --- a/frontend/src/services/passportSelf.ts +++ b/frontend/src/services/passportSelf.ts @@ -1,4 +1,4 @@ -import { oidcAccessToken } from './oidc' +import { oidcAuthHeaders } from './oidc' import { OAUTH_PASSPORT_RESOURCE } from '../constants' /** @@ -26,11 +26,14 @@ export type SelfContinuation = { } const call = async (path: string, body?: Record): Promise => { - const token = await oidcAccessToken(OAUTH_PASSPORT_RESOURCE) - if (!token) return { httpStatus: 401, error: 'unauthorized' } - const response = await fetch(OAUTH_PASSPORT_RESOURCE + path, { - method: body ? 'POST' : 'GET', - headers: { authorization: 'Bearer ' + token, ...(body ? { 'content-type': 'application/json' } : {}) }, + const url = OAUTH_PASSPORT_RESOURCE + path + const method = body ? 'POST' : 'GET' + // Scheme-aware (plan D9): a DPoP-bound token presents as `DPoP` + an ath proof. + const auth = await oidcAuthHeaders(method, url, OAUTH_PASSPORT_RESOURCE) + if (!auth.authorization) return { httpStatus: 401, error: 'unauthorized' } + const response = await fetch(url, { + method, + headers: { ...auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }) const parsed = (await response.json().catch(() => ({}))) as SelfContinuation diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index c53dd0b30..3d635eadb 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -3,7 +3,7 @@ * the unit, and revoking it kills every refresh token minted from it. No graphql gateway: * the deleted Hydra façade is not coming back, and the AS view already carries names, * logos, per-action detail and honest revocation reach. */ -import { oidcAccessToken } from './oidc' +import { oidcAuthHeaders } from './oidc' import { OAUTH_ISSUER } from '../constants' const RESOURCE = `${OAUTH_ISSUER}/account/api` @@ -11,11 +11,14 @@ const RESOURCE = `${OAUTH_ISSUER}/account/api` export type AccountApiResult = { status: number; body?: T } async function call(path: string, init: RequestInit = {}): Promise> { - const token = await oidcAccessToken(RESOURCE) - if (!token) return { status: 401 } - const response = await fetch(`${RESOURCE}${path}`, { + const url = `${RESOURCE}${path}` + // Scheme-aware (plan D9): a DPoP-bound token presents as `DPoP` + an ath proof; an + // unbound one stays Bearer. The AS decides which we hold. + const auth = await oidcAuthHeaders(init.method ?? 'GET', url, RESOURCE) + if (!auth.authorization) return { status: 401 } + const response = await fetch(url, { ...init, - headers: { authorization: `Bearer ${token}`, ...(init.headers || {}) }, + headers: { ...auth, ...(init.headers || {}) }, }) let body: T | undefined try { From 0d90981207d82dc4df384d62991bcd7799b0b10c Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 16:25:46 -0700 Subject: [PATCH 069/238] AS-served stage switcher; events stream minted as its own audience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D10+D11a (permitteer docs/remoteit-desktop-login.md Phase 4c): - getToken() follows the switcher: the graphql token's audience IS the switched URL (getApiResource), so cross-stage testing mints for the target instead of replaying wrong-audience tokens into silent 403s. - cloudController mints the events audience for per-stage WS fronts (wss://ws..remote.it/v1 — the URL IS the resource identifier); legacy shared-domain URLs keep riding the graphql token through the authorizer's dual-accept window. - TestPage: the free-text switcher becomes a STAGE-PAIR picker fed by the AS's bindable-resources (allowlist ∩ registry — the picker and the mint guardrail share one source). One selection sets both URLs and mints both audiences immediately; invalid_target renders inline at selection. Free-text fields remain as an advanced escape, mint-checked on save. Selecting the default stage clears the override entirely, which retires the stale-localStorage re-enable trap (apis persists across logout by design — SAVED_ACROSS_LOGOUT). Co-Authored-By: Claude Fable 5 --- frontend/src/helpers/apiHelper.ts | 14 +++- frontend/src/pages/TestPage.tsx | 94 ++++++++++++++++++++-- frontend/src/services/cloudController.ts | 17 +++- frontend/src/services/permitteerAccount.ts | 8 ++ frontend/src/services/remoteit.ts | 11 ++- 5 files changed, 132 insertions(+), 12 deletions(-) diff --git a/frontend/src/helpers/apiHelper.ts b/frontend/src/helpers/apiHelper.ts index e93862e2a..1762d0481 100644 --- a/frontend/src/helpers/apiHelper.ts +++ b/frontend/src/helpers/apiHelper.ts @@ -1,4 +1,4 @@ -import { GRAPHQL_API, GRAPHQL_BETA_API, API_URL, WEBSOCKET_BETA_URL, WEBSOCKET_URL, TEST_HEADER } from '../constants' +import { GRAPHQL_API, GRAPHQL_BETA_API, API_URL, WEBSOCKET_BETA_URL, WEBSOCKET_URL, TEST_HEADER, OAUTH_GRAPHQL_RESOURCE } from '../constants' import { graphQLRentANode } from '../services/graphQLMutation' import { version } from './versionHelper' import { store } from '../store' @@ -15,6 +15,18 @@ export function getApiURL(): string | undefined { return apiGraphqlURL && switchApi ? apiGraphqlURL : defaultURL } +// D10 (permitteer docs/remoteit-desktop-login.md Phase 4c): the token's audience follows the +// switched URL — for the Permitteer-era fronts the graphql URL IS the resource identifier, so +// switching APIs means switching WHICH resource we mint for. Off-allowlist targets fail at +// MINT with a legible invalid_target instead of as ambient 403s an hour later. Only the +// switcher lane follows; the default lane stays pinned to the env's declared resource (the +// backend-override lane predates audience binding and never fed the token layer). +export function getApiResource(): string { + if (!store) return OAUTH_GRAPHQL_RESOURCE + const { apiGraphqlURL, switchApi } = store.getState().ui.apis + return switchApi && apiGraphqlURL ? apiGraphqlURL : OAUTH_GRAPHQL_RESOURCE +} + export function getRestApi(): string | undefined { try { if (!store) return API_URL diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 058db8070..5cb9a0321 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,10 +1,12 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import cloudSync from '../services/CloudSync' -import { TEST_HEADER } from '../constants' +import { TEST_HEADER, OAUTH_GRAPHQL_RESOURCE } from '../constants' import { Dispatch, State } from '../store' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL } from '../helpers/apiHelper' +import { bindableResources } from '../services/permitteerAccount' +import { oidcAccessToken } from '../services/oidc' import { selectLimitsLookup, selectLimits } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' @@ -31,6 +33,57 @@ export const TestPage: React.FC = () => { emit('preferences', { ...preferences, [key]: value }) } + // --- the stage-pair switcher (D10+D11a, permitteer docs/remoteit-desktop-login.md 4c) ---- + // The options come FROM the AS: the client's own allowlist joined to registry names, so the + // picker and the mint-time guardrail can never disagree. Identifiers group into stage pairs + // (graphql + events); one selection sets BOTH URLs and mints BOTH audiences immediately, so + // an illegal target fails here with a legible error, never as ambient 403s an hour later. + const [targets, setTargets] = useState>([]) + const [mintError, setMintError] = useState('') + useEffect(() => { + bindableResources().then(setTargets) + }, []) + + type StagePair = { stage: string; name: string; graphql?: string; ws?: string } + const stagePairs: StagePair[] = React.useMemo(() => { + const pairs = new Map() + for (const target of targets) { + const gql = target.identifier.match(/^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/) + const ws = target.identifier.match(/^wss:\/\/ws(?:\.([a-z0-9-]+))?\.remote\.it\/v1$/) + if (!gql && !ws) continue // passport / account-api entries are not switch targets + const stage = (gql?.[1] ?? ws?.[1]) || 'prod' + const pair = pairs.get(stage) || { stage, name: stage } + if (gql) { + pair.graphql = target.identifier + pair.name = target.name + } else pair.ws = target.identifier + pairs.set(stage, pair) + } + return [...pairs.values()].filter(pair => pair.graphql) + }, [targets]) + + async function selectStage(pair: StagePair) { + setMintError('') + const isDefault = pair.graphql === OAUTH_GRAPHQL_RESOURCE + const values = { + switchApi: !isDefault, + apiGraphqlURL: pair.graphql!, + ...(pair.ws ? { webSocketURL: pair.ws } : {}), + } + await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) + emit('preferences', { ...preferences, ...values }) + try { + if (!isDefault) { + await oidcAccessToken(pair.graphql!) + if (pair.ws) await oidcAccessToken(pair.ws) + } + emit('binaries/install') + cloudSync.all() + } catch (error) { + setMintError(error instanceof Error ? error.message : String(error)) + } + } + return ( { + {stagePairs.map(pair => ( + selectStage(pair)} + /> + ))} + {!!mintError && ( + + + {t('testPage.mintError', 'This target was refused at token mint: {{error}}', { + error: mintError, + })} + + + )} { - setAPIPreference('apiGraphqlURL', url) + onSave={async result => { + const url = result.toString() + setMintError('') + await setAPIPreference('apiGraphqlURL', url) + try { + if (apis.switchApi) await oidcAccessToken(url) + } catch (error) { + setMintError(error instanceof Error ? error.message : String(error)) + } emit('binaries/install') cloudSync.all() }} @@ -121,7 +203,7 @@ export const TestPage: React.FC = () => { /> .remote.it/v1; prod's bare wss://ws.remote.it/v1). The legacy +// shared-domain URL (wss://ws.remote.it/) is not a registered resource, so +// connections through it keep presenting the graphql-audience token — admitted by the +// authorizer's dual-accept window until that contract retires. +const EVENTS_RESOURCE = /^wss:\/\/ws(\.[a-z0-9-]+)?\.remote\.it\/v1$/ +async function wsAuthorization(): Promise { + const url = getWebSocketURL() || '' + if (!EVENTS_RESOURCE.test(url)) return await getToken() + const token = await oidcAccessToken(url) + return token ? 'Bearer ' + token : '' +} + class CloudController { initialized: boolean = false socket?: ReconnectingWebSocket @@ -149,7 +164,7 @@ class CloudController { // this flag continue to receive single-event frames. supportsBatch: true, headers: { - authorization: await getToken(), + authorization: await wsAuthorization(), 'User-Agent': `remoteit/${version} ${agent()}`, ...getTestHeader(), }, diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index 3d635eadb..d25207f1f 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -10,6 +10,14 @@ const RESOURCE = `${OAUTH_ISSUER}/account/api` export type AccountApiResult = { status: number; body?: T } +/** The legal token targets for THIS client — the AS's allowlist joined to registry names + * (D10). The stage picker and the mint-time guardrail read the SAME source, so they can + * never disagree; adding a stage to the tf allowlist puts it here on the next fetch. */ +export async function bindableResources(): Promise> { + const r = await call>('/bindable-resources') + return r.status === 200 && Array.isArray(r.body) ? r.body : [] +} + async function call(path: string, init: RequestInit = {}): Promise> { const url = `${RESOURCE}${path}` // Scheme-aware (plan D9): a DPoP-bound token presents as `DPoP` + an ath proof; an diff --git a/frontend/src/services/remoteit.ts b/frontend/src/services/remoteit.ts index 0a61b3df9..2767aea03 100644 --- a/frontend/src/services/remoteit.ts +++ b/frontend/src/services/remoteit.ts @@ -1,12 +1,15 @@ import { oidcAccessToken } from './oidc' +import { getApiResource } from '../helpers/apiHelper' /** - * The single token choke point every authenticated call flows through. The token is a - * Permitteer access token minted by the BACKEND process (which owns refresh + storage); - * this keeps the old contract — resolves to 'Bearer …' or '' (callers no-op on empty). + * The single token choke point every authenticated graphql call flows through. The token's + * audience FOLLOWS the switcher (D10, permitteer docs/remoteit-desktop-login.md Phase 4c): + * pointing the app at another stage mints for that stage instead of replaying a + * wrong-audience token into ambient 403s. Resolves to 'Bearer …' or '' (callers no-op on + * empty). */ export async function getToken(): Promise { - const token = await oidcAccessToken() + const token = await oidcAccessToken(getApiResource()) return token ? 'Bearer ' + token : '' } From cf684b46bf0cbd60c870178149f913cb32a78d8d Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 17:31:37 -0700 Subject: [PATCH 070/238] Test Settings: new i18n keys so the renamed switcher labels take effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t(key, 'default') only uses the default when the key is MISSING — the locale files define switchGraphQLAPIs/webSocketURL, so the renamed labels were silently ignored and the page looked untouched. New keys (customGraphQLURL / customWebSocketURL) plus en entries, which also keeps stale ja/de/es translations from misdescribing fields whose meaning changed from 'the switcher' to 'the advanced escape hatch'. Co-Authored-By: Claude Fable 5 --- frontend/src/i18n/locales/en/app.json | 3 +++ frontend/src/pages/TestPage.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index f57d6be6b..0abf9206f 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1979,6 +1979,8 @@ "addQueryHeaderPlaceholder": "Example: \"key:value\"", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", + "customGraphQLURL": "Custom GraphQL URL (advanced)", + "customWebSocketURL": "Custom WebSocket URL (advanced)", "disableTestUI": "Disable Test UI", "disableTestUIHint": "To re-enable the alpha UI you will have to select the Avatar menu while holding alt-shift.", "featureLabel": "{{name}} (default {{state}})", @@ -1986,6 +1988,7 @@ "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", "licensingOptions": "Licensing Options", + "mintError": "This target was refused at token mint: {{error}}", "overrideDefaultAPIs": "Override default APIs", "overrideLicensesAndLimits": "Override licenses and limits", "reset": "Reset", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 5cb9a0321..267fbe13d 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -183,7 +183,7 @@ export const TestPage: React.FC = () => { )} { /> Date: Fri, 21 Aug 2026 17:45:06 -0700 Subject: [PATCH 071/238] =?UTF-8?q?Stage=20targets=20are=20radios=20?= =?UTF-8?q?=E2=80=94=20they=20are=20mutually=20exclusive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggles implied independent on/off switches for what is a single choice of which stage the app points at. ListItemRadio is the repo's existing affordance for exactly this. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/TestPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 267fbe13d..d2a3ab452 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -11,6 +11,7 @@ import { selectLimitsLookup, selectLimits } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' import { ListItemSetting } from '../components/ListItemSetting' +import { ListItemRadio } from '../components/ListItemRadio' import { Container } from '../components/Container' import { PortalUI } from '../components/PortalUI' import { Title } from '../components/Title' @@ -160,12 +161,11 @@ export const TestPage: React.FC = () => { {stagePairs.map(pair => ( - Date: Fri, 21 Aug 2026 17:51:16 -0700 Subject: [PATCH 072/238] =?UTF-8?q?Drop=20the=20override=20switch=20?= =?UTF-8?q?=E2=80=94=20the=20radio=20choice=20already=20carries=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting the stage a build ships with IS 'override off', so the separate 'Override default APIs' toggle was a second control for state the radios own, and the two could disagree. switchApi is now DERIVED from the choice (it stays in preferences — the Electron backend reads it to configure the CLI binary). A 'Custom' radio completes the group so every reachable state is one of the options, and it gates the advanced URL fields that the toggle used to gate. Custom-ness is held in local state rather than inferred purely from the URL: a hand-typed URL may coincide with a registered stage, and the selection should not silently jump to that stage. Co-Authored-By: Claude Fable 5 --- frontend/src/i18n/locales/en/app.json | 2 + frontend/src/pages/TestPage.tsx | 81 ++++++++++++++++----------- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 0abf9206f..7e1c08884 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1979,6 +1979,8 @@ "addQueryHeaderPlaceholder": "Example: \"key:value\"", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", + "customAPITarget": "Custom", + "customAPITargetHint": "Point at a URL the authorization server has not registered.", "customGraphQLURL": "Custom GraphQL URL (advanced)", "customWebSocketURL": "Custom WebSocket URL (advanced)", "disableTestUI": "Disable Test UI", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index d2a3ab452..322348a84 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -63,8 +63,31 @@ export const TestPage: React.FC = () => { return [...pairs.values()].filter(pair => pair.graphql) }, [targets]) + // Which radio is lit. The override flag is DERIVED from the choice — selecting the stage + // this build ships with is the same thing the old "Override default APIs" switch expressed, + // so the switch is gone and `switchApi` (still read by the Electron backend to configure + // the CLI binary) is set from here. `customMode` is held locally because a hand-typed URL + // may coincide with a registered stage, and the choice should not silently jump to it. + const currentGraphql = apis.switchApi && apis.apiGraphqlURL ? apis.apiGraphqlURL : OAUTH_GRAPHQL_RESOURCE + const [customMode, setCustomMode] = useState(undefined) + const customSelected = + customMode ?? (!!apis.switchApi && stagePairs.length > 0 && !stagePairs.some(p => p.graphql === currentGraphql)) + + async function selectCustom() { + setMintError('') + setCustomMode(true) + const values = { + switchApi: true, + apiGraphqlURL: apis.apiGraphqlURL || getApiURL() || '', + webSocketURL: apis.webSocketURL || getWebSocketURL() || '', + } + await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) + emit('preferences', { ...preferences, ...values }) + } + async function selectStage(pair: StagePair) { setMintError('') + setCustomMode(false) const isDefault = pair.graphql === OAUTH_GRAPHQL_RESOURCE const values = { switchApi: !isDefault, @@ -148,43 +171,37 @@ export const TestPage: React.FC = () => { /> - { - setAPIPreference('switchApi', !apis.switchApi) - emit('binaries/install') - }} - toggle={!!apis.switchApi} + {stagePairs.map(pair => ( + selectStage(pair)} + /> + ))} + + {!!mintError && ( + + + {t('testPage.mintError', 'This target was refused at token mint: {{error}}', { + error: mintError, + })} + + + )} - {stagePairs.map(pair => ( - selectStage(pair)} - /> - ))} - {!!mintError && ( - - - {t('testPage.mintError', 'This target was refused at token mint: {{error}}', { - error: mintError, - })} - - - )} { @@ -192,7 +209,7 @@ export const TestPage: React.FC = () => { setMintError('') await setAPIPreference('apiGraphqlURL', url) try { - if (apis.switchApi) await oidcAccessToken(url) + await oidcAccessToken(url) } catch (error) { setMintError(error instanceof Error ? error.message : String(error)) } @@ -204,7 +221,7 @@ export const TestPage: React.FC = () => { { From abaeb1d1e06c8b81c9cf48ea4a35323cd2551f69 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 17:54:59 -0700 Subject: [PATCH 073/238] Give the stage picker its own 'API Target' section Removing the override toggle left the radios unlabelled inside Test Options. They are their own concern, so they get their own section header, parallel to Features and Licensing Options. Co-Authored-By: Claude Fable 5 --- frontend/src/i18n/locales/en/app.json | 1 + frontend/src/pages/TestPage.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 7e1c08884..28c6b31a5 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1977,6 +1977,7 @@ "testPage": { "addQueryHeader": "Add query header", "addQueryHeaderPlaceholder": "Example: \"key:value\"", + "apiTarget": "API Target", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", "customAPITarget": "Custom", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 322348a84..76be1d3ce 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -170,7 +170,10 @@ export const TestPage: React.FC = () => { hideIcon /> + + {t('testPage.apiTarget', 'API Target')} + {stagePairs.map(pair => ( Date: Fri, 21 Aug 2026 21:11:19 -0700 Subject: [PATCH 074/238] Windows gets the new-window auth browser too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sign-in journey opened a dedicated browser window only on macOS. It never BROKE elsewhere — off macOS the app-name lookup returned '', no flag matched, and the function fell through to shell.openExternal, the pre-polish behavior — but Windows kept the flash of unrelated content that the polish exists to avoid. Windows now resolves the default browser's real executable through the registry association chain (the https UserChoice ProgId, then that ProgId's shell-open command) and launches it with the new-window flag. getApplicationNameForProtocol is no help here: it returns a display name like 'Google Chrome', which is not launchable. The flag is chosen from the file name, not the full path, so a user folder called 'Edge' cannot decide it. No background-then-front counterpart: Windows governs foreground activation itself, so the window simply appears. Linux deliberately stays on the plain open — its default-browser lookup varies by desktop environment for the same modest gain. Every path still ends at openExternal, so an unrecognized browser, an unexpected registry shape, or a failed spawn costs the nicety and never the sign-in. Co-Authored-By: Claude Fable 5 --- electron/src/ElectronApp.ts | 84 ++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index bb7aeda02..4f339ff28 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -193,30 +193,82 @@ export default class ElectronApp { } } + /** The new-window flag for a browser named by app name (mac) or executable (Windows). + * Empty for Safari and anything unrecognized — those keep the plain open. */ + private newWindowFlag(browser: string) { + return /chrome|chromium|edge|brave|vivaldi|opera/i.test(browser) + ? '--new-window' + : /firefox/i.test(browser) + ? '-new-window' + : '' + } + + /** The default browser's EXECUTABLE on Windows, via the registry association chain: + * the user's https choice names a ProgId, and that ProgId's shell-open command holds + * the real path. Yields '' on anything unexpected — a missing UserChoice (no explicit + * default set), an unparsable command, a non-exe target — and every caller treats '' + * as "use the plain open". Two hops rather than getApplicationNameForProtocol because + * that returns a DISPLAY name here ("Google Chrome"), which is not launchable. */ + private windowsDefaultBrowser(done: (exe: string) => void) { + const association = + 'HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice' + execFile('reg', ['query', association, '/v', 'ProgId'], (error, stdout) => { + const progId = error ? undefined : /ProgId\s+REG_SZ\s+(\S+)/i.exec(stdout)?.[1] + if (!progId) return done('') + execFile('reg', ['query', `HKCR\\${progId}\\shell\\open\\command`, '/ve'], (commandError, commandOut) => { + const command = commandError ? undefined : /REG_SZ\s+(.+)/i.exec(commandOut)?.[1]?.trim() + if (!command) return done('') + // Either `"C:\...\chrome.exe" --single-argument %1` or a bare path plus switches. + const exe = command.startsWith('"') ? command.slice(1, command.indexOf('"', 1)) : command.split(/\s+/)[0] + done(/\.exe$/i.test(exe) ? exe : '') + }) + }) + } + /** The auth journey gets a NEW browser window. Plain openExternal fronts the browser * on whatever tab it already had — a flash of unrelated content before the sign-in * page. Chromium-family and Firefox take a new-window flag; Safari and unknown * browsers would need Apple-Events permission for the same, so they keep the plain * open. Regular external links (setWindowOpenHandler, deep-linked URLs) deliberately - * stay on openExternal — normal tab behavior is right for them. */ + * stay on openExternal — normal tab behavior is right for them. + * + * EVERY path falls back to openExternal, which is the pre-polish behavior and always + * correct — so an unrecognized browser, a registry shape we don't expect, or a failed + * spawn costs the nicety, never the sign-in. Linux stays on the plain open: its + * default-browser lookup varies by desktop environment for the same modest gain. */ private openAuthWindow(url: string) { - const name = environment.isMac ? this.app.getApplicationNameForProtocol('https://') : '' - const flag = /chrome|chromium|edge|brave|vivaldi|opera/i.test(name) ? '--new-window' : /firefox/i.test(name) ? '-new-window' : '' - if (!flag) { - electron.shell.openExternal(url) + const openPlainly = () => electron.shell.openExternal(url) + + if (environment.isMac) { + const name = this.app.getApplicationNameForProtocol('https://') + const flag = this.newWindowFlag(name) + if (!flag) return openPlainly() + // Two-step: create the window WITHOUT focus (-g), let the page load and paint out of + // sight, then front the browser — the user lands on a finished sign-in page instead + // of watching a window be born. The delay is a heuristic; there is no cross-process + // signal for the browser's paint. + execFile('open', ['-g', '-na', name, '--args', flag, url], error => { + if (error) return openPlainly() + setTimeout(() => execFile('open', ['-a', name], () => {}), 900) + }) return } - // Two-step: create the window WITHOUT focus (-g), let the page load and paint out of - // sight, then front the browser — the user lands on a finished sign-in page instead - // of watching a window be born. The delay is a heuristic; there is no cross-process - // signal for the browser's paint. - execFile('open', ['-g', '-na', name, '--args', flag, url], error => { - if (error) { - electron.shell.openExternal(url) - return - } - setTimeout(() => execFile('open', ['-a', name], () => {}), 900) - }) + + if (environment.isWindows) { + this.windowsDefaultBrowser(exe => { + // Match the FILE NAME, not the full path — a user folder called "Edge" should not + // decide which flag we pass. No background-then-front counterpart here: Windows + // governs foreground activation itself, so the window simply appears. + const flag = exe ? this.newWindowFlag(path.basename(exe)) : '' + if (!flag) return openPlainly() + execFile(exe, [flag, url], error => { + if (error) openPlainly() + }) + }) + return + } + + openPlainly() } private setDeepLink(url?: string) { From 2524250a19e04f79fa2b3dfb091dd9452d82b7ef Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 22:25:51 -0700 Subject: [PATCH 075/238] Connected Apps: the same two extensions, in step with the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the account console: offered permissions and out-of-consent accounts are selectable and marked (dashed chips), the pending addition is named above Save, and saving asks before it creates access. A 403 reauthentication_required sends the person through prompt=login rather than reporting a failure they cannot act on, and other failures now surface instead of vanishing — the save previously ignored its own result entirely. Cancel also clears the reach edit, which it had been leaving pending so the Save button never went away. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 90 +++++++++++++++++-- types.d.ts | 3 + 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 1277ca30c..cb8ab83cf 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -14,6 +14,7 @@ import { Icon } from '../components/Icon' import { Timestamp } from '../components/Timestamp' import { AgentAvatar } from '../components/ConnectedApps/AgentAvatar' import { enabledActions, revokeWindow } from '../components/ConnectedApps/helpers' +import { oidcStart } from '../services/oidc' import { updateAccountApp } from '../services/permitteerAccount' import { spacing } from '../styling' @@ -36,6 +37,7 @@ export const ConnectedAppDetailPage: React.FC = () => { const [scopeEdit, setScopeEdit] = useState | null>(null) const [reachEdit, setReachEdit] = useState<{ all: boolean; ids: Set } | null>(null) const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) useEffect(() => { dispatch.agents.init() @@ -115,19 +117,59 @@ export const ConnectedAppDetailPage: React.FC = () => { } const toggleReachId = (id: string) => { if (!agent.active || saving || !reachNow || reachNow.all) return - if (!reachGroup?.ceilingAll && !reachGroup?.ceilingIds.includes(id)) return + // An account outside this consent is selectable now: the app asked for account-scoped + // access and never named accounts, so choosing a different subset of your OWN accounts + // adds no capability it did not request. The server bounds it by what you may actually + // delegate today and asks for a recent sign-in before it lands. const ids = new Set(reachNow.ids) ids.has(id) ? ids.delete(id) : ids.add(id) setReachEdit({ all: false, ids }) } + // What this save would ADD beyond what was consented — an offered permission being taken + // up, or an account this grant never reached. Everything else on this page removes access; + // these are the only choices that create it, so they are named before they are made. + const adding = [ + ...allActions.filter(a => a.offered && kept.has(a.key)).map(a => a.label), + ...(reachGroup && reachNow && !reachNow.all + ? [...reachNow.ids] + .filter(id => !reachGroup.ceilingAll && !reachGroup.ceilingIds.includes(id)) + .map(id => (reachGroup.options ?? []).find(o => o.id === id)?.label ?? id) + : []), + ] + const save = async () => { + if (adding.length) { + const ok = window.confirm( + t('connectedAppDetailPage.confirmExtend', { + name, + list: adding.join(', '), + defaultValue: + 'Give {{name}} access it does not have yet?\n\nAdding: {{list}}\n\nYou may be asked to sign in again to confirm it is you.', + }) + ) + if (!ok) return + } setSaving(true) - await updateAccountApp( + const r = await updateAccountApp( agent.id, [...kept], [...scopesKept], reachDirty && reachNow ? (reachNow.all ? { all: true } : { accounts: [...reachNow.ids] }) : undefined ) + // The step-up: the session is live but not RECENT, and giving an app more than was + // approved needs proof it is you. Send them back through the login page with the choice + // still pending, rather than reporting a failure they cannot act on. + if (r.status === 403 && (r.body as any)?.error === 'reauthentication_required') { + setSaving(false) + await oidcStart({ prompt: 'login' }) + return + } + if (r.status >= 400) { + setSaving(false) + setError((r.body as any)?.error_description || t('connectedAppDetailPage.saveFailed', 'That change could not be saved.')) + return + } + setError(null) await dispatch.agents.fetch() setKeepEdit(null) setScopeEdit(null) @@ -182,6 +224,11 @@ export const ConnectedAppDetailPage: React.FC = () => { const chips = (actions: IGrantAction[]) => actions.map(action => { const on = kept.has(action.key) + // Three states: granted-and-on, granted-but-off, and ASKED FOR but never + // granted. The third is selectable because the app did request it and you + // did see it at consent — turning it on adds nothing it never asked for. + const offered = !!action.offered + const base = !sharedLimit && action.limit ? `${action.label} (${action.limit})` : action.label return ( { color={on && agent.active ? 'primary' : undefined} variant={on ? 'filled' : 'outlined'} onClick={() => toggleAction(action.key)} - label={!sharedLimit && action.limit ? `${action.label} (${action.limit})` : action.label} - title={action.description || undefined} - sx={{ mr: 1, mb: 0.5, opacity: on ? 1 : 0.6 }} + label={offered ? t('connectedAppDetailPage.notGranted', { label: base, defaultValue: '{{label}} — not granted' }) : base} + title={ + offered + ? t('connectedAppDetailPage.notGrantedHint', 'This app asked for this and you did not grant it. You can turn it on here.') + : action.description || undefined + } + sx={{ mr: 1, mb: 0.5, opacity: on ? 1 : 0.6, ...(offered ? { borderStyle: 'dashed' } : {}) }} /> ) }) @@ -243,7 +294,10 @@ export const ConnectedAppDetailPage: React.FC = () => { ])].map(id => { const label = (group.reach!.options ?? []).find(o => o.id === id)?.label ?? id const on = reachNow.all || reachNow.ids.has(id) - const editable = agent.active && !reachNow.all && (group.reach!.ceilingAll || group.reach!.ceilingIds.includes(id)) + const editable = agent.active && !reachNow.all + // Outside what was consented: still offerable, but say so — turning + // it on shares that account with this app for the first time. + const adding = !group.reach!.ceilingAll && !group.reach!.ceilingIds.includes(id) return ( { color={on && agent.active && !reachNow.all ? 'primary' : undefined} variant={on ? 'filled' : 'outlined'} onClick={() => toggleReachId(id)} - label={label} - sx={{ mr: 1, mb: 0.5, opacity: on ? (reachNow.all ? 0.7 : 1) : 0.6 }} + label={adding ? t('connectedAppDetailPage.addAccount', { label, defaultValue: '{{label}} — add' }) : label} + sx={{ mr: 1, mb: 0.5, opacity: on ? (reachNow.all ? 0.7 : 1) : 0.6, ...(adding ? { borderStyle: 'dashed' } : {}) }} /> ) })} @@ -290,12 +344,30 @@ export const ConnectedAppDetailPage: React.FC = () => { })} ) : null} + {error ? ( + + {error} + + ) : null} {dirty ? ( + {adding.length ? ( + + {t('connectedAppDetailPage.willAdd', { + list: adding.join(', '), + defaultValue: 'This gives the app access it does not have yet: {{list}}', + })} + + ) : null} - diff --git a/types.d.ts b/types.d.ts index 561b51ba1..98194d4cd 100644 --- a/types.d.ts +++ b/types.d.ts @@ -860,6 +860,9 @@ declare global { description: string | null limit: string | null enabled: boolean + // Asked for by the app at consent but never granted. Listed so it can be taken up here + // without the app running authorize again — it asked, and you saw it. + offered?: boolean orgLimited: boolean } From fe230b240e2429eee4d83f6ace1ac2c29a964e67 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 22:33:15 -0700 Subject: [PATCH 076/238] Heal a grant that predates a deployed slice, without a sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a slice to the client's declaration leaves already-signed-in installs short: their grant was written from the OLD request, and no refresh can widen it — refresh re-reads the grant, it never adds to it. Only a fresh authorize merges the new slice in, which for this first-party skipConsent client shows no consent screen. Until now the person just hit an unexplained 403 in whichever feature needed the slice, with signing out and back in as the only cure they could discover. The declaration becomes ONE list that both builds the authorize request and measures the tokens, so a slice cannot be requested-but-never-checked or the reverse. The check reads what the AS actually minted rather than a local version marker, so it is true by construction, and any mint failure reports nothing missing — a network blip must never start a sign-in. Exactly one attempt per app session: a client whose declaration outruns what the AS will grant would otherwise loop the person through the browser forever. Same loop-breaker as the console's renew marker. Cost: two extra token mints at boot for the passport and account-api audiences, which are cached and needed by those screens anyway. Co-Authored-By: Claude Fable 5 --- frontend/src/models/auth.ts | 38 ++++++++++++++++++++++++++--- frontend/src/services/oidc.ts | 46 ++++++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index a31ea4e47..7e1d58667 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,7 +9,7 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, OidcClaims } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, oidcGrantShortfall, OidcClaims } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' @@ -72,8 +72,10 @@ export default createModel()({ // token mint — a dead refresh family clears itself and we boot signed OUT // instead of rendering an authenticated shell over a corpse. const alive = await getToken() - if (alive) await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) - else invalidateOidcToken() + if (alive) { + await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) + await dispatch.auth.healGrant() + } else invalidateOidcToken() } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') } catch (error: any) { console.error('AUTH INIT: sign-in completion failed', error) @@ -83,6 +85,36 @@ export default createModel()({ dispatch.auth.set({ initialized: true }) console.log('AUTH INIT END') }, + /** A build that declares MORE than the standing grant carries (a slice added in a deploy, + * against an install that has not signed in since) heals itself: only a fresh authorize + * merges the new slice in, and for this first-party skipConsent client that shows no + * consent screen — a redirect chain back to the app. Without it the person hits an + * unexplained 403 in whichever feature needed the slice, and the only cure they could + * find is signing out and in again. + * + * ONE attempt per app session. If the re-authorize comes back still short — a client + * whose declaration outruns what the AS will grant it — a second try would return here + * and loop the person through the browser forever. Same loop-breaker the console's + * renew marker uses. */ + async healGrant() { + const ATTEMPTED = 'oidc.regrant' + try { + const missing = await oidcGrantShortfall() + if (!missing.length) { + window.sessionStorage.removeItem(ATTEMPTED) + return + } + if (window.sessionStorage.getItem(ATTEMPTED)) { + console.warn('AUTH: grant still short after re-authorizing; not retrying', { missing }) + return + } + console.log('AUTH: grant is missing declared access — re-authorizing', { missing }) + window.sessionStorage.setItem(ATTEMPTED, '1') + await oidcStart({}) + } catch (error) { + console.warn('AUTH: grant heal check failed (leaving the session as it is)', error) + } + }, // Leave for the AS (the whole login UX — email-first, org SSO, MFA, signup, forgot — // lives there). On web the page departs; on desktop the window shows the waiting // panel until the deep link reloads it with the code. diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 473d0b522..f4a21017a 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -1,5 +1,5 @@ import browser from './browser' -import { OAUTH_ISSUER, OAUTH_CLIENT_ID, OAUTH_GRAPHQL_RESOURCE, PROTOCOL } from '../constants' +import { OAUTH_ISSUER, OAUTH_CLIENT_ID, OAUTH_GRAPHQL_RESOURCE, OAUTH_PASSPORT_RESOURCE, PROTOCOL } from '../constants' /** * The renderer-owned OIDC client (permitteer docs/remoteit-desktop-login.md, D8): @@ -86,6 +86,44 @@ const redirectUri = () => /** Leave for the AS. On web the page departs; on desktop the main process bounces the * issuer origin to the system browser and the window stays on the waiting panel. */ +/** What this build asks for, per audience. ONE source of truth: the authorize request is + * built from it AND the boot check measures tokens against it, so a slice added in a deploy + * cannot end up requested-but-never-checked (or checked-but-never-requested). + * `passport_account` gates the native security settings; `permitteer_account` is Connected + * Apps against the AS's own account API (plan D6) — list + revoke. The graphql audience + * stays pure scope-`full` and carries no details, so it is not listed here. */ +const DECLARED: Array<{ resource: string; type: string; actions: string[] }> = [ + { resource: OAUTH_PASSPORT_RESOURCE, type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, + { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write'] }, +] + +/** Declared actions the standing grant does NOT carry. A deploy that adds a slice leaves + * already-signed-in installs short: their grant was written from the OLD request, and no + * refresh can widen it — refresh re-reads the grant, it never adds to it. Only a fresh + * authorize merges the new slice in (silently, for a skipConsent first-party client). + * + * Measured from the tokens themselves rather than a local version marker, so it is true by + * construction: whatever the AS actually minted is what we compare. Any mint failure yields + * NOTHING missing — a network blip must not trigger a sign-in journey. */ +export async function oidcGrantShortfall(): Promise { + const missing: string[] = [] + for (const decl of DECLARED) { + let held: string[] = [] + try { + const token = await oidcAccessToken(decl.resource) + if (!token) return [] + const details = decodeJwt(token)?.authorization_details + held = Array.isArray(details) + ? details.filter((d: any) => d?.type === decl.type).flatMap((d: any) => d?.actions ?? []) + : [] + } catch { + return [] + } + for (const action of decl.actions) if (!held.includes(action)) missing.push(`${decl.type}:${action}`) + } + return missing +} + export async function oidcStart(opts: { prompt?: 'login' | 'select_account' } = {}): Promise { const d = await discover() const verifier = randomB64u(48) @@ -104,11 +142,7 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account' } = // the passport-audience token minted later via refresh carries this slice, gating the // native security settings (credentials.write); the graphql audience stays pure // scope-`full` (an uncovered resource yields audience-only tokens). - authorization_details: JSON.stringify([ - { type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, - // Connected Apps rides the AS's own account API (plan D6) — list + revoke only. - { type: 'permitteer_account', actions: ['apps.read', 'apps.write'] }, - ]), + authorization_details: JSON.stringify(DECLARED.map(d => ({ type: d.type, actions: d.actions }))), state: flow.state, nonce: flow.nonce, } From 2b4c46e853fc1f42e3c291b95c2a68479fad841c Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 22:44:26 -0700 Subject: [PATCH 077/238] Detect a stale grant by fingerprint, not by minting tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot check cost two token mints for every user on every boot, to detect something that happens about once per deploy — and it leaned on a token-walking comparison whose failure mode was bouncing everyone through a browser. The declaration now carries a stable fingerprint, stamped at the moment a completed authorize writes the grant from it. Boot compares two strings: no network, and nothing a comparison bug can turn into a sign-in loop for people whose grant is fine. It is a claim rather than proof, which is why it is only acceptable here: being wrong costs one silent re-authorize. An install with no stamp — cleared storage, or signed in before this shipped — heals once and then matches, which is also how everyone currently signed in picks up the current declaration. Sign-out clears it with the tokens. What it deliberately cannot see is a grant narrowed on the server; that needs a different signal, not attempted here. Co-Authored-By: Claude Fable 5 --- frontend/src/models/auth.ts | 9 +++---- frontend/src/services/oidc.ts | 51 +++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 7e1d58667..cdc59334b 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,7 +9,7 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, oidcGrantShortfall, OidcClaims } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, oidcGrantStale, OidcClaims } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' @@ -99,16 +99,15 @@ export default createModel()({ async healGrant() { const ATTEMPTED = 'oidc.regrant' try { - const missing = await oidcGrantShortfall() - if (!missing.length) { + if (!oidcGrantStale()) { window.sessionStorage.removeItem(ATTEMPTED) return } if (window.sessionStorage.getItem(ATTEMPTED)) { - console.warn('AUTH: grant still short after re-authorizing; not retrying', { missing }) + console.warn('AUTH: grant still stale after re-authorizing; not retrying this session') return } - console.log('AUTH: grant is missing declared access — re-authorizing', { missing }) + console.log('AUTH: grant predates this build’s declaration — re-authorizing') window.sessionStorage.setItem(ATTEMPTED, '1') await oidcStart({}) } catch (error) { diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index f4a21017a..4cb8f725b 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -27,6 +27,8 @@ export type OidcClaims = { const FLOW_KEY = 'oidc.flow' const TOKENS_KEY = 'oidc.tokens' +// What the grant behind those tokens was last written from (see oidcGrantStale). +const DECLARATION_KEY = 'oidc.declaration' // A boot on /signoutCallback is the RETURN from an explicit sign-out: the next authorize // must show the LOGIN PAGE (prompt=login), never silently SSO into another account's @@ -97,31 +99,30 @@ const DECLARED: Array<{ resource: string; type: string; actions: string[] }> = [ { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write'] }, ] -/** Declared actions the standing grant does NOT carry. A deploy that adds a slice leaves - * already-signed-in installs short: their grant was written from the OLD request, and no - * refresh can widen it — refresh re-reads the grant, it never adds to it. Only a fresh - * authorize merges the new slice in (silently, for a skipConsent first-party client). +/** A stable fingerprint of what this build asks for. Order-insensitive, so reshuffling the + * list is not a change; adding, dropping or renaming an action is. */ +const declarationFingerprint = () => + DECLARED.map(d => `${d.resource}=${d.type}:${[...d.actions].sort().join(',')}`) + .sort() + .join('|') + +/** Does the standing grant predate what this build asks for? A deploy that adds a slice + * leaves already-signed-in installs short: their grant was written from the OLD request, and + * no refresh can widen it — refresh re-reads the grant, it never adds to it. Only a fresh + * authorize merges the new slice in, silently for a skipConsent first-party client. * - * Measured from the tokens themselves rather than a local version marker, so it is true by - * construction: whatever the AS actually minted is what we compare. Any mint failure yields - * NOTHING missing — a network blip must not trigger a sign-in journey. */ -export async function oidcGrantShortfall(): Promise { - const missing: string[] = [] - for (const decl of DECLARED) { - let held: string[] = [] - try { - const token = await oidcAccessToken(decl.resource) - if (!token) return [] - const details = decodeJwt(token)?.authorization_details - held = Array.isArray(details) - ? details.filter((d: any) => d?.type === decl.type).flatMap((d: any) => d?.actions ?? []) - : [] - } catch { - return [] - } - for (const action of decl.actions) if (!held.includes(action)) missing.push(`${decl.type}:${action}`) + * Answered from a fingerprint stamped at the last completed authorize — the one moment we + * know the grant was written from a particular declaration — so the check costs nothing and + * cannot mistake a network problem for a missing permission. It is a CLAIM rather than + * proof, which is acceptable only because being wrong costs one silent re-authorize: an + * install with no stamp (cleared storage, or signed in before this existed) heals once and + * then matches. What it deliberately cannot see is a grant narrowed on the server. */ +export function oidcGrantStale(): boolean { + try { + return localStorage.getItem(DECLARATION_KEY) !== declarationFingerprint() + } catch { + return false } - return missing } export async function oidcStart(opts: { prompt?: 'login' | 'select_account' } = {}): Promise { @@ -190,6 +191,9 @@ export async function oidcCompleteFromUrl(): Promise { }).catch(() => {}) } persist({ refresh_token: body.refresh_token, id_token: body.id_token }) + // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant + // is merged from exactly that — so the grant now covers this build. Stamp it. + try { localStorage.setItem(DECLARATION_KEY, declarationFingerprint()) } catch { /* non-fatal */ } const at = decodeJwt(body.access_token) access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return claims @@ -267,6 +271,7 @@ function clearLocal() { void clearDpopKey() access = {} localStorage.removeItem(TOKENS_KEY) + localStorage.removeItem(DECLARATION_KEY) } function persist(tokens: Stored) { From a984d024512b6d769e83729897ac2ff51de60f24 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 23:36:29 -0700 Subject: [PATCH 078/238] Connected Apps: offer all-accounts when the AS says it is on offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the console: the chip renders whenever reach.offerAll is set, not only where the grant already had it, dashed and marked as an addition — and the confirmation names what it means (every account, including ones you join later) rather than listing it as another account. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 16 ++++++++++++---- types.d.ts | 3 +++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index cb8ab83cf..54fa4d2b7 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -101,7 +101,8 @@ export const ConnectedAppDetailPage: React.FC = () => { setScopeEdit(next) } const toggleReachAll = () => { - if (!agent.active || saving || !reachNow || !reachGroup?.ceilingAll) return + // Offered wherever consent would have offered it, not only where it was accepted then. + if (!agent.active || saving || !reachNow || !(reachGroup?.ceilingAll || reachGroup?.offerAll)) return if (reachNow.all) { // Leaving all-mode keeps today's accounts selected — deselecting "all, including // ones added later" narrows from full coverage, it doesn't strip everything. @@ -130,6 +131,9 @@ export const ConnectedAppDetailPage: React.FC = () => { // these are the only choices that create it, so they are named before they are made. const adding = [ ...allActions.filter(a => a.offered && kept.has(a.key)).map(a => a.label), + ...(reachGroup && reachNow?.all && !reachGroup.ceilingAll + ? [t('connectedAppDetailPage.allAccountsPlain', 'every account, including ones you join later')] + : []), ...(reachGroup && reachNow && !reachNow.all ? [...reachNow.ids] .filter(id => !reachGroup.ceilingAll && !reachGroup.ceilingIds.includes(id)) @@ -276,15 +280,19 @@ export const ConnectedAppDetailPage: React.FC = () => { {t('connectedAppDetailPage.accounts', 'Accounts')} - {group.reach.ceilingAll ? ( + {group.reach.ceilingAll || group.reach.offerAll ? ( ) : null} {[...new Set([ diff --git a/types.d.ts b/types.d.ts index 98194d4cd..c23a99212 100644 --- a/types.d.ts +++ b/types.d.ts @@ -841,6 +841,9 @@ declare global { all: boolean accounts: { id: string; filter: string | null }[] ceilingAll: boolean + // "All accounts" is still on offer (consent would have offered it), even if this grant + // never took it — so the editor can present it as an addition. + offerAll?: boolean ceilingIds: string[] options: { id: string; label: string }[] | null } | null From 9e9a250dccdef37864ff3f286e737575d34264b2 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 21 Aug 2026 23:46:22 -0700 Subject: [PATCH 079/238] Name the account when stepping up oidcStart takes a loginHint, and the Connected Apps step-up passes the signed-in address. Without it prompt=login lands on the account picker, where choosing your own account returns you to the same page. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 6 ++++-- frontend/src/services/oidc.ts | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 54fa4d2b7..67b94ffe2 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -14,7 +14,7 @@ import { Icon } from '../components/Icon' import { Timestamp } from '../components/Timestamp' import { AgentAvatar } from '../components/ConnectedApps/AgentAvatar' import { enabledActions, revokeWindow } from '../components/ConnectedApps/helpers' -import { oidcStart } from '../services/oidc' +import { oidcStart, oidcClaims } from '../services/oidc' import { updateAccountApp } from '../services/permitteerAccount' import { spacing } from '../styling' @@ -165,7 +165,9 @@ export const ConnectedAppDetailPage: React.FC = () => { // still pending, rather than reporting a failure they cannot act on. if (r.status === 403 && (r.body as any)?.error === 'reauthentication_required') { setSaving(false) - await oidcStart({ prompt: 'login' }) + // Name the account so the AS asks "is it you" instead of "who are you" — this is a + // step-up on the session we already hold, never an invitation to switch accounts. + await oidcStart({ prompt: 'login', loginHint: oidcClaims()?.email }) return } if (r.status >= 400) { diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 4cb8f725b..6eaa9c7c9 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -125,7 +125,7 @@ export function oidcGrantStale(): boolean { } } -export async function oidcStart(opts: { prompt?: 'login' | 'select_account' } = {}): Promise { +export async function oidcStart(opts: { prompt?: 'login' | 'select_account'; loginHint?: string } = {}): Promise { const d = await discover() const verifier = randomB64u(48) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) @@ -147,6 +147,10 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account' } = state: flow.state, nonce: flow.nonce, } + // Naming WHO is signing in turns a step-up into "confirm it's you" rather than an account + // chooser — without it, prompt=login lands on the picker and choosing your own account + // simply returns you to the same page, which reads as a loop. + if (opts.loginHint) params.login_hint = opts.loginHint if (opts.prompt) { params.prompt = opts.prompt } else if (promptLogin) { From 03d106cbd78b984f6b06407e9e721d91fb5de59e Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sat, 22 Aug 2026 09:59:25 -0700 Subject: [PATCH 080/238] Drop the sign-in warning from the extend confirmation Editing a grant no longer step-ups, so the copy promised a journey that will not happen. The confirmation still names exactly what is being added. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/ConnectedAppDetailPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 67b94ffe2..b31610618 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -147,8 +147,7 @@ export const ConnectedAppDetailPage: React.FC = () => { t('connectedAppDetailPage.confirmExtend', { name, list: adding.join(', '), - defaultValue: - 'Give {{name}} access it does not have yet?\n\nAdding: {{list}}\n\nYou may be asked to sign in again to confirm it is you.', + defaultValue: 'Give {{name}} access it does not have yet?\n\nAdding: {{list}}', }) ) if (!ok) return From 8cc250380f9e701832a990c6876c54a98cfd54b6 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 15:04:55 -0700 Subject: [PATCH 081/238] View-as deep-links to the operator console (Phase 4d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eye button keeps its seat and its meaning — one click, see the product as this user — and stops pretending the header lane exists: on Permitteer tokens X-R3-User refuses by design (no delegate scope is minted). It now opens the operator console's user page, whose 'open as user' mints a 15-minute support session behind the mint's own gates: kill-switch, operator roster, an MFA-carrying sign-in fresher than ten minutes. Act-stamped, audited, and — unlike the header it replaces — visible to the user, who can end it. The subject id is the r3 user id on this lane, so the deep link is direct. Plan: permitteer docs/remoteit-desktop-login.md Phase 4d. Co-Authored-By: Claude Fable 5 --- .../AdminUsersPage/AdminUserDetailPage.tsx | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index 34f2f8f8c..b44c3b474 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { useHistory, useParams } from 'react-router-dom' +import { useParams } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' import { Typography, List, ListItemText, Box, Divider } from '@mui/material' import { Container } from '../../components/Container' @@ -10,14 +10,12 @@ import { Body } from '../../components/Body' import { LoadingMessage } from '../../components/LoadingMessage' import { IconButton } from '../../buttons/IconButton' import { spacing } from '../../styling' -import { PORTAL_URL } from '../../constants' +import { OAUTH_ISSUER } from '../../constants' import { Dispatch, State } from '../../store' -import browser from '../../services/browser' import { windowOpen } from '../../services/browser' export const AdminUserDetailPage: React.FC = () => { const { userId } = useParams<{ userId: string }>() - const history = useHistory() const dispatch = useDispatch() const user = useSelector((state: State) => state.adminUsers.detailCache[userId]) const [loading, setLoading] = useState(!user) @@ -59,17 +57,13 @@ export const AdminUserDetailPage: React.FC = () => { const deviceOnline = user.info?.devices?.online || 0 const handleViewAsUser = () => { - const viewAs = `/devices?viewAs=${user.id},${encodeURIComponent(user.email || '')}` - // The desktop app shows local backend data, so view as has to run in the web portal - if (browser.isElectron) { - windowOpen(`${PORTAL_URL}/#${viewAs}`, '_blank', true) - return - } - if (browser.isMobile) { - history.push(viewAs) - return - } - windowOpen(`${window.location.href.split('#')[0]}#${viewAs}`, '_blank') + // Permitteer lane: view-as is a SUPPORT SESSION, not a header (docs/remoteit-desktop- + // login.md Phase 4d). The X-R3-User lane is deliberately dead for these tokens (no + // `delegate` scope is minted), so the eye button deep-links to the operator console's + // user page, whose "open as user" mints the launch behind its own gates: + // kill-switch, operator roster, an MFA-carrying sign-in fresher than ten minutes. + // The subject id IS the r3 user id on this lane (the authorizer maps sub → userId 1:1). + windowOpen(`${OAUTH_ISSUER}/admin/console/users/${user.id}`, '_blank', true) } return ( From 0d1f732b989f26d0fc3625aecf9ac304760b8c2b Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 16:21:46 -0700 Subject: [PATCH 082/238] =?UTF-8?q?View-as=20deep-links=20by=20EMAIL=20?= =?UTF-8?q?=E2=80=94=20the=20key=20both=20worlds=20share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first wiring linked by r3 user id and landed on 'unknown user': permitteer subjects are sub_, joined to r3 users by email. The console resolves an emailed deep link through its search (one match opens; else honestly unknown). Co-Authored-By: Claude Fable 5 --- frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index b44c3b474..c2f470927 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -62,8 +62,10 @@ export const AdminUserDetailPage: React.FC = () => { // `delegate` scope is minted), so the eye button deep-links to the operator console's // user page, whose "open as user" mints the launch behind its own gates: // kill-switch, operator roster, an MFA-carrying sign-in fresher than ten minutes. - // The subject id IS the r3 user id on this lane (the authorizer maps sub → userId 1:1). - windowOpen(`${OAUTH_ISSUER}/admin/console/users/${user.id}`, '_blank', true) + // The EMAIL is the key both worlds share: permitteer subjects are sub_, not r3 + // GUIDs — the authorizer joins them by email — and the console resolves an unknown + // deep-link id through its user search (one match opens; else honestly unknown). + windowOpen(`${OAUTH_ISSUER}/admin/console/users/${encodeURIComponent(user.email || user.id)}`, '_blank', true) } return ( From d9723df0e5cb0832c10b95d8dc92db1eebeb9d48 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 16:36:08 -0700 Subject: [PATCH 083/238] The portal drops its tokens on a support-session launch (4d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boot carrying ?support_session=1 is permitteer's impersonation launch: an operator just became someone else in the AS cookie, and this app's stored tokens still belong to the operator — the first live test showed exactly that. Clear local state, let the ordinary signed-out boot run its authorize, and the silent SSO inherits the now-active impersonated session. Deliberately not prompt=login — inheriting that session is the point. Co-Authored-By: Claude Fable 5 --- frontend/src/services/oidc.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 6eaa9c7c9..95d234120 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -44,6 +44,20 @@ if (window.location.pathname === '/signoutCallback') { window.history.replaceState({}, '', window.location.origin + '/') } +// A boot carrying ?support_session=1 is a SUPPORT-SESSION LAUNCH (permitteer's +// /impersonate/launch, docs/remoteit-desktop-login.md Phase 4d): an operator just became +// someone else in the AS's cookie, and THIS app's stored tokens still belong to the +// operator — shadow auth that would silently show the wrong account, which is exactly what +// the first live test did. Drop local state and let the ordinary signed-out boot run its +// authorize: the silent SSO lands on the ACTIVE session, which the launch just made the +// impersonated one. Deliberately NOT prompt=login — inheriting that session is the point. +if (new URLSearchParams(window.location.search).has('support_session')) { + clearLocal() + const clean = new URL(window.location.href) + clean.searchParams.delete('support_session') + window.history.replaceState({}, '', clean.toString()) +} + type Flow = { verifier: string; state: string; nonce: string; redirectUri: string } type Stored = { refresh_token: string; id_token?: string } From 4b094cffaf708bb90395e438dfac59d0ca6e30d1 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 16:47:29 -0700 Subject: [PATCH 084/238] The support-session hook must not reach below itself (TDZ crash on dev) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot hook ran at module evaluation and called clearLocal(), which touches declared sixty lines further down — a temporal- dead-zone ReferenceError that killed the entire bundle: eternal splash, param never stripped, exactly what dev showed. The hook now inlines the two storage removals whose const keys are declared above it, and leaves the DPoP key alone — it is the app's own key, and the impersonated session's tokens bind to it like any other boot's. Co-Authored-By: Claude Fable 5 --- frontend/src/services/oidc.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 95d234120..9ce3b83c7 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -52,7 +52,15 @@ if (window.location.pathname === '/signoutCallback') { // authorize: the silent SSO lands on the ACTIVE session, which the launch just made the // impersonated one. Deliberately NOT prompt=login — inheriting that session is the point. if (new URLSearchParams(window.location.search).has('support_session')) { - clearLocal() + // Inline removals, NOT clearLocal(): this runs at module evaluation, and clearLocal + // touches `let access` declared BELOW — a TDZ ReferenceError here killed the whole + // bundle on dev (eternal splash, param never stripped). Only the hoisted consts above + // are safe to reach from module scope. The DPoP key stays: it is the APP's key, and the + // impersonated session's tokens bind to it exactly as any other boot's would. + try { + localStorage.removeItem(TOKENS_KEY) + localStorage.removeItem(DECLARATION_KEY) + } catch { /* a blocked storage API must not kill the boot */ } const clean = new URL(window.location.href) clean.searchParams.delete('support_session') window.history.replaceState({}, '', clean.toString()) From 9524e7dd5ecbbd56b59d02b53aa9c71e6b65894b Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 17:00:07 -0700 Subject: [PATCH 085/238] =?UTF-8?q?The=20support=20banner=20is=20back=20?= =?UTF-8?q?=E2=80=94=20fed=20by=20the=20token,=20not=20app=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old bar rode ui.viewAsUser, set by the header lane this branch retired. The support session needs no app state at all: permitteer stamps act (the operator) into every token of an impersonated session, now the id_token included — so the banner reads the one signal that cannot drift from what the session actually is. Legacy viewAsUser keeps its lane; a support session shows its own line and the same exit. Co-Authored-By: Claude Fable 5 --- frontend/src/components/ViewAsBanner.tsx | 15 +++++++++++++-- frontend/src/services/oidc.ts | 4 ++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ViewAsBanner.tsx b/frontend/src/components/ViewAsBanner.tsx index 39ec78e8b..b6433da3f 100644 --- a/frontend/src/components/ViewAsBanner.tsx +++ b/frontend/src/components/ViewAsBanner.tsx @@ -4,13 +4,22 @@ import { useSelector, useDispatch } from 'react-redux' import { Box, Typography, IconButton } from '@mui/material' import { State, Dispatch } from '../store' import { Icon } from './Icon' +import { oidcActor } from '../services/oidc' export const ViewAsBanner: React.FC = () => { const { t } = useTranslation() const viewAsUser = useSelector((state: State) => state.ui.viewAsUser) + const user = useSelector((state: State) => state.auth.user) const dispatch = useDispatch() - if (!viewAsUser) return null + // Two lanes light this banner. The legacy header lane sets ui.viewAsUser. A SUPPORT + // SESSION (permitteer impersonation) needs no app state at all: the id_token itself says + // the identity is acted (`act` names the operator), so the banner reads the token — the + // one signal that cannot drift from what the session actually is. + const actor = oidcActor() + const supportSession = !viewAsUser && !!actor && !!user + if (!viewAsUser && !supportSession) return null + const email = viewAsUser?.email || user?.email || '' const handleExit = () => { // Clear from sessionStorage @@ -36,7 +45,9 @@ export const ViewAsBanner: React.FC = () => { }} > - {t('viewAsBanner.viewingAs', { email: viewAsUser.email, defaultValue: 'Viewing as: {{email}}' })} + {supportSession + ? t('viewAsBanner.supportSession', { email, defaultValue: 'Support session — viewing as {{email}}. Tokens are stamped with your identity; the user can see and end this session.' }) + : t('viewAsBanner.viewingAs', { email, defaultValue: 'Viewing as: {{email}}' })} { export const oidcConfigured = () => !!OAUTH_ISSUER export const oidcSignedIn = () => !!stored()?.refresh_token export const oidcClaims = (): OidcClaims | undefined => decodeJwt(stored()?.id_token) +/** The support-session marker: permitteer stamps `act` (the OPERATOR acting as this + * subject) into every token of an impersonated session, the id_token included — the + * app-readable artifact. Null on an ordinary session. */ +export const oidcActor = (): { sub: string } | null => decodeJwt(stored()?.id_token)?.act ?? null async function discover() { if (discovery) return discovery From 544298bc5b9bd26783817ae56465d5c1b2dde6a6 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 17:08:45 -0700 Subject: [PATCH 086/238] A support session is tab-scoped: sessionStorage, never the shared store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localStorage is origin-wide: the support tab's impersonated tokens replaced the operator's own, so refreshing their normally-signed-in tab silently became the support session. The support tab now keeps its tokens in sessionStorage — per-tab, dying with it — and never touches the shared store at all: the operator's tabs keep their tokens untouched (isolation beats the clearing it replaces), and the support tab still boots token-less into the silent authorize that inherits the impersonated session. Every TOKENS/DECLARATION read and write routes through one tokenStore() accessor keyed on a per-tab flag. Co-Authored-By: Claude Fable 5 --- frontend/src/services/oidc.ts | 43 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index a6c5b95a2..b3bf8c594 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -44,27 +44,26 @@ if (window.location.pathname === '/signoutCallback') { window.history.replaceState({}, '', window.location.origin + '/') } -// A boot carrying ?support_session=1 is a SUPPORT-SESSION LAUNCH (permitteer's -// /impersonate/launch, docs/remoteit-desktop-login.md Phase 4d): an operator just became -// someone else in the AS's cookie, and THIS app's stored tokens still belong to the -// operator — shadow auth that would silently show the wrong account, which is exactly what -// the first live test did. Drop local state and let the ordinary signed-out boot run its -// authorize: the silent SSO lands on the ACTIVE session, which the launch just made the -// impersonated one. Deliberately NOT prompt=login — inheriting that session is the point. +// A support TAB keeps its tokens in sessionStorage — per-tab — never in the shared +// localStorage. The first cut CLEARED localStorage instead, and localStorage is +// origin-wide: the support tab's impersonated tokens replaced the operator's own, so +// refreshing their normally-signed-in tab silently became the support session. Isolation +// beats clearing on both counts: the operator's tabs keep their tokens untouched, and the +// support tab boots token-less into the silent authorize that inherits the impersonated +// session. The flag itself lives in sessionStorage, so it dies with the tab. +// (Module-scope discipline: touch only hoisted consts and the storage APIs here — the +// first cut's clearLocal() call hit a temporal dead zone and killed the whole bundle.) +const SUPPORT_FLAG = 'oidc.support' if (new URLSearchParams(window.location.search).has('support_session')) { - // Inline removals, NOT clearLocal(): this runs at module evaluation, and clearLocal - // touches `let access` declared BELOW — a TDZ ReferenceError here killed the whole - // bundle on dev (eternal splash, param never stripped). Only the hoisted consts above - // are safe to reach from module scope. The DPoP key stays: it is the APP's key, and the - // impersonated session's tokens bind to it exactly as any other boot's would. - try { - localStorage.removeItem(TOKENS_KEY) - localStorage.removeItem(DECLARATION_KEY) - } catch { /* a blocked storage API must not kill the boot */ } + try { sessionStorage.setItem(SUPPORT_FLAG, '1') } catch { /* a blocked storage API must not kill the boot */ } const clean = new URL(window.location.href) clean.searchParams.delete('support_session') window.history.replaceState({}, '', clean.toString()) } +/** The token store for THIS TAB: tab-scoped for a support session, shared otherwise. */ +const tokenStore = (): Storage => { + try { return sessionStorage.getItem(SUPPORT_FLAG) ? window.sessionStorage : window.localStorage } catch { return window.localStorage } +} type Flow = { verifier: string; state: string; nonce: string; redirectUri: string } type Stored = { refresh_token: string; id_token?: string } @@ -86,7 +85,7 @@ const decodeJwt = (jwt?: string): any => { const stored = (): Stored | undefined => { try { - const raw = localStorage.getItem(TOKENS_KEY) + const raw = tokenStore().getItem(TOKENS_KEY) return raw ? JSON.parse(raw) : undefined } catch { return undefined @@ -145,7 +144,7 @@ const declarationFingerprint = () => * then matches. What it deliberately cannot see is a grant narrowed on the server. */ export function oidcGrantStale(): boolean { try { - return localStorage.getItem(DECLARATION_KEY) !== declarationFingerprint() + return tokenStore().getItem(DECLARATION_KEY) !== declarationFingerprint() } catch { return false } @@ -223,7 +222,7 @@ export async function oidcCompleteFromUrl(): Promise { persist({ refresh_token: body.refresh_token, id_token: body.id_token }) // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant // is merged from exactly that — so the grant now covers this build. Stamp it. - try { localStorage.setItem(DECLARATION_KEY, declarationFingerprint()) } catch { /* non-fatal */ } + try { tokenStore().setItem(DECLARATION_KEY, declarationFingerprint()) } catch { /* non-fatal */ } const at = decodeJwt(body.access_token) access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return claims @@ -300,12 +299,12 @@ export function oidcClearLocal() { function clearLocal() { void clearDpopKey() access = {} - localStorage.removeItem(TOKENS_KEY) - localStorage.removeItem(DECLARATION_KEY) + tokenStore().removeItem(TOKENS_KEY) + tokenStore().removeItem(DECLARATION_KEY) } function persist(tokens: Stored) { - localStorage.setItem(TOKENS_KEY, JSON.stringify(tokens)) + tokenStore().setItem(TOKENS_KEY, JSON.stringify(tokens)) } function cleanUrl() { From f69dfc9f8b351955a398dabead5a27efffa74c54 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 17:59:18 -0700 Subject: [PATCH 087/238] View-as is one click: the deep link directs the portal launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console hop remains in the chain — the admin grant lives there — but the ?launch=remoteit_portal directive makes it invisible: the user page fires the mint on arrival and the tab becomes the support session. A gate refusal parks on the console user page with the server's own message and its remedy. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index c2f470927..2916602b1 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -65,7 +65,7 @@ export const AdminUserDetailPage: React.FC = () => { // The EMAIL is the key both worlds share: permitteer subjects are sub_, not r3 // GUIDs — the authorizer joins them by email — and the console resolves an unknown // deep-link id through its user search (one match opens; else honestly unknown). - windowOpen(`${OAUTH_ISSUER}/admin/console/users/${encodeURIComponent(user.email || user.id)}`, '_blank', true) + windowOpen(`${OAUTH_ISSUER}/admin/console/users/${encodeURIComponent(user.email || user.id)}?launch=remoteit_portal`, '_blank', true) } return ( From 151a06747a2183372fd60d1c934ff398317ca8e5 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 22:02:42 -0700 Subject: [PATCH 088/238] The chat rides the session: one sign-in, agent-audience tokens, the DCR lane retires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permitteer docs/remoteit-ai-agent.md Phase 4. The sign-in's DECLARED list gains the stage's MCP detail delegated ONWARD to svc_ai_agent — the actor mark is what stamps may_act into this session's tokens, the exchange's precondition — and the existing grant-heal machinery upgrades already-signed-in installs with one silent re-authorize (the fingerprint moved). Chat requests now carry oidcAuthHeaders for the agent audience: DPoP proofs signed over the CANONICAL resource URL (audience + path), matching the agent edge regardless of the proxy or override transporting the request. Deleted: hydra.ts (the whole DCR/PKCE parallel auth stack), the agent token/session store and its secureStorage encryption, the /hydra vite proxy, ensureFreshAgentToken (the client-side pre-refresh — the same idea D4 killed server-side), the agent sign-in callback flow, and the Test Settings MCP-audience override (the exchange target is the server's business now). The chat's 'sign in' becomes 'refresh permissions' — one grant heal. App sign-out revokes nothing agent-specific because nothing agent-specific exists. Typecheck clean; electron 29/29; frontend builds. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Chat/ChatBody.tsx | 6 +- frontend/src/constants.ts | 7 + frontend/src/hooks/useChatSync.ts | 1 - frontend/src/i18n/locales/en/app.json | 9 +- frontend/src/i18n/locales/en/notices.json | 2 +- frontend/src/models/chat.ts | 47 +--- frontend/src/models/ui.ts | 1 - frontend/src/pages/TestPage.tsx | 12 +- frontend/src/services/agent.ts | 86 ++----- frontend/src/services/chatPopout.ts | 2 +- frontend/src/services/hydra.ts | 298 ---------------------- frontend/src/services/oidc.ts | 14 +- frontend/src/services/secureStorage.ts | 89 ------- frontend/vite.config.ts | 5 - 14 files changed, 54 insertions(+), 525 deletions(-) delete mode 100644 frontend/src/services/hydra.ts delete mode 100644 frontend/src/services/secureStorage.ts diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 320896cff..54f8333be 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -42,12 +42,12 @@ export const ChatBody: React.FC = () => { - {t('chat.signInNeeded', 'The AI agent needs its own sign-in to act on your behalf.')} - {isChatPopout && ` ${t('chat.signInFromMain', 'Sign in from the main app window.')}`} + {t('chat.signInNeeded', 'The AI agent needs permissions your session doesn\u2019t carry yet.')} + {isChatPopout && ` ${t('chat.signInFromMain', 'Refresh permissions from the main app window.')}`} {!isChatPopout && ( )} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index e36767344..b54e9d4a0 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -8,6 +8,13 @@ export const OAUTH_ISSUER = env.VITE_OAUTH_ISSUER || '' export const OAUTH_CLIENT_ID = env.VITE_OAUTH_CLIENT_ID || 'remoteit_desktop' export const OAUTH_GRAPHQL_RESOURCE = env.VITE_OAUTH_GRAPHQL_RESOURCE || 'https://graphql.dev.remote.it/graphql' export const OAUTH_PASSPORT_RESOURCE = env.VITE_OAUTH_PASSPORT_RESOURCE || 'https://passport.dev.remote.it/account/api' +// The AI agent lane (permitteer docs/remoteit-ai-agent.md D1/D5): chat requests carry +// tokens ADDRESSED to the agent service, and the sign-in declares the stage's MCP detail +// delegated onward to the service actor — which is what makes those tokens exchangeable. +export const OAUTH_AGENT_RESOURCE = env.VITE_OAUTH_AGENT_RESOURCE || 'https://agent.dev.remote.it' +export const OAUTH_MCP_RESOURCE = env.VITE_OAUTH_MCP_RESOURCE || 'https://mcp.dev.remote.it/mcp' +export const OAUTH_MCP_DETAIL = env.VITE_OAUTH_MCP_DETAIL || 'remoteit_mcp_dev' +export const OAUTH_AGENT_ACTOR = 'svc_ai_agent' export const API_URL = env.VITE_API_URL || 'https://api.remote.it/apv/v27' export const AUTH_API_URL = env.VITE_AUTH_API_URL || env.AUTH_API_URL || 'https://auth.api.remote.it/v1' diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index 3523b2298..4e53ddceb 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -23,7 +23,6 @@ export const useChatMainSync = (): void => { dispatch.chat.resetTransient() // Completes a Hydra sign-in redirect if this page load carries ?code — // runs on mount regardless of whether the panel is open - dispatch.chat.handleSignInCallback() const handlers: PopoutMainHandlers = { getHandoff: currentHandoff, adopt: payload => { diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index a2abd8412..2cd8c483a 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -197,9 +197,9 @@ "popIn": "Pop back in", "popOut": "Pop out", "send": "Send", - "signIn": "Sign in with remote.it", - "signInFromMain": "Sign in from the main app window.", - "signInNeeded": "The AI agent needs its own sign-in to act on your behalf.", + "signIn": "Refresh permissions", + "signInFromMain": "Refresh permissions from the main app window.", + "signInNeeded": "The AI agent needs permissions your session doesn\u2019t carry yet.", "stop": "Stop", "toolRequest": "The agent wants to run {{tool}}", "toolsUsed_one": "Used {{count}} tool", @@ -2019,10 +2019,9 @@ "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", "licensingOptions": "Licensing Options", - "mcpAudience": "Agent MCP audience", "mintError": "This target was refused at token mint: {{error}}", "overrideAgent": "Override agent service", - "overrideAgentSub": "Point the Mycal chat at a deployed agent (https only). dev-ai-agent pairs with audience https://mcp.demo.remote.it/mcp. Sign in to the agent again after changing these.", + "overrideAgentSub": "Point the Mycal chat at a deployed agent (https only). Auth rides your app session either way — the token is minted for the agent audience, so the target must trust this stage.", "overrideDefaultAPIs": "Override default APIs", "overrideLicensesAndLimits": "Override licenses and limits", "reset": "Reset", diff --git a/frontend/src/i18n/locales/en/notices.json b/frontend/src/i18n/locales/en/notices.json index ea0991f2d..63330fbde 100644 --- a/frontend/src/i18n/locales/en/notices.json +++ b/frontend/src/i18n/locales/en/notices.json @@ -12,7 +12,7 @@ "chat": { "authRequired": "Agent authentication required — sign in to continue.", "popupBlocked": "Pop out was blocked — allow popups for this site and try again.", - "sessionExpired": "Agent session expired — sign in again to continue.", + "sessionExpired": "The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.", "signInFailed": "Agent sign-in failed — {{error}}" }, "connection": { diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 86a192a76..9d2d1afb4 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -10,15 +10,13 @@ import { AgentMessageParam, OrgSelection, } from '../services/agent' -import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra' import { ChatHandoff, broadcastChatSignout, openChatPopout, popIn as closePopoutWithHandback, } from '../services/chatPopout' -// Value import is deref'd only inside effects, so the store/model cycle is -// safe (same pattern as services/hydra.ts) +// Value import is deref'd only inside effects, so the store/model cycle is safe import { store } from '../store' import type { State } from '../store' import i18n from '../i18n' @@ -102,7 +100,7 @@ function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { // pointless until the token is refreshed (e.g. it expired mid-turn). if (event.message.startsWith('reauth_required')) { state.error = i18n.t('notices:chat.sessionExpired', { - defaultValue: 'Agent session expired — sign in again to continue.', + defaultValue: 'The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.', }) state.health = 'unauthorized' } else { @@ -143,7 +141,7 @@ export const toChatHandoff = (chat: IChatState): ChatHandoff => ({ }) const authRequiredError = () => - i18n.t('notices:chat.authRequired', { defaultValue: 'Agent authentication required — sign in to continue.' }) + i18n.t('notices:chat.authRequired', { defaultValue: 'The agent refused this session\u2019s credentials — refresh permissions to continue.' }) let abortController: AbortController | null = null @@ -174,7 +172,6 @@ export default createModel()({ } } try { - await ensureFreshAgentToken() await streamChat({ conversationId, messages, @@ -215,7 +212,6 @@ export default createModel()({ // Deny) can't post a second, contradictory decision while in flight dispatch.chat.set({ pendingConfirmation: null }) try { - await ensureFreshAgentToken() await confirmTool({ conversationId: state.chat.conversationId, toolUseId: pending.toolUseId, @@ -250,45 +246,24 @@ export default createModel()({ closePopoutWithHandback(toChatHandoff(store.getState().chat)) }, async checkHealth() { - await ensureFreshAgentToken() dispatch.chat.set({ health: await agentHealth() }) }, - /* Full-page redirect to the Hydra login (registers a client first if - needed); handleSignInCallback picks up the return after reload */ + /* The chat has no sign-in of its own anymore — it rides the app session + (permitteer docs/remoteit-ai-agent.md D2). An unauthorized chat while the + app works means the standing grant predates this build's agent slice, so + the fix is the grant heal: one silent re-authorize that merges it in. */ async signIn() { - try { - await startAgentSignIn() - } catch (error) { - dispatch.chat.set({ error: (error as Error).message }) - } - }, - /* Complete a sign-in redirect if this page load carries one */ - async handleSignInCallback(_: void, state) { - const result = await handleAgentSignInCallback() - if (!result) return - // Don't yank the dock open if the conversation currently lives in the - // popout window — the popout is the active surface, not the panel. - const openIfDocked = state.chat.poppedOut ? {} : { open: true } - if (result.ok) dispatch.chat.set({ error: null, ...openIfDocked }) - else - dispatch.chat.set({ - error: i18n.t('notices:chat.signInFailed', { - defaultValue: 'Agent sign-in failed — {{error}}', - error: result.error, - }), - ...openIfDocked, - }) + await dispatch.auth.healGrant() await dispatch.chat.checkHealth() }, - /* App sign-out tears the agent session down with it: revoke + clear the - Hydra credentials. The transcript reset is dispatched by auth.signedOut - alongside the other model resets — dispatching it here would land in the + /* App sign-out: nothing agent-specific to revoke — the session's end IS the + chat's end. The transcript reset is dispatched by auth.signedOut alongside + the other model resets — dispatching it here would land in the purge-to-reload window and re-persist the pre-signout state. */ async signOut() { broadcastChatSignout() abortController?.abort() abortController = null - await agentSignOut() }, }), reducers: { diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index 4a125b9cd..a8f836520 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -52,7 +52,6 @@ export type UIState = { // Test UI: point the Mycal chat at a deployed agent (https only) switchAgent?: boolean agentURL?: string - mcpAudience?: string } layout: ILayout silent: string | null diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 6fa2bf08f..425f8a962 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -18,7 +18,6 @@ import { Title } from '../components/Title' import { Quote } from '../components/Quote' import { emit } from '../services/Controller' import { isSecureAgentURL } from '../services/agent' -import { MCP_AUDIENCE } from '../services/hydra' export const TestPage: React.FC = () => { const { t } = useTranslation() @@ -249,7 +248,7 @@ export const TestPage: React.FC = () => { label={t('testPage.overrideAgent', 'Override agent service')} subLabel={t( 'testPage.overrideAgentSub', - 'Point the Mycal chat at a deployed agent (https only). dev-ai-agent pairs with audience https://mcp.demo.remote.it/mcp. Sign in to the agent again after changing these.' + 'Point the Mycal chat at a deployed agent (https only). Auth rides your app session either way — the token is minted for the agent audience, so the target must trust this stage.' )} onClick={() => setAgentPreference('switchAgent', !apis.switchAgent)} toggle={!!apis.switchAgent} @@ -278,15 +277,6 @@ export const TestPage: React.FC = () => { }} hideIcon /> - setAgentPreference('mcpAudience', value.toString().trim())} - hideIcon - /> diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 13b80d559..f297a1b46 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -1,9 +1,16 @@ /** * Client for the ai-agent service (REST + SSE). The service is stateless: * the client holds the transcript and resends it each turn. + * + * Auth rides the FIRST-PARTY session (permitteer docs/remoteit-ai-agent.md D2): every + * request carries an agent-audience token from the oidc machinery plus a DPoP proof — + * signed over the CANONICAL resource URL (audience + path), which is what the agent's + * edge checks regardless of the proxy or override actually transporting the request. + * No agent-specific credentials exist anywhere anymore. */ import { store } from '../store' -import { encryptString, decryptString, isEncrypted } from './secureStorage' +import { oidcAuthHeaders } from './oidc' +import { OAUTH_AGENT_RESOURCE } from '../constants' /* The override must be https — the app's CSP blocks plain http. Shared with the Test Settings validation so what saves is exactly what engages. */ @@ -20,67 +27,6 @@ export function agentURL(): string { return import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent' } -// Hydra credentials for the agent service (AUTH_MODE=hydra), written by the -// in-app sign-in flow (services/hydra.ts) — or a token pasted from the -// ai-agent dev harness as a fallback. Stored in localStorage (shared with the -// popout window) encrypted at rest via secureStorage. -const AGENT_TOKEN_KEY = 'agentToken' -const AGENT_SESSION_KEY = 'agentSession' - -export type AgentSession = { - refresh_token: string - expires_at: number - client_id: string -} - -/* Tokens are encrypted at rest (secureStorage) so localStorage never holds - them in clear text. Reads fall back to plaintext for a token pasted from - the ai-agent dev harness and for values stored before encryption landed — - the next write re-encrypts. */ - -export async function decodeAgentToken(raw: string | null): Promise { - if (!raw) return null - return isEncrypted(raw) ? await decryptString(raw) : raw -} - -export async function decodeAgentSession(raw: string | null): Promise { - if (!raw) return null - try { - const json = isEncrypted(raw) ? await decryptString(raw) : raw - return json ? (JSON.parse(json) as AgentSession) : null - } catch { - return null - } -} - -export const getAgentToken = (): Promise => - decodeAgentToken(window.localStorage.getItem(AGENT_TOKEN_KEY)) - -export async function setAgentToken(token: string | null): Promise { - if (token?.trim()) - window.localStorage.setItem(AGENT_TOKEN_KEY, await encryptString(token.trim().replace(/^Bearer\s+/i, ''))) - else window.localStorage.removeItem(AGENT_TOKEN_KEY) -} - -export const getAgentSession = (): Promise => - decodeAgentSession(window.localStorage.getItem(AGENT_SESSION_KEY)) - -export async function setAgentSession(session: AgentSession | null): Promise { - if (session) window.localStorage.setItem(AGENT_SESSION_KEY, await encryptString(JSON.stringify(session))) - else window.localStorage.removeItem(AGENT_SESSION_KEY) -} - -/* Synchronous read-and-clear for sign-out: the stored credentials must be - gone before any await gives a signOut-triggered reload a chance to - interrupt; the raw values are returned so revoke can still decode them */ -export function takeAgentCredentials(): { token: string | null; session: string | null } { - const token = window.localStorage.getItem(AGENT_TOKEN_KEY) - const session = window.localStorage.getItem(AGENT_SESSION_KEY) - window.localStorage.removeItem(AGENT_TOKEN_KEY) - window.localStorage.removeItem(AGENT_SESSION_KEY) - return { token, session } -} - /* The agent rejected our credential (401 reauth_required) — sign in again */ export class AgentAuthError extends Error { constructor() { @@ -88,11 +34,11 @@ export class AgentAuthError extends Error { } } -async function agentHeaders(json = true): Promise> { - const headers: Record = json ? { 'Content-Type': 'application/json' } : {} - const token = await getAgentToken() - if (token) headers.Authorization = `Bearer ${token}` - return headers +async function agentHeaders(method: string, path: string, json = true): Promise> { + return { + ...(json ? { 'Content-Type': 'application/json' } : {}), + ...(await oidcAuthHeaders(method, `${OAUTH_AGENT_RESOURCE}${path}`, OAUTH_AGENT_RESOURCE)), + } } export type AgentEvent = @@ -118,7 +64,7 @@ export async function streamChat(options: { const { conversationId, messages, org, signal, onEvent } = options const response = await fetch(`${agentURL()}/api/chat`, { method: 'POST', - headers: await agentHeaders(), + headers: await agentHeaders('POST', '/api/chat'), body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }), signal, }) @@ -155,7 +101,7 @@ export async function confirmTool(options: { }): Promise { const response = await fetch(`${agentURL()}/api/chat/confirm`, { method: 'POST', - headers: await agentHeaders(), + headers: await agentHeaders('POST', '/api/chat/confirm'), body: JSON.stringify(options), }) if (response.status === 401) throw new AgentAuthError() @@ -166,7 +112,7 @@ export type AgentHealth = 'ok' | 'unauthorized' | 'unreachable' export async function agentHealth(): Promise { try { - const response = await fetch(`${agentURL()}/api/health`, { headers: await agentHeaders(false) }) + const response = await fetch(`${agentURL()}/api/health`, { headers: await agentHeaders('GET', '/api/health', false) }) if (response.status === 401) return 'unauthorized' if (!response.ok) return 'unreachable' const body = (await response.json()) as { ok?: boolean } diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index 6ff548cfa..e35e437a3 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -13,7 +13,7 @@ import { CHAT_POPOUT_PARAM, CHAT_POPOUT_SIZE } from '@common/constants' const OWNER_KEY = 'chatPopoutOwner' // Captured at module-evaluation time, before any routing can touch the URL -// (same pattern as the hydra ?code capture in services/hydra.ts) + const bootQuery = new URLSearchParams(window.location.search) export const isChatPopout = bootQuery.has(CHAT_POPOUT_PARAM) // The popout's identity — the flag's value ties it to the one tab that opened diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts deleted file mode 100644 index 55853b356..000000000 --- a/frontend/src/services/hydra.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * In-app Hydra sign-in for the agent service — OAuth 2.1 authorization code + - * PKCE, with self-service Dynamic Client Registration. Mirrors the MCP demo - * SPA in the authentication repo (hydra-login-consent/scripts/demo-spa). - * - * Flow: ensure a DCR client for this origin → full-page redirect to the Hydra - * login/consent pages → return to the app root with ?code → exchange for - * tokens → store access token + refresh session (services/agent.ts) → - * silently refresh before expiry. - * - * The register/token calls go through the dev vite proxy at /hydra - * (same-origin, so no CORS); the login redirect itself is a top-level - * navigation to the real issuer. Packaged builds need the app origin on the - * OAuth front's CORS allow-list, or the exchange moved to the Electron main - * process. - */ -import { - getAgentSession, - getAgentToken, - setAgentSession, - setAgentToken, - takeAgentCredentials, - decodeAgentSession, - decodeAgentToken, -} from './agent' -import { encryptString, decryptString } from './secureStorage' -import { store } from '../store' - -export const HYDRA_ISSUER = import.meta.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it' -export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.beta.remote.it/mcp' -const SCOPE = 'openid offline email device:read device:write device:connect device:execute' -const LIFESPAN = '30m' // access-token TTL override, verified accepted via DCR - -// OAuth fetches (DCR register, token exchange, revoke) always use the -// same-origin /hydra path: the vite proxy serves it in dev, and the Amplify -// rewrite rule serves it on deployed previews (login.dev.remote.it does not -// answer CORS preflights, so direct browser calls are blocked). The login -// redirect is a top-level navigation to the issuer and needs neither. -const OAUTH_API = '/hydra' - -const CLIENT_KEY = 'agentOauthClient' -const FLOW_KEY = 'agentOauthFlow' - -// Captured synchronously at module-evaluation time: the app's Cognito side -// (Amplify, configured with an oauth block) installs a URL listener that -// consumes and strips ?code/state params for ITS authorization-code flow. -// Our Hydra callback uses the same param names on the same origin, so we must -// grab them before Amplify boots — and only claim them when this tab actually -// started an agent sign-in (flow state present), so a genuine Cognito -// callback is left untouched. -// The callback is claimed only when its `state` matches the flow this tab -// started — a Cognito callback (same origin, same param names) never matches, -// so even a stale flow key left by an abandoned agent sign-in can't hijack it. -const bootParams = new URLSearchParams(window.location.search) -// The flow record keeps `state` readable (it is public — it rides the URL) -// so this synchronous gate can run before Amplify boots; the verifier and -// client id live encrypted in `data` and are only decrypted in the handler. -type StoredFlow = { state?: string; data?: string } -let bootFlow: StoredFlow | null = null -try { - bootFlow = JSON.parse(window.sessionStorage.getItem(FLOW_KEY) || 'null') -} catch {} -const isAgentCallback = - !!bootFlow?.state && bootParams.get('state') === bootFlow.state && (bootParams.has('code') || bootParams.has('error')) -if (isAgentCallback) { - // Strip immediately: hides the single-use code from Amplify's listener and - // from any reload. The hash route is preserved. - window.history.replaceState({}, '', window.location.pathname + window.location.hash) -} - -const b64url = (bytes: ArrayBuffer | Uint8Array): string => - btoa(String.fromCharCode(...new Uint8Array(bytes))) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - -const randomString = (length: number): string => { - const bytes = new Uint8Array(length) - crypto.getRandomValues(bytes) - return b64url(bytes) -} - -const sha256 = async (value: string): Promise => - b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))) - -// The redirect must land somewhere this SPA is served; the app uses hash -// routing, so the root URL with a ?code query never collides with a route. -const redirectUri = (): string => `${window.location.origin}/` - -type StoredClient = { client_id: string; key: string } - -// One public client per (issuer, origin, scope, audience) — the cache key -// busts when the requested grant changes, like the demo SPA. -/* Effective audience for agent tokens: the Test UI override wins (Test - Settings → Override agent service) so tokens match the deployment the - tester pointed the chat at; changing it busts the client cache below. */ -const mcpAudience = (): string => { - const { switchAgent, mcpAudience: override } = store.getState().ui.apis - return (switchAgent && override?.trim()) || MCP_AUDIENCE -} - -const clientCacheKey = (): string => `${HYDRA_ISSUER}|${window.location.origin}|${SCOPE}|${mcpAudience()}` - -async function ensureClient(): Promise { - try { - const cached = JSON.parse(window.localStorage.getItem(CLIENT_KEY) || 'null') as StoredClient | null - if (cached?.client_id && cached.key === clientCacheKey()) return cached.client_id - } catch {} - - const response = await fetch(`${OAUTH_API}/oauth2/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_name: 'remote.it desktop agent chat', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - redirect_uris: [redirectUri()], - scope: SCOPE, - token_endpoint_auth_method: 'none', - authorization_code_grant_access_token_lifespan: LIFESPAN, - refresh_token_grant_access_token_lifespan: LIFESPAN, - }), - }) - if (!response.ok) throw new Error(`Agent sign-in registration failed (${response.status}): ${await response.text()}`) - const { client_id } = (await response.json()) as { client_id: string } - window.localStorage.setItem(CLIENT_KEY, JSON.stringify({ client_id, key: clientCacheKey() })) - return client_id -} - -/* Token-endpoint rejection with the HTTP status attached, so callers can - distinguish a definitive denial from a transient failure structurally - instead of parsing the message */ -class TokenRequestError extends Error { - constructor(public status: number, message: string) { - super(message) - } -} - -async function tokenRequest(params: Record): Promise<{ - access_token: string - refresh_token?: string - expires_in?: number -}> { - const response = await fetch(`${OAUTH_API}/oauth2/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams(params), - }) - const text = await response.text() - if (!response.ok) throw new TokenRequestError(response.status, `Agent token exchange failed (${response.status}): ${text}`) - return JSON.parse(text) -} - -async function storeTokens( - clientId: string, - tokens: { access_token: string; refresh_token?: string; expires_in?: number } -) { - await setAgentToken(tokens.access_token) - await setAgentSession({ - refresh_token: tokens.refresh_token || (await getAgentSession())?.refresh_token || '', - // Missing expires_in falls back to the requested LIFESPAN — an expires_at - // of "now" would make every subsequent call fire a refresh grant - expires_at: Date.now() + (tokens.expires_in ?? 1800) * 1000, - client_id: clientId, - }) -} - -/* Kick off the sign-in: registers the client if needed, then navigates the - whole window to the Hydra login page. The app reloads on return. */ -export async function startAgentSignIn(): Promise { - const clientId = await ensureClient() - const verifier = randomString(32) - const state = randomString(16) - // The PKCE verifier (and client id) are encrypted at rest; only `state` - // stays plaintext for the synchronous boot-time callback gate - const data = await encryptString(JSON.stringify({ verifier, clientId })) - window.sessionStorage.setItem(FLOW_KEY, JSON.stringify({ state, data })) - - const auth = new URL(`${HYDRA_ISSUER}/oauth2/auth`) - auth.searchParams.set('response_type', 'code') - auth.searchParams.set('client_id', clientId) - auth.searchParams.set('redirect_uri', redirectUri()) - auth.searchParams.set('scope', SCOPE) - auth.searchParams.set('state', state) - auth.searchParams.set('code_challenge', await sha256(verifier)) - auth.searchParams.set('code_challenge_method', 'S256') - // RFC 8707: binds the access token's audience to the MCP resource - auth.searchParams.set('resource', mcpAudience()) - window.location.assign(auth.toString()) -} - -let callbackConsumed = false - -/* Complete the flow after the redirect back. Call once on app boot; returns - null when this page load carries no agent sign-in response. Reads the - module-scope capture, not the live URL (already stripped above). */ -export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?: string } | null> { - if (!isAgentCallback || callbackConsumed) return null - callbackConsumed = true - const code = bootParams.get('code') - const error = bootParams.get('error') - - // Consume the flow before any branch can return — a leftover key would stay - // armed and claim a later, unrelated OAuth callback on this origin. The - // state match is already guaranteed by the isAgentCallback gate above. - const stored = bootFlow - window.sessionStorage.removeItem(FLOW_KEY) - - if (error) { - // A client cached from before a scope/resource change can be rejected at - // authorize (e.g. invalid_target); drop it so the next attempt re-registers. - window.localStorage.removeItem(CLIENT_KEY) - return { ok: false, error: `${error}: ${bootParams.get('error_description') || ''}` } - } - - let flow: { verifier: string; clientId: string } | null = null - try { - const json = stored?.data ? await decryptString(stored.data) : null - flow = json ? JSON.parse(json) : null - } catch {} - if (!flow) return { ok: false, error: 'Sign-in expired — try again.' } - - try { - const tokens = await tokenRequest({ - grant_type: 'authorization_code', - code: code!, - redirect_uri: redirectUri(), - client_id: flow.clientId, - code_verifier: flow.verifier, - }) - await storeTokens(flow.clientId, tokens) - return { ok: true } - } catch (err) { - return { ok: false, error: (err as Error).message } - } -} - -/* Sign the agent out alongside the app: drop the local credentials, then - best-effort revoke the refresh token so Hydra's otherwise never-expiring - refresh chain dies server-side too. The DCR client registration is kept — - it belongs to the app origin, not the user. */ -export async function agentSignOut(): Promise { - // Clear synchronously first — a signOut-triggered reload must never find - // credentials still stored; the raw values are decoded after for revoke - const raw = takeAgentCredentials() - const session = await decodeAgentSession(raw.session) - const token = await decodeAgentToken(raw.token) - const revoke = (value: string, clientId: string) => - fetch(`${OAUTH_API}/oauth2/revoke`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ token: value, client_id: clientId }), - }) - try { - if (session?.refresh_token) await revoke(session.refresh_token, session.client_id) - else if (token && session?.client_id) await revoke(token, session.client_id) - } catch { - // Offline or proxy unavailable — locals are already cleared; the access - // token dies at its 30m TTL. - } -} - -let refreshPromise: Promise | null = null - -/* Refresh the access token when it is missing or close to expiry. Silent - no-op when there is nothing to refresh (e.g. a hand-pasted token). - Single-flight: concurrent callers (panel-open health check racing a send) - share one request — presenting a rotating refresh token twice trips - Hydra's reuse detection and revokes the whole chain. */ -export async function ensureFreshAgentToken(): Promise { - if (refreshPromise) return refreshPromise - const session = await getAgentSession() - if (!session?.refresh_token) return - const fresh = (await getAgentToken()) && Date.now() < session.expires_at - 60_000 - if (fresh) return - // Re-check after the async reads above: another caller may have started a - // refresh while we were reading — join it instead of racing a second grant - if (refreshPromise) return refreshPromise - refreshPromise = (async () => { - try { - const tokens = await tokenRequest({ - grant_type: 'refresh_token', - refresh_token: session.refresh_token, - client_id: session.client_id, - }) - storeTokens(session.client_id, tokens) - } catch (error) { - // Only a definitive rejection means the chain is dead (revoked or - // reuse-detection) — then clear so the UI falls back to the sign-in - // prompt. A transient network/proxy failure keeps the session so a - // later call can retry instead of forcing a full re-sign-in. - if (error instanceof TokenRequestError && [400, 401, 403].includes(error.status)) setAgentSession(null) - } finally { - refreshPromise = null - } - })() - return refreshPromise -} diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index b3bf8c594..f18fb82aa 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -1,5 +1,5 @@ import browser from './browser' -import { OAUTH_ISSUER, OAUTH_CLIENT_ID, OAUTH_GRAPHQL_RESOURCE, OAUTH_PASSPORT_RESOURCE, PROTOCOL } from '../constants' +import { OAUTH_ISSUER, OAUTH_CLIENT_ID, OAUTH_GRAPHQL_RESOURCE, OAUTH_PASSPORT_RESOURCE, OAUTH_MCP_RESOURCE, OAUTH_MCP_DETAIL, OAUTH_AGENT_ACTOR, PROTOCOL } from '../constants' /** * The renderer-owned OIDC client (permitteer docs/remoteit-desktop-login.md, D8): @@ -119,15 +119,21 @@ const redirectUri = () => * `passport_account` gates the native security settings; `permitteer_account` is Connected * Apps against the AS's own account API (plan D6) — list + revoke. The graphql audience * stays pure scope-`full` and carries no details, so it is not listed here. */ -const DECLARED: Array<{ resource: string; type: string; actions: string[] }> = [ +const DECLARED: Array<{ resource: string; type: string; actions: string[]; actor?: string }> = [ { resource: OAUTH_PASSPORT_RESOURCE, type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write'] }, + // The AI agent's slice (remoteit-ai-agent.md D5): the stage's MCP detail, delegated + // ONWARD to the agent service — `actor` is what stamps may_act into this session's + // tokens, which is the exchange's precondition. The slice partitions from any plain + // request of the same type, and the grant row it mints is the revocable object the + // account console shows. + { resource: OAUTH_MCP_RESOURCE, type: OAUTH_MCP_DETAIL, actions: ['device:read', 'device:write', 'device:connect', 'device:execute'], actor: OAUTH_AGENT_ACTOR }, ] /** A stable fingerprint of what this build asks for. Order-insensitive, so reshuffling the * list is not a change; adding, dropping or renaming an action is. */ const declarationFingerprint = () => - DECLARED.map(d => `${d.resource}=${d.type}:${[...d.actions].sort().join(',')}`) + DECLARED.map(d => `${d.resource}=${d.type}:${[...d.actions].sort().join(',')}${d.actor ? `@${d.actor}` : ''}`) .sort() .join('|') @@ -168,7 +174,7 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account'; log // the passport-audience token minted later via refresh carries this slice, gating the // native security settings (credentials.write); the graphql audience stays pure // scope-`full` (an uncovered resource yields audience-only tokens). - authorization_details: JSON.stringify(DECLARED.map(d => ({ type: d.type, actions: d.actions }))), + authorization_details: JSON.stringify(DECLARED.map(d => ({ type: d.type, actions: d.actions, ...(d.actor ? { actor: d.actor } : {}) }))), state: flow.state, nonce: flow.nonce, } diff --git a/frontend/src/services/secureStorage.ts b/frontend/src/services/secureStorage.ts deleted file mode 100644 index 20c76193c..000000000 --- a/frontend/src/services/secureStorage.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * At-rest encryption for sensitive values persisted to localStorage (the - * agent's OAuth tokens). The AES-GCM key is generated non-extractable and - * lives only in IndexedDB: running code on this origin can ask it to - * encrypt/decrypt, but the key material itself can never be read out — so a - * copied localStorage (backups, disk images, extensions reading storage) - * yields ciphertext only. Both app windows (main and chat popout) share the - * key through the same origin-scoped database. - */ - -const DB_NAME = 'remoteit-secure' -const STORE = 'keys' -const KEY_ID = 'at-rest' -const PREFIX = 'enc.v1.' -const IV_LENGTH = 12 - -export const isEncrypted = (value: string): boolean => value.startsWith(PREFIX) - -function openDb(): Promise { - return new Promise((resolve, reject) => { - const request = window.indexedDB.open(DB_NAME, 1) - request.onupgradeneeded = () => request.result.createObjectStore(STORE) - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) -} - -/* Get-or-create inside one readwrite transaction so concurrent windows can't - race two different keys into existence (the freshly generated key is - discarded when another window won) */ -async function loadOrStoreKey(fresh: CryptoKey): Promise { - const db = await openDb() - try { - return await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readwrite') - const keys = tx.objectStore(STORE) - const existing = keys.get(KEY_ID) - existing.onsuccess = () => { - if (existing.result) resolve(existing.result as CryptoKey) - else { - keys.put(fresh, KEY_ID) - resolve(fresh) - } - } - tx.onerror = () => reject(tx.error) - }) - } finally { - db.close() - } -} - -let keyPromise: Promise | null = null - -function atRestKey(): Promise { - keyPromise ??= crypto.subtle - .generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) - .then(loadOrStoreKey) - .catch(error => { - keyPromise = null // e.g. IndexedDB unavailable — let a later call retry - throw error - }) - return keyPromise -} - -export async function encryptString(plain: string): Promise { - const key = await atRestKey() - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) - const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plain)) - const bytes = new Uint8Array(IV_LENGTH + cipher.byteLength) - bytes.set(iv) - bytes.set(new Uint8Array(cipher), IV_LENGTH) - let binary = '' - bytes.forEach(byte => (binary += String.fromCharCode(byte))) - return PREFIX + btoa(binary) -} - -/* null when the value isn't ours to read: wrong/rotated key, corrupt data, - or not an encrypted value at all — callers treat it as signed out */ -export async function decryptString(value: string): Promise { - if (!isEncrypted(value)) return null - try { - const bytes = Uint8Array.from(atob(value.slice(PREFIX.length)), c => c.charCodeAt(0)) - const key = await atRestKey() - const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.slice(0, IV_LENGTH) }, key, bytes.slice(IV_LENGTH)) - return new TextDecoder().decode(plain) - } catch { - return null - } -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8bd850ff3..f06eb56ad 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -49,11 +49,6 @@ export default defineConfig(({ mode }) => { // DCR + token-exchange calls avoid CORS entirely (top-level login // redirects go to the real domain and don't need this). Packaged builds // need the origin CORS-allow-listed or a main-process exchange instead. - '/hydra': { - target: env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it', - changeOrigin: true, - rewrite: (p: string) => p.replace(/^\/hydra/, ''), - }, }, }, type: 'module', From a61b03e3f2451c17b2d91bb18f60f4010572c40a Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 22:26:04 -0700 Subject: [PATCH 089/238] The chat sends only its words: the desktop lands the D11 resource contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permitteer docs/remoteit-ai-agent.md Phase 5, the lockstep half. A turn starts with the NEW message at /api/conversations/:id/messages; the transcript the panel renders is a display cache of the server's durable copy, not the record. The stream's opening turn event makes the turn addressable — confirmations post to /api/turns/:id/confirm. Typecheck clean; electron 29/29; frontend builds. Co-Authored-By: Claude Fable 5 --- frontend/src/models/chat.ts | 15 +++++++-------- frontend/src/services/agent.ts | 32 +++++++++++++++++++------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 9d2d1afb4..9be58e6f2 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -7,7 +7,6 @@ import { AgentAuthError, AgentEvent, AgentHealth, - AgentMessageParam, OrgSelection, } from '../services/agent' import { @@ -38,6 +37,7 @@ export type IChatState = { expanded: boolean messages: ChatTranscriptMessage[] conversationId: string + turnId: string /** Org the agent is scoped to; null = uninitialized, user id = personal */ orgId: string | null /** Conversation currently lives in the popout window (main window only) */ @@ -53,6 +53,7 @@ export const defaultChatState: IChatState = { expanded: false, messages: [], conversationId: '', + turnId: '', orgId: null, poppedOut: false, streaming: false, @@ -115,9 +116,6 @@ function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { } /* The agent service is stateless: resend the transcript as role/content pairs each turn */ -function toMessageParams(messages: ChatTranscriptMessage[]): AgentMessageParam[] { - return messages.filter(m => m.text.trim().length > 0).map(m => ({ role: m.role, content: m.text })) -} /* Single source of truth for the org the chat is scoped to (null = personal). Membership decides the scope, so the Current Org label and the org sent @@ -151,7 +149,6 @@ export default createModel()({ async send(text: string, state) { if (state.chat.streaming || state.chat.pendingConfirmation) return const conversationId = state.chat.conversationId || crypto.randomUUID() - const messages = toMessageParams([...state.chat.messages, { role: 'user', text }]) dispatch.chat.addUserMessage(text) dispatch.chat.set({ conversationId, streaming: true, error: null }) abortController = new AbortController() @@ -174,11 +171,13 @@ export default createModel()({ try { await streamChat({ conversationId, - messages, + text, org, signal: abortController.signal, onEvent: event => { - if (event.type === 'text_delta') { + if (event.type === 'turn') { + dispatch.chat.set({ turnId: event.turnId }) + } else if (event.type === 'text_delta') { deltaBuffer += event.text if (flushTimer === null) flushTimer = window.setTimeout(flushDeltas, 50) } else { @@ -213,7 +212,7 @@ export default createModel()({ dispatch.chat.set({ pendingConfirmation: null }) try { await confirmTool({ - conversationId: state.chat.conversationId, + turnId: state.chat.turnId, toolUseId: pending.toolUseId, approved, }) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index f297a1b46..9f5db5a16 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -1,6 +1,8 @@ /** - * Client for the ai-agent service (REST + SSE). The service is stateless: - * the client holds the transcript and resends it each turn. + * Client for the ai-agent service (REST + SSE). Conversations are SERVER-side resources + * now (permitteer docs/remoteit-ai-agent.md D11/Phase 5): each turn sends only the NEW + * message; the server owns the durable transcript and journals every turn, so this + * client's copy is a display cache, not the record. * * Auth rides the FIRST-PARTY session (permitteer docs/remoteit-ai-agent.md D2): every * request carries an agent-audience token from the oidc machinery plus a DPoP proof — @@ -42,6 +44,7 @@ async function agentHeaders(method: string, path: string, json = true): Promise< } export type AgentEvent = + | { type: 'turn'; turnId: string } | { type: 'text_delta'; text: string } | { type: 'tool_call_start'; id: string; name: string; input: Record } | { type: 'tool_call_result'; id: string; name: string; result: string; isError: boolean; durationMs: number } @@ -53,19 +56,21 @@ export type AgentMessageParam = { role: 'user' | 'assistant'; content: string } export type OrgSelection = { id: string; name: string } -/* Stream one chat turn. Events arrive as SSE: `event: \ndata: \n\n` */ +/* Stream one chat turn: the NEW message only. Events arrive as SSE, opening with + `turn {turnId}` — the id confirm() addresses. */ export async function streamChat(options: { conversationId: string - messages: AgentMessageParam[] + text: string org?: OrgSelection signal?: AbortSignal onEvent: (event: AgentEvent) => void }): Promise { - const { conversationId, messages, org, signal, onEvent } = options - const response = await fetch(`${agentURL()}/api/chat`, { + const { conversationId, text, org, signal, onEvent } = options + const path = `/api/conversations/${encodeURIComponent(conversationId)}/messages` + const response = await fetch(`${agentURL()}${path}`, { method: 'POST', - headers: await agentHeaders('POST', '/api/chat'), - body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }), + headers: await agentHeaders('POST', path), + body: JSON.stringify(org ? { text, org } : { text }), signal, }) if (response.status === 401) throw new AgentAuthError() @@ -93,16 +98,17 @@ export async function streamChat(options: { } } -/* Approve or deny a write tool the agent paused on */ +/* Approve or deny a write tool the agent paused on — addressed to the TURN */ export async function confirmTool(options: { - conversationId: string + turnId: string toolUseId: string approved: boolean }): Promise { - const response = await fetch(`${agentURL()}/api/chat/confirm`, { + const path = `/api/turns/${encodeURIComponent(options.turnId)}/confirm` + const response = await fetch(`${agentURL()}${path}`, { method: 'POST', - headers: await agentHeaders('POST', '/api/chat/confirm'), - body: JSON.stringify(options), + headers: await agentHeaders('POST', path), + body: JSON.stringify({ toolUseId: options.toolUseId, approved: options.approved }), }) if (response.status === 401) throw new AgentAuthError() if (!response.ok) throw new Error(`Confirm failed (${response.status})`) From da88a2be2ff9fb593575ae9e7da345a65578b09f Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 22:37:56 -0700 Subject: [PATCH 090/238] Background work gets its switch: enroll, watch the verdict, revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permitteer docs/remoteit-ai-agent.md Phase 6, the desktop half. Test Settings grows the Background-work toggle — enrollment opens the AS ceremony in the browser and the page polls for the verdict; disabling revokes the agent's stored grant. Explicit sign-out sends the same best-effort revoke (D8: sign-out ends the relationship, a closed lid does not). The chat adopts the server's transcript on mount, which is exactly how a background turn's result appears after a reopen. Co-Authored-By: Claude Fable 5 --- frontend/src/hooks/useChatSync.ts | 8 +++++- frontend/src/i18n/locales/en/app.json | 4 +++ frontend/src/models/chat.ts | 25 ++++++++++++++++++ frontend/src/pages/TestPage.tsx | 36 +++++++++++++++++++++++++- frontend/src/services/agent.ts | 37 +++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index 4e53ddceb..e14a010c4 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -8,7 +8,7 @@ import { initChatPopoutMain, initChatPopoutWindow, checkPopoutPresence, PopoutMa const currentHandoff = () => toChatHandoff(store.getState().chat) /* Main-window chat lifecycle — everything ChatPanel needs to happen but that - isn't display: completing a Hydra sign-in redirect, wiring the popout + isn't display: adopting the server's transcript on mount, wiring the popout handoff protocol, re-checking agent health when the dock opens, and mirroring the app's active org. */ export const useChatMainSync = (): void => { @@ -21,6 +21,9 @@ export const useChatMainSync = (): void => { // the panel must not reset a still-running stream (closing the panel // deliberately leaves the stream running) dispatch.chat.resetTransient() + // The server owns the transcript: catch up on anything a background turn finished + // while this window was away (plan D6/D11). + dispatch.chat.syncTranscript() // Completes a Hydra sign-in redirect if this page load carries ?code — // runs on mount regardless of whether the panel is open const handlers: PopoutMainHandlers = { @@ -61,6 +64,9 @@ export const useChatPopoutSync = (): void => { useEffect(() => { document.title = t('chat.windowTitle', 'remote.it chat') dispatch.chat.resetTransient() + // The server owns the transcript: catch up on anything a background turn finished + // while this window was away (plan D6/D11). + dispatch.chat.syncTranscript() // No syncOrg here: the popout keeps the org handed off with the // conversation (it has no sidebar to change it with) dispatch.chat.checkHealth() diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 2cd8c483a..c74238cf7 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2006,6 +2006,10 @@ "agentURL": "Agent service URL", "agentURLInvalid": "Agent service URL must start with https://", "apiTarget": "API Target", + "backgroundWork": "Background work", + "backgroundWorkOff": "Off — the agent only works while you\u2019re here. Enabling grants it a separate, narrower permission you can revoke any time.", + "backgroundWorkOn": "Enabled — while you\u2019re away the agent can look and watch, not touch. Turns you start can finish without you.", + "backgroundWorkUnknown": "Checking…", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", "customAPITarget": "Custom", diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 9be58e6f2..b44040742 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -3,6 +3,8 @@ import { RootModel } from '.' import { streamChat, confirmTool, + backgroundDisable, + fetchConversation, agentHealth, AgentAuthError, AgentEvent, @@ -247,6 +249,26 @@ export default createModel()({ async checkHealth() { dispatch.chat.set({ health: await agentHealth() }) }, + /* The server owns the transcript now (D11) — adopt its copy when it knows more than + we do, which is exactly how a background turn's result appears after a reopen. */ + async syncTranscript(_: void, state) { + const id = state.chat.conversationId + if (!id || state.chat.streaming) return + try { + const remote = await fetchConversation(id) + if (remote && remote.messages.length > state.chat.messages.length) { + dispatch.chat.set({ + messages: remote.messages.map(m => + m.role === 'assistant' + ? { role: 'assistant' as const, text: m.content, toolCalls: [] } + : { role: 'user' as const, text: m.content }, + ), + }) + } + } catch { + /* offline or deleted — the local display cache stands */ + } + }, /* The chat has no sign-in of its own anymore — it rides the app session (permitteer docs/remoteit-ai-agent.md D2). An unauthorized chat while the app works means the standing grant predates this build's agent slice, so @@ -263,6 +285,9 @@ export default createModel()({ broadcastChatSignout() abortController?.abort() abortController = null + // Explicit sign-out ends the background relationship too (plan D8): best-effort + // revoke of the agent's stored grant, before the session tokens vanish. + void backgroundDisable() }, }), reducers: { diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 425f8a962..d60da9cdd 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -17,7 +17,8 @@ import { PortalUI } from '../components/PortalUI' import { Title } from '../components/Title' import { Quote } from '../components/Quote' import { emit } from '../services/Controller' -import { isSecureAgentURL } from '../services/agent' +import { isSecureAgentURL, backgroundConnectUrl, backgroundStatus, backgroundDisable } from '../services/agent' +import { windowOpen } from '../services/browser' export const TestPage: React.FC = () => { const { t } = useTranslation() @@ -41,6 +42,26 @@ export const TestPage: React.FC = () => { await dispatch.ui.setPersistent({ apis: { ...apis, [key]: value } }) } + // Background work (permitteer docs/remoteit-ai-agent.md D6): the agent's own, narrower + // grant — enrollment is a browser ceremony at the AS; this page only reads/ends it. + const [backgroundEnrolled, setBackgroundEnrolled] = useState(undefined) + useEffect(() => { + backgroundStatus().then(setBackgroundEnrolled) + }, []) + async function connectBackground() { + await windowOpen(backgroundConnectUrl(), '_blank', true) + // The ceremony finishes in the browser — poll briefly for the verdict. + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 2000)) + if (await backgroundStatus()) break + } + setBackgroundEnrolled(await backgroundStatus()) + } + async function disableBackground() { + await backgroundDisable() + setBackgroundEnrolled(await backgroundStatus()) + } + // --- the stage-pair switcher (D10+D11a, permitteer docs/remoteit-desktop-login.md 4c) ---- // The options come FROM the AS: the client's own allowlist joined to registry names, so the // picker and the mint-time guardrail can never disagree. Identifiers group into stage pairs @@ -280,6 +301,19 @@ export const TestPage: React.FC = () => { + (backgroundEnrolled ? disableBackground() : connectBackground())} + /> {t('testPage.features', 'Features')} diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 9f5db5a16..8694dc463 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -127,3 +127,40 @@ export async function agentHealth(): Promise { return 'unreachable' } } + +/* The server-side transcript (D11) — the durable copy this client's display caches. */ +export async function fetchConversation( + conversationId: string, +): Promise<{ messages: Array<{ role: string; content: string }> } | null> { + const path = `/api/conversations/${encodeURIComponent(conversationId)}` + const response = await fetch(`${agentURL()}${path}`, { headers: await agentHeaders('GET', path, false) }) + if (!response.ok) return null + return (await response.json()) as { messages: Array<{ role: string; content: string }> } +} + +// --- Background work (permitteer docs/remoteit-ai-agent.md D6/Phase 6) ----------------- + +/* Where the enrollment ceremony starts — a top-level navigation to the agent, which + redirects into the AS consent screen. Who enrolled is the AS's answer at the + callback, so this URL needs no token. */ +export const backgroundConnectUrl = (): string => `${agentURL()}/oauth/connect` + +export async function backgroundStatus(): Promise { + try { + const response = await fetch(`${agentURL()}/api/enrollment`, { headers: await agentHeaders('GET', '/api/enrollment', false) }) + if (!response.ok) return false + return ((await response.json()) as { enrolled?: boolean }).enrolled === true + } catch { + return false + } +} + +/* Best-effort: revokes the agent's stored grant at the AS and empties its vault. + Called from Background-work settings and from explicit sign-out (plan D8). */ +export async function backgroundDisable(): Promise { + try { + await fetch(`${agentURL()}/api/enrollment`, { method: 'DELETE', headers: await agentHeaders('DELETE', '/api/enrollment', false) }) + } catch { + /* best-effort by design */ + } +} From 54fe78ad06a7fb287b449c841f60a160c02fc027 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 24 Aug 2026 23:57:23 -0700 Subject: [PATCH 091/238] Rename Mycal to Remote.It AI; the AI portal shows the chat unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat panel's title and its user-facing strings become 'Remote.It AI' (the header literal becomes a translatable chat.title). And a CHAT_ALWAYS_ON flag (VITE_CHAT_ALWAYS_ON, set on the app.ai.remote.it Amplify branch) shows the chat without the Test UI gate on the dedicated AI surface — while every other deployed build keeps the soft-launch, so the code is safe to merge elsewhere. Typecheck clean; electron 29/29. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Chat/ChatBody.tsx | 2 +- frontend/src/components/Chat/ChatHeader.tsx | 19 +++++++++++-------- frontend/src/constants.ts | 4 ++++ frontend/src/hooks/useChatEnabled.ts | 6 ++++-- frontend/src/i18n/locales/en/app.json | 5 +++-- frontend/src/models/ui.ts | 2 +- frontend/src/pages/TestPage.tsx | 2 +- 7 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 54f8333be..886630ed0 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -27,7 +27,7 @@ export const ChatBody: React.FC = () => { // Literal default: the i18next parser can't extract a value passed as a variable const unavailableMessage = t( 'chat.unavailable', - 'Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.' + 'Remote.It AI is temporarily unavailable. Check your internet connection or try again in a few minutes.' ) return ( diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index bc262ae88..d4e467eff 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -7,14 +7,17 @@ import { IconButton } from '../../buttons/IconButton' /* Title row shared by the docked panel and the popout window — the window-specific buttons render as children in each caller's order */ -export const ChatHeader: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( - - - Mycal - - {children} - -) +export const ChatHeader: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const { t } = useTranslation() + return ( + + + {t('chat.title', 'Remote.It AI')} + + {children} + + ) +} export const NewChatButton: React.FC = () => { const { t } = useTranslation() diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index b54e9d4a0..9a4d1e870 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -2,6 +2,10 @@ import brand from '@common/brand/config' const env = import.meta.env export const MODE = env.MODE || 'development' +// The AI portal (app.ai.remote.it) shows the Remote.It AI chat unconditionally: it IS the +// AI surface. Set per-deployment via the Amplify branch env so the general app keeps the +// soft-launch (Test UI) even if this branch's code merges elsewhere. +export const CHAT_ALWAYS_ON = env.VITE_CHAT_ALWAYS_ON === 'true' // Renderer-owned OIDC (permitteer docs/remoteit-desktop-login.md, D8) — identical on // web and desktop; the backend never touches auth. export const OAUTH_ISSUER = env.VITE_OAUTH_ISSUER || '' diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index 337cd6d53..7b59eb15a 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux' import { State } from '../store' import { MODE, + CHAT_ALWAYS_ON, CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED, HIDE_TWO_PANEL_WIDTH, @@ -11,11 +12,12 @@ import { ORGANIZATION_BAR_WIDTH, } from '../constants' -/* Mycal is always on in local dev builds; in deployed builds it soft-launches +/* The Remote.It AI chat is always on in local dev builds and on the dedicated AI portal + (CHAT_ALWAYS_ON, set for app.ai.remote.it); in every other deployed build it soft-launches behind the hidden Test UI (shift+option on the avatar menu → Test UI). */ export const useChatEnabled = (): boolean => { const testUI = useSelector((state: State) => state.ui.testUI) - return MODE === 'development' || !!testUI + return MODE === 'development' || CHAT_ALWAYS_ON || !!testUI } /* Width the docked chat column occupies — single source for the fits-check diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index c74238cf7..8f79fc28f 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -204,7 +204,8 @@ "toolRequest": "The agent wants to run {{tool}}", "toolsUsed_one": "Used {{count}} tool", "toolsUsed_other": "Used {{count}} tools", - "unavailable": "Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.", + "title": "Remote.It AI", + "unavailable": "Remote.It AI is temporarily unavailable. Check your internet connection or try again in a few minutes.", "waitingApproval": "Waiting for approval…", "windowTitle": "remote.it chat" }, @@ -2025,7 +2026,7 @@ "licensingOptions": "Licensing Options", "mintError": "This target was refused at token mint: {{error}}", "overrideAgent": "Override agent service", - "overrideAgentSub": "Point the Mycal chat at a deployed agent (https only). Auth rides your app session either way — the token is minted for the agent audience, so the target must trust this stage.", + "overrideAgentSub": "Point the Remote.It AI chat at a deployed agent (https only). Auth rides your app session either way — the token is minted for the agent audience, so the target must trust this stage.", "overrideDefaultAPIs": "Override default APIs", "overrideLicensesAndLimits": "Override licenses and limits", "reset": "Reset", diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index a8f836520..29dc9102c 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -49,7 +49,7 @@ export type UIState = { apiGraphqlURL?: IPreferences['apiGraphqlURL'] webSocketURL?: IPreferences['webSocketURL'] apiURL?: IPreferences['apiURL'] - // Test UI: point the Mycal chat at a deployed agent (https only) + // Test UI: point the Remote.It AI chat at a deployed agent (https only) switchAgent?: boolean agentURL?: string } diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index d60da9cdd..6e881fec7 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -269,7 +269,7 @@ export const TestPage: React.FC = () => { label={t('testPage.overrideAgent', 'Override agent service')} subLabel={t( 'testPage.overrideAgentSub', - 'Point the Mycal chat at a deployed agent (https only). Auth rides your app session either way — the token is minted for the agent audience, so the target must trust this stage.' + 'Point the Remote.It AI chat at a deployed agent (https only). Auth rides your app session either way — the token is minted for the agent audience, so the target must trust this stage.' )} onClick={() => setAgentPreference('switchAgent', !apis.switchAgent)} toggle={!!apis.switchAgent} From 203872667c349df284fc67c0620601afc73b7ab6 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 25 Aug 2026 00:05:44 -0700 Subject: [PATCH 092/238] A history picker for the chat: past conversations, backed by the server (D11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock icon in the chat header opens the user's conversations (GET /api/conversations, newest first), titled by their first message. Selecting one adopts its server-side transcript; the trash affordance deletes it for real (the cascade already reaches messages, turns, and the journal). The list refreshes on mount, after each turn (so a new thread appears titled), and on open. This surfaces what Phase 5 already stored — the desktop just had no way in. Typecheck clean; electron 29/29. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Chat/ChatHeader.tsx | 64 ++++++++++++++++++++- frontend/src/components/Chat/ChatPanel.tsx | 3 +- frontend/src/components/Chat/ChatWindow.tsx | 3 +- frontend/src/hooks/useChatSync.ts | 6 +- frontend/src/i18n/locales/en/app.json | 4 ++ frontend/src/models/chat.ts | 46 +++++++++++++++ frontend/src/services/agent.ts | 20 ++++++- 7 files changed, 137 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index d4e467eff..b2490705f 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -1,9 +1,10 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { Box, Typography } from '@mui/material' -import { Dispatch } from '../../store' +import { useDispatch, useSelector } from 'react-redux' +import { Box, Typography, Menu, MenuItem, ListItemText, IconButton as MuiIconButton } from '@mui/material' +import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' +import { Icon } from '../Icon' /* Title row shared by the docked panel and the popout window — the window-specific buttons render as children in each caller's order */ @@ -24,3 +25,60 @@ export const NewChatButton: React.FC = () => { const dispatch = useDispatch() return dispatch.chat.clearConversation()} /> } + +/* History picker: the server-side conversation list (D11), newest first. Selecting one + loads its transcript; the trash affordance deletes it for real. */ +export const HistoryButton: React.FC = () => { + const { t } = useTranslation() + const dispatch = useDispatch() + const conversations = useSelector((state: State) => state.chat.conversations) + const currentId = useSelector((state: State) => state.chat.conversationId) + const [anchorEl, setAnchorEl] = React.useState(null) + + const open = (e: React.MouseEvent) => { + dispatch.chat.loadConversations() // freshen on open + setAnchorEl(e.currentTarget) + } + const close = () => setAnchorEl(null) + + return ( + <> + + + {conversations.length === 0 && ( + + + + )} + {conversations.map(c => ( + { + dispatch.chat.openConversation(c.id) + close() + }} + > + + { + e.stopPropagation() + dispatch.chat.removeConversation(c.id) + }} + > + + + + ))} + + + ) +} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index e987fa678..5c3637b26 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -6,7 +6,7 @@ import { State, Dispatch } from '../../store' import { useChatDocked, useChatWidth } from '../../hooks/useChatEnabled' import { useChatMainSync } from '../../hooks/useChatSync' import { IconButton } from '../../buttons/IconButton' -import { ChatHeader, NewChatButton } from './ChatHeader' +import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' import { ChatBody } from './ChatBody' import browser from '../../services/browser' @@ -67,6 +67,7 @@ export const ChatPanel: React.FC = () => { onClick={() => dispatch.chat.popOut()} /> )} + dispatch.chat.set({ open: false })} /> diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 4c5bff261..035366d87 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -5,7 +5,7 @@ import { Box } from '@mui/material' import { Dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { useChatPopoutSync } from '../../hooks/useChatSync' -import { ChatHeader, NewChatButton } from './ChatHeader' +import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' import { ChatBody } from './ChatBody' /* Full-page chat for the popped-out window (?chatPopout boot flag). Display @@ -29,6 +29,7 @@ export const ChatWindow: React.FC = () => { }} > + { // deliberately leaves the stream running) dispatch.chat.resetTransient() // The server owns the transcript: catch up on anything a background turn finished - // while this window was away (plan D6/D11). + // while this window was away (plan D6/D11), and load the conversation history. dispatch.chat.syncTranscript() + dispatch.chat.loadConversations() // Completes a Hydra sign-in redirect if this page load carries ?code — // runs on mount regardless of whether the panel is open const handlers: PopoutMainHandlers = { @@ -65,8 +66,9 @@ export const useChatPopoutSync = (): void => { document.title = t('chat.windowTitle', 'remote.it chat') dispatch.chat.resetTransient() // The server owns the transcript: catch up on anything a background turn finished - // while this window was away (plan D6/D11). + // while this window was away (plan D6/D11), and load the conversation history. dispatch.chat.syncTranscript() + dispatch.chat.loadConversations() // No syncOrg here: the popout keeps the org handed off with the // conversation (it has no sidebar to change it with) dispatch.chat.checkHealth() diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 8f79fc28f..bd85c21a3 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -192,6 +192,10 @@ "expand": "Expand", "interrupted": "Interrupted", "newChat": "New Chat", + "history": "History", + "historyEmpty": "No past conversations", + "untitled": "New conversation", + "deleteConversation": "Delete", "organization": "Organization", "personal": "Personal", "popIn": "Pop back in", diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index b44040742..da8cadf34 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -5,6 +5,9 @@ import { confirmTool, backgroundDisable, fetchConversation, + listConversations, + deleteConversation, + type ConversationSummary, agentHealth, AgentAuthError, AgentEvent, @@ -40,6 +43,7 @@ export type IChatState = { messages: ChatTranscriptMessage[] conversationId: string turnId: string + conversations: ConversationSummary[] /** Org the agent is scoped to; null = uninitialized, user id = personal */ orgId: string | null /** Conversation currently lives in the popout window (main window only) */ @@ -56,6 +60,7 @@ export const defaultChatState: IChatState = { messages: [], conversationId: '', turnId: '', + conversations: [], orgId: null, poppedOut: false, streaming: false, @@ -198,6 +203,9 @@ export default createModel()({ flushDeltas() abortController = null dispatch.chat.set({ streaming: false }) + // A finished turn may have created (and titled) a new conversation — refresh the + // picker so it appears without a manual reload. + dispatch.chat.loadConversations() } }, /* The chat follows the app's active org (the sidebar selector) — the main @@ -277,6 +285,44 @@ export default createModel()({ await dispatch.auth.healGrant() await dispatch.chat.checkHealth() }, + /* The history picker's list — refreshed on mount, after a turn, and after a delete. */ + async loadConversations() { + try { + dispatch.chat.set({ conversations: await listConversations() }) + } catch { + /* offline — leave the last-known list */ + } + }, + /* Switch the panel to an existing conversation: adopt its server transcript, reset the + live turn state so nothing from the previous thread bleeds across. */ + async openConversation(id: string, state) { + if (state.chat.streaming) dispatch.chat.stop() + const remote = await fetchConversation(id) + if (!remote) { + // Vanished (deleted elsewhere) — drop it from the list and start fresh. + dispatch.chat.clearConversation() + await dispatch.chat.loadConversations() + return + } + dispatch.chat.set({ + conversationId: id, + turnId: '', + streaming: false, + pendingConfirmation: null, + error: null, + messages: remote.messages.map(m => + m.role === 'assistant' + ? { role: 'assistant' as const, text: m.content, toolCalls: [] } + : { role: 'user' as const, text: m.content }, + ), + }) + }, + /* Delete a conversation for real (D9). If it's the one on screen, clear to a new chat. */ + async removeConversation(id: string, state) { + await deleteConversation(id) + if (state.chat.conversationId === id) dispatch.chat.clearConversation() + await dispatch.chat.loadConversations() + }, /* App sign-out: nothing agent-specific to revoke — the session's end IS the chat's end. The transcript reset is dispatched by auth.signedOut alongside the other model resets — dispatching it here would land in the diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 8694dc463..8e9940c1b 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -128,14 +128,30 @@ export async function agentHealth(): Promise { } } +export type ConversationSummary = { id: string; title: string | null; createdAt: string; updatedAt: string } + +/* The user's conversations, newest first (D11) — the history picker's source. */ +export async function listConversations(): Promise { + const response = await fetch(`${agentURL()}/api/conversations`, { headers: await agentHeaders('GET', '/api/conversations', false) }) + if (!response.ok) return [] + return ((await response.json()) as { conversations: ConversationSummary[] }).conversations +} + /* The server-side transcript (D11) — the durable copy this client's display caches. */ export async function fetchConversation( conversationId: string, -): Promise<{ messages: Array<{ role: string; content: string }> } | null> { +): Promise<{ title: string | null; messages: Array<{ role: string; content: string }> } | null> { const path = `/api/conversations/${encodeURIComponent(conversationId)}` const response = await fetch(`${agentURL()}${path}`, { headers: await agentHeaders('GET', path, false) }) if (!response.ok) return null - return (await response.json()) as { messages: Array<{ role: string; content: string }> } + return (await response.json()) as { title: string | null; messages: Array<{ role: string; content: string }> } +} + +/* The delete that actually deletes (D9): messages, turns, journal all cascade server-side. */ +export async function deleteConversation(conversationId: string): Promise { + const path = `/api/conversations/${encodeURIComponent(conversationId)}` + const response = await fetch(`${agentURL()}${path}`, { method: 'DELETE', headers: await agentHeaders('DELETE', path, false) }) + return response.ok } // --- Background work (permitteer docs/remoteit-ai-agent.md D6/Phase 6) ----------------- From 3198691886af9d1656ab9098d7660acec4bb39e2 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 25 Aug 2026 00:22:10 -0700 Subject: [PATCH 093/238] The chat panel reads as a chat: intro empty-state, session name, slimmer chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things from the app.ai feedback. (1) An empty-state introduction (ChatIntro): a robot avatar, a one-line description, and three tappable example prompts that start a first turn — so the panel isn't a blank column with a distant input. (2) The current session name in the header, as a subtitle under 'Remote.It AI' (titled by the first message, carried through the popout handoff, 'New chat' when fresh). (3) Slimmer chrome: the bulky 'CURRENT ORG / Personal' block becomes a slim 'Acting in Personal' line with an icon, and the input gains a real 'Message Remote.It AI…' placeholder. Typecheck clean; electron 29/29; build green. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Chat/ChatBody.tsx | 9 ++- frontend/src/components/Chat/ChatHeader.tsx | 12 ++- frontend/src/components/Chat/ChatIntro.tsx | 74 +++++++++++++++++++ frontend/src/components/Chat/ChatOrgLabel.tsx | 17 +++-- frontend/src/i18n/locales/en/app.json | 9 +++ frontend/src/models/chat.ts | 16 +++- frontend/src/services/chatPopout.ts | 1 + 7 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/Chat/ChatIntro.tsx diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 886630ed0..26ce7c643 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -6,6 +6,7 @@ import { State, Dispatch } from '../../store' import { ChatMessages } from './ChatMessages' import { ChatApproval } from './ChatApproval' import { ChatInput } from './ChatInput' +import { ChatIntro } from './ChatIntro' import { ChatOrgLabel } from './ChatOrgLabel' import { Notice } from '../Notice' import { Body } from '../Body' @@ -58,6 +59,8 @@ export const ChatBody: React.FC = () => { {unavailableMessage} + ) : !messages.length && !pendingConfirmation && !error ? ( + ) : ( {pendingConfirmation && ( @@ -76,7 +79,11 @@ export const ChatBody: React.FC = () => { )} dispatch.chat.send(text)} onStop={() => dispatch.chat.stop()} diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index b2490705f..bba7dd279 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -10,11 +10,17 @@ import { Icon } from '../Icon' window-specific buttons render as children in each caller's order */ export const ChatHeader: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const { t } = useTranslation() + const sessionName = useSelector((state: State) => state.chat.title) return ( - - {t('chat.title', 'Remote.It AI')} - + + + {t('chat.title', 'Remote.It AI')} + + + {sessionName || t('chat.newSession', 'New chat')} + + {children} ) diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx new file mode 100644 index 000000000..212a19dc4 --- /dev/null +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Box, Button, Typography } from '@mui/material' +import { Dispatch } from '../../store' +import { Icon } from '../Icon' + +/* Empty-state introduction: shown before the first message so the panel reads as a chat, + not a blank column. The example prompts are one tap into a first turn. */ +export const ChatIntro: React.FC = () => { + const { t } = useTranslation() + const dispatch = useDispatch() + + const prompts = [ + t('chat.prompt1', 'Which of my devices are offline?'), + t('chat.prompt2', 'Show my recent connections'), + t('chat.prompt3', 'Restart a service on one of my devices'), + ] + + return ( + + + + + {t('chat.introTitle', 'Remote.It AI')} + + {t( + 'chat.introBody', + 'Ask about your devices, connections, and services — I can look things up and take actions for you.' + )} + + + + {t('chat.tryAsking', 'Try asking')} + + {prompts.map(prompt => ( + + ))} + + + ) +} diff --git a/frontend/src/components/Chat/ChatOrgLabel.tsx b/frontend/src/components/Chat/ChatOrgLabel.tsx index e4ca9e12b..b1b5312f8 100644 --- a/frontend/src/components/Chat/ChatOrgLabel.tsx +++ b/frontend/src/components/Chat/ChatOrgLabel.tsx @@ -3,22 +3,23 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { Box, Typography } from '@mui/material' import { resolveChatOrg } from '../../models/chat' +import { Icon } from '../Icon' -/* Read-only display of the org the agent is scoped to. The chat follows the - app's active org (the sidebar org selector); the popout window shows the - org handed off with the conversation. Resolved by the same lookup send() - uses, so the label and the org sent to the agent can never disagree. */ +/* Read-only display of the org the agent is scoped to — a slim line, since it is context, + not the panel's headline. The chat follows the app's active org (the sidebar org + selector); the popout window shows the org handed off with the conversation. Resolved by + the same lookup send() uses, so the label and the org sent to the agent can never disagree. */ export const ChatOrgLabel: React.FC = () => { const { t } = useTranslation() const org = useSelector(resolveChatOrg, (a, b) => a?.id === b?.id && a?.name === b?.name) const orgName = org ? org.name || t('chat.organization', 'Organization') : t('chat.personal', 'Personal') return ( - - - {t('chat.currentOrg', 'Current Org')} + + + + {t('chat.actingIn', 'Acting in {{org}}', { org: orgName })} - {orgName} ) } diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index bd85c21a3..733752719 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -198,6 +198,15 @@ "deleteConversation": "Delete", "organization": "Organization", "personal": "Personal", + "actingIn": "Acting in {{org}}", + "newSession": "New chat", + "inputPlaceholder": "Message Remote.It AI…", + "introTitle": "Remote.It AI", + "introBody": "Ask about your devices, connections, and services — I can look things up and take actions for you.", + "tryAsking": "Try asking", + "prompt1": "Which of my devices are offline?", + "prompt2": "Show my recent connections", + "prompt3": "Restart a service on one of my devices", "popIn": "Pop back in", "popOut": "Pop out", "send": "Send", diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index da8cadf34..177c6eafd 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -43,6 +43,7 @@ export type IChatState = { messages: ChatTranscriptMessage[] conversationId: string turnId: string + title: string conversations: ConversationSummary[] /** Org the agent is scoped to; null = uninitialized, user id = personal */ orgId: string | null @@ -60,6 +61,7 @@ export const defaultChatState: IChatState = { messages: [], conversationId: '', turnId: '', + title: '', conversations: [], orgId: null, poppedOut: false, @@ -142,6 +144,7 @@ export function resolveChatOrg(state: State): OrgSelection | null { export const toChatHandoff = (chat: IChatState): ChatHandoff => ({ messages: chat.messages, conversationId: chat.conversationId, + title: chat.title, orgId: chat.orgId, }) @@ -157,7 +160,14 @@ export default createModel()({ if (state.chat.streaming || state.chat.pendingConfirmation) return const conversationId = state.chat.conversationId || crypto.randomUUID() dispatch.chat.addUserMessage(text) - dispatch.chat.set({ conversationId, streaming: true, error: null }) + dispatch.chat.set({ + conversationId, + streaming: true, + error: null, + // Name a fresh session by its first message immediately; the server sets the same + // title, and loadConversations reconciles after the turn. + ...(state.chat.title ? {} : { title: text.replace(/\s+/g, ' ').trim().slice(0, 80) }), + }) abortController = new AbortController() // Same resolution the Current Org label renders, so the scope shown is // always the scope sent — membership decides, name falls back @@ -307,6 +317,7 @@ export default createModel()({ dispatch.chat.set({ conversationId: id, turnId: '', + title: remote.title || '', streaming: false, pendingConfirmation: null, error: null, @@ -360,12 +371,15 @@ export default createModel()({ adoptTranscript(state: IChatState, payload: ChatHandoff) { state.messages = payload.messages state.conversationId = payload.conversationId + state.title = payload.title state.orgId = payload.orgId return state }, clearConversation(state: IChatState) { state.messages = [] state.conversationId = '' + state.turnId = '' + state.title = '' state.streaming = false state.pendingConfirmation = null state.error = null diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index e35e437a3..af1fb47a3 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -28,6 +28,7 @@ const ownerId = (): string | null => window.sessionStorage.getItem(OWNER_KEY) export type ChatHandoff = { messages: ChatTranscriptMessage[] conversationId: string + title: string orgId: string | null } From 4c8ddb53281ea57eae281a71f809de6d0a55126d Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 25 Aug 2026 00:36:04 -0700 Subject: [PATCH 094/238] A working indicator: pulsing dots while the agent has the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dead air between sending and the first token — routing, MCP connect, token exchange, the model's first output — now shows an assistant-aligned 'typing' bubble (three pulsing dots, CSS-only). It appears whenever a turn is in flight and nothing else is moving, and suppresses itself while a tool call shows its own spinner or text is actively streaming, so there's never a double indicator. Typecheck clean; electron 29/29; build green. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Chat/ChatBody.tsx | 10 ++++- frontend/src/components/Chat/ChatMessages.tsx | 7 +++- frontend/src/components/Chat/ChatTyping.tsx | 40 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Chat/ChatTyping.tsx diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 26ce7c643..8d85d12dd 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -31,6 +31,14 @@ export const ChatBody: React.FC = () => { 'Remote.It AI is temporarily unavailable. Check your internet connection or try again in a few minutes.' ) + // "Working" indicator: a turn is in flight but nothing else is moving — before the first + // token, and between a tool finishing and the next output. A running tool shows its own + // spinner and streaming text is its own motion, so suppress the dots while either is live. + const tail = messages[messages.length - 1] + const tailIsStreamingText = tail?.role === 'assistant' && tail.text.length > 0 + const toolRunning = tail?.role === 'assistant' && tail.toolCalls.some(c => c.status === 'running') + const typing = streaming && !tailIsStreamingText && !toolRunning + return ( <> @@ -62,7 +70,7 @@ export const ChatBody: React.FC = () => { ) : !messages.length && !pendingConfirmation && !error ? ( ) : ( - + {pendingConfirmation && ( = ({ messages, streaming, children }) => { +export const ChatMessages: React.FC = ({ messages, streaming, typing, children }) => { const ref = useRef(null) const [pinned, setPinned] = useState(true) // Follow the stream, but release when the user scrolls up to read useEffect(() => { if (pinned) ref.current?.scrollTo({ top: ref.current.scrollHeight }) - }, [messages, streaming, pinned, children]) + }, [messages, streaming, typing, pinned, children]) return ( = ({ messages, streaming, children }) {messages.map((message, index) => ( ))} + {typing && } {children} ) diff --git a/frontend/src/components/Chat/ChatTyping.tsx b/frontend/src/components/Chat/ChatTyping.tsx new file mode 100644 index 000000000..88ff6dd3e --- /dev/null +++ b/frontend/src/components/Chat/ChatTyping.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Box } from '@mui/material' + +/* "The agent is working" — three pulsing dots in an assistant-aligned bubble, shown while a + turn is in flight but nothing else is visibly moving (before the first token, and between + a tool finishing and the next output). Running tool calls show their own spinner, and + streaming text is its own motion, so this never doubles up with either. */ +export const ChatTyping: React.FC = () => ( + + + {[0, 1, 2].map(i => ( + + ))} + + +) From 754f3b2f004196cdc74bdb740b83ce987b47f58b Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 25 Aug 2026 09:54:50 -0700 Subject: [PATCH 095/238] feat(chat): drag-to-resize the chat column --- frontend/src/components/Chat/ChatPanel.tsx | 34 +++++++++++++--- frontend/src/components/PanelHandle.tsx | 45 ++++++++++++++++++++++ frontend/src/constants.ts | 1 + frontend/src/hooks/useChatEnabled.ts | 37 +++++++++++++++--- frontend/src/hooks/usePanelDrag.ts | 11 ++++-- frontend/src/models/chat.ts | 6 ++- frontend/src/store.ts | 2 +- 7 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/PanelHandle.tsx diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 5c3637b26..a78dc0537 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -1,10 +1,13 @@ -import React from 'react' +import React, { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useSelector, useDispatch } from 'react-redux' import { Box } from '@mui/material' import { State, Dispatch } from '../../store' -import { useChatDocked, useChatWidth } from '../../hooks/useChatEnabled' +import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED, CHAT_PANEL_WIDTH_MIN } from '../../constants' +import { useChatDocked, useChatWidth, useChatMaxWidth } from '../../hooks/useChatEnabled' import { useChatMainSync } from '../../hooks/useChatSync' +import { usePanelDrag } from '../../hooks/usePanelDrag' +import { PanelHandle } from '../PanelHandle' import { IconButton } from '../../buttons/IconButton' import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' import { ChatBody } from './ChatBody' @@ -15,15 +18,30 @@ import browser from '../../services/browser' export const ChatPanel: React.FC = () => { const { t } = useTranslation() const open = useSelector((state: State) => state.chat.open) - const expanded = useSelector((state: State) => state.chat.expanded) const insets = useSelector((state: State) => state.ui.layout.insets) const showBottomMenu = useSelector((state: State) => state.ui.layout.showBottomMenu) + const layout = useSelector((state: State) => state.ui.layout) const docked = useChatDocked() const chatWidth = useChatWidth() + const maxWidth = useChatMaxWidth() + const panelRef = useRef(null) const dispatch = useDispatch() useChatMainSync() + // Drag-to-resize, same mechanism as the content panels — anchored right, so + // pulling the handle left widens the chat. The width persists on release. + const getMaxWidth = useCallback(() => maxWidth, [maxWidth]) + const drag = usePanelDrag(chatWidth, { + panelRef, + minWidth: CHAT_PANEL_WIDTH_MIN, + getMaxWidth, + onPersist: width => dispatch.chat.set({ width }), + layoutDep: layout, + anchor: 'right', + }) + const expanded = chatWidth >= CHAT_PANEL_WIDTH_EXPANDED + if (!open) return null return ( @@ -38,7 +56,7 @@ export const ChatPanel: React.FC = () => { ? { position: 'relative', height: '100%', - width: chatWidth, + width: drag.width, } : { position: 'absolute', inset: 0, width: '100%', zIndex: 15, paddingLeft: insets?.leftPx }), // Match the page panels' safe-area handling (Panel.tsx): keep the @@ -51,13 +69,19 @@ export const ChatPanel: React.FC = () => { borderColor: 'grayLighter.main', paddingBottom: showBottomMenu ? 1 : insets?.bottomPx || 1, }} + ref={panelRef} > + {docked && } {docked && ( dispatch.chat.set({ expanded: !expanded })} + onClick={() => + dispatch.chat.set({ + width: Math.min(expanded ? CHAT_PANEL_WIDTH : CHAT_PANEL_WIDTH_EXPANDED, maxWidth), + }) + } /> )} {!browser.isMobile && ( diff --git a/frontend/src/components/PanelHandle.tsx b/frontend/src/components/PanelHandle.tsx new file mode 100644 index 000000000..32719bbda --- /dev/null +++ b/frontend/src/components/PanelHandle.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Box, Theme } from '@mui/material' + +/* The app's drag divider: a hairline that thickens and takes the primary + color on hover or while grabbed. Extracted from the panel components so + every resizable edge — content panels and the chat column — matches. */ +const handleSx = (theme: Theme) => ({ + zIndex: 8, + position: 'absolute' as const, + height: '100%', + marginLeft: '-5px', + padding: `0 ${theme.spacing(0.375)}`, + WebkitAppRegion: 'no-drag' as const, + '&:hover': { + cursor: 'col-resize', + }, + '& > div': { + width: '1px', + marginLeft: '1px', + marginRight: '1px', + height: '100%', + backgroundColor: theme.palette.grayLighter.main, + transition: 'background-color 100ms 200ms, width 100ms 200ms, margin 100ms 200ms', + }, + '&:hover > div, & .active': { + width: '3px', + marginLeft: 0, + marginRight: 0, + backgroundColor: theme.palette.primary.main, + }, +}) + +type Props = { + onMouseDown: (event: React.MouseEvent) => void + grab: boolean + /** Position it against the left edge of the panel it resizes — for a + * right-docked column that has no divider slot of its own in the flow */ + inset?: boolean +} + +export const PanelHandle: React.FC = ({ onMouseDown, grab, inset }) => ( + +
+ +) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 9a4d1e870..6fe5a8f62 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -101,6 +101,7 @@ export const MAX_DESCRIPTION_LENGTH = 1024 export const SIDEBAR_WIDTH = 250 export const CHAT_PANEL_WIDTH = 400 export const CHAT_PANEL_WIDTH_EXPANDED = 640 +export const CHAT_PANEL_WIDTH_MIN = 320 export const ORGANIZATION_BAR_WIDTH = 70 export const HIDE_SIDEBAR_WIDTH = 1150 export const HIDE_TWO_PANEL_WIDTH = 750 diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index 7b59eb15a..c6b0769ad 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react' import { useMediaQuery } from '@mui/material' import { useSelector } from 'react-redux' import { State } from '../store' @@ -5,7 +6,7 @@ import { MODE, CHAT_ALWAYS_ON, CHAT_PANEL_WIDTH, - CHAT_PANEL_WIDTH_EXPANDED, + CHAT_PANEL_WIDTH_MIN, HIDE_TWO_PANEL_WIDTH, HIDE_SIDEBAR_WIDTH, SIDEBAR_WIDTH, @@ -20,11 +21,37 @@ export const useChatEnabled = (): boolean => { return MODE === 'development' || CHAT_ALWAYS_ON || !!testUI } -/* Width the docked chat column occupies — single source for the fits-check - below, App's reserved layout width, and ChatPanel's rendered width */ +/* Viewport width, tracked for the chat column's fit math — the content + panels measure on the same event (see DoublePanel's resize listener) */ +const useViewportWidth = (): number => { + const [width, setWidth] = useState(() => window.innerWidth) + useEffect(() => { + const onResize = () => setWidth(window.innerWidth) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + return width +} + +/* The widest the chat column may be dragged: what is left once the sidebar + chrome and the two content panels keep their minimums. Bounding the drag + here is also what keeps the column docked — a wider chat would fail the + fit-check below and snap to a full-screen overlay mid-drag. */ +export const useChatMaxWidth = (): number => { + const viewport = useViewportWidth() + const sidebarWidth = useSidebarWidth() + return Math.max(CHAT_PANEL_WIDTH_MIN, viewport - sidebarWidth - HIDE_TWO_PANEL_WIDTH) +} + +/* Width the docked chat column occupies — single source for the fit-check + below, App's reserved layout width, and ChatPanel's rendered width. The + stored width is clamped to what fits, so a column dragged wide on a large + display still docks (narrower) on a small one instead of sticking as an + overlay the user has no handle to resize. */ export const useChatWidth = (): number => { - const expanded = useSelector((state: State) => state.chat.expanded) - return expanded ? CHAT_PANEL_WIDTH_EXPANDED : CHAT_PANEL_WIDTH + const stored = useSelector((state: State) => state.chat.width) + const max = useChatMaxWidth() + return Math.min(Math.max(stored || CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN), max) } /* Width of the left chrome (sidebar + org bar) the layout reserves — diff --git a/frontend/src/hooks/usePanelDrag.ts b/frontend/src/hooks/usePanelDrag.ts index f1d990874..cc81d3e48 100644 --- a/frontend/src/hooks/usePanelDrag.ts +++ b/frontend/src/hooks/usePanelDrag.ts @@ -6,6 +6,10 @@ interface UsePanelDragOptions { getMaxWidth: () => number onPersist?: (width: number) => void layoutDep?: unknown + /** Which edge the panel is fixed to. A right-anchored panel (the chat + * column) grows when the handle is dragged LEFT, so the pointer delta + * is inverted. Defaults to left, matching the content panels. */ + anchor?: 'left' | 'right' } /** @@ -20,7 +24,7 @@ interface UsePanelDragOptions { * @param options.layoutDep - Dependency to trigger re-measurement (e.g., layout object) */ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) { - const { panelRef, minWidth, getMaxWidth, onPersist, layoutDep } = options + const { panelRef, minWidth, getMaxWidth, onPersist, layoutDep, anchor = 'left' } = options const handleRef = useRef(initialWidth) const moveRef = useRef(0) @@ -36,13 +40,14 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) const onMove = useCallback( (event: MouseEvent) => { const maxWidth = getMaxWidth() - handleRef.current += event.clientX - moveRef.current + const delta = event.clientX - moveRef.current + handleRef.current += anchor === 'right' ? -delta : delta moveRef.current = event.clientX if (handleRef.current > minWidth && handleRef.current < maxWidth) { setWidth(handleRef.current) } }, - [minWidth, getMaxWidth] + [minWidth, getMaxWidth, anchor] ) const onUp = useCallback( diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 177c6eafd..a6ece67d3 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -23,6 +23,7 @@ import { // Value import is deref'd only inside effects, so the store/model cycle is safe import { store } from '../store' import type { State } from '../store' +import { CHAT_PANEL_WIDTH } from '../constants' import i18n from '../i18n' export type ChatToolCall = { @@ -39,7 +40,8 @@ export type ChatTranscriptMessage = export type IChatState = { open: boolean - expanded: boolean + /** Docked column width in px — drag-resized, persisted */ + width: number messages: ChatTranscriptMessage[] conversationId: string turnId: string @@ -57,7 +59,7 @@ export type IChatState = { export const defaultChatState: IChatState = { open: false, - expanded: false, + width: CHAT_PANEL_WIDTH, messages: [], conversationId: '', turnId: '', diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 8f3eb9519..9961ba53e 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -22,7 +22,7 @@ const chatTransform = createTransform( conversationId: inbound.conversationId, orgId: inbound.orgId, open: inbound.open, - expanded: inbound.expanded, + width: inbound.width, poppedOut: inbound.poppedOut, }), (outbound: Partial) => ({ ...defaultChatState, ...outbound }), From 65b4440ea77d0c03a126b8635ff86a411c976e44 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 25 Aug 2026 14:56:44 -0700 Subject: [PATCH 096/238] Usage limits Phase 4: the visible meter, quiet until it matters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/usage-limits.md D6. A gauge affordance in the chat header (ChatUsage) that tints as the tighter window fills and opens a popover with both windows — 5-hour session and this week — each a labeled bar, % used, and reset time; hidden entirely when both are unlimited. The client reads GET /api/usage on mount, after each turn, and refreshes the meter as spend moves. A turn refused for budget (429/503 usage_limit) throws UsageLimitError carrying the window + reset, and the chat shows '…Resets 4:30pm.' instead of a generic error. Typecheck clean; electron 29/29; frontend builds. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Chat/ChatPanel.tsx | 2 + frontend/src/components/Chat/ChatUsage.tsx | 75 +++++++++++++++++++++ frontend/src/components/Chat/ChatWindow.tsx | 2 + frontend/src/hooks/useChatSync.ts | 2 + frontend/src/i18n/locales/en/app.json | 6 ++ frontend/src/i18n/locales/en/notices.json | 1 + frontend/src/models/chat.ts | 30 ++++++++- frontend/src/services/agent.ts | 39 +++++++++++ 8 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Chat/ChatUsage.tsx diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index a78dc0537..5c33c33d8 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -10,6 +10,7 @@ import { usePanelDrag } from '../../hooks/usePanelDrag' import { PanelHandle } from '../PanelHandle' import { IconButton } from '../../buttons/IconButton' import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' +import { ChatUsage } from './ChatUsage' import { ChatBody } from './ChatBody' import browser from '../../services/browser' @@ -91,6 +92,7 @@ export const ChatPanel: React.FC = () => { onClick={() => dispatch.chat.popOut()} /> )} + dispatch.chat.set({ open: false })} /> diff --git a/frontend/src/components/Chat/ChatUsage.tsx b/frontend/src/components/Chat/ChatUsage.tsx new file mode 100644 index 000000000..f20e933a4 --- /dev/null +++ b/frontend/src/components/Chat/ChatUsage.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { Box, Popover, Typography, LinearProgress } from '@mui/material' +import { State } from '../../store' +import { formatReset } from '../../models/chat' +import { UsageWindow } from '../../services/agent' +import { IconButton } from '../../buttons/IconButton' +import { Icon } from '../Icon' + +const pct = (w: UsageWindow) => (w.unlimited || w.limitUsd <= 0 ? 0 : Math.min(100, Math.round((w.spentUsd / w.limitUsd) * 100))) + +/* One window's row in the popover: a labeled bar + reset time. */ +const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, window }) => { + const { t } = useTranslation() + const used = pct(window) + const color = used >= 90 ? 'error' : used >= 70 ? 'warning' : 'primary' + return ( + + + + {label} + + + {window.unlimited ? t('chat.usageUnlimited', 'No limit') : `${used}%`} + + + {!window.unlimited && ( + <> + + {window.resetsAt && ( + + {t('chat.usageResets', 'Resets {{when}}', { when: formatReset(window.resetsAt) })} + + )} + + )} + + ) +} + +/* The header usage affordance (docs/usage-limits.md D6): quiet until it matters. A gauge + icon that tints as the tighter window fills; clicking opens both windows' meters. Hidden + entirely when both windows are unlimited (nothing to show). */ +export const ChatUsage: React.FC = () => { + const { t } = useTranslation() + const usage = useSelector((state: State) => state.chat.usage) + const [anchorEl, setAnchorEl] = React.useState(null) + + if (!usage || (usage.session.unlimited && usage.weekly.unlimited)) return null + + const worst = Math.max(pct(usage.session), pct(usage.weekly)) + const color = worst >= 90 ? 'error' : worst >= 70 ? 'warning' : 'grayDarker' + + return ( + <> + setAnchorEl(e.currentTarget)} /> + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + slotProps={{ paper: { sx: { width: 260, padding: 2 } } }} + > + + + {t('chat.usageTitle', 'Usage')} + + + + + + ) +} diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 035366d87..7bc4cdb9e 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -6,6 +6,7 @@ import { Dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { useChatPopoutSync } from '../../hooks/useChatSync' import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' +import { ChatUsage } from './ChatUsage' import { ChatBody } from './ChatBody' /* Full-page chat for the popped-out window (?chatPopout boot flag). Display @@ -29,6 +30,7 @@ export const ChatWindow: React.FC = () => { }} > + { // while this window was away (plan D6/D11), and load the conversation history. dispatch.chat.syncTranscript() dispatch.chat.loadConversations() + dispatch.chat.loadUsage() // Completes a Hydra sign-in redirect if this page load carries ?code — // runs on mount regardless of whether the panel is open const handlers: PopoutMainHandlers = { @@ -69,6 +70,7 @@ export const useChatPopoutSync = (): void => { // while this window was away (plan D6/D11), and load the conversation history. dispatch.chat.syncTranscript() dispatch.chat.loadConversations() + dispatch.chat.loadUsage() // No syncOrg here: the popout keeps the org handed off with the // conversation (it has no sidebar to change it with) dispatch.chat.checkHealth() diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 733752719..ebea6b168 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -193,6 +193,12 @@ "interrupted": "Interrupted", "newChat": "New Chat", "history": "History", + "usage": "Usage", + "usageTitle": "Usage", + "usageSession": "5-hour session", + "usageWeekly": "This week", + "usageUnlimited": "No limit", + "usageResets": "Resets {{when}}", "historyEmpty": "No past conversations", "untitled": "New conversation", "deleteConversation": "Delete", diff --git a/frontend/src/i18n/locales/en/notices.json b/frontend/src/i18n/locales/en/notices.json index 63330fbde..6e55ec136 100644 --- a/frontend/src/i18n/locales/en/notices.json +++ b/frontend/src/i18n/locales/en/notices.json @@ -10,6 +10,7 @@ "passwordChanged": "Password changed successfully." }, "chat": { + "usageReset": "{{msg}} Resets {{when}}.", "authRequired": "Agent authentication required — sign in to continue.", "popupBlocked": "Pop out was blocked — allow popups for this site and try again.", "sessionExpired": "The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.", diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index a6ece67d3..91d24f5c0 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -7,7 +7,10 @@ import { fetchConversation, listConversations, deleteConversation, + fetchUsage, + UsageLimitError, type ConversationSummary, + type Usage, agentHealth, AgentAuthError, AgentEvent, @@ -47,6 +50,7 @@ export type IChatState = { turnId: string title: string conversations: ConversationSummary[] + usage: Usage | null /** Org the agent is scoped to; null = uninitialized, user id = personal */ orgId: string | null /** Conversation currently lives in the popout window (main window only) */ @@ -65,6 +69,7 @@ export const defaultChatState: IChatState = { turnId: '', title: '', conversations: [], + usage: null, orgId: null, poppedOut: false, streaming: false, @@ -153,6 +158,21 @@ export const toChatHandoff = (chat: IChatState): ChatHandoff => ({ const authRequiredError = () => i18n.t('notices:chat.authRequired', { defaultValue: 'The agent refused this session\u2019s credentials — refresh permissions to continue.' }) +/* A short, human reset time: a time-of-day within a day, else weekday + time. */ +export const formatReset = (iso: string | null): string => { + if (!iso) return '' + const at = new Date(iso) + const soon = at.getTime() - Date.now() < 24 * 60 * 60 * 1000 + return soon + ? at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + : at.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) +} + +const usageLimitMessage = (e: UsageLimitError): string => { + const when = formatReset(e.resetsAt) + return when ? i18n.t('notices:chat.usageReset', { defaultValue: '{{msg}} Resets {{when}}.', msg: e.message, when }) : e.message +} + let abortController: AbortController | null = null export default createModel()({ @@ -209,6 +229,7 @@ export default createModel()({ } catch (error) { flushDeltas() if (error instanceof AgentAuthError) dispatch.chat.set({ error: authRequiredError(), health: 'unauthorized' }) + else if (error instanceof UsageLimitError) dispatch.chat.applyEvent({ type: 'error', message: usageLimitMessage(error) }) else if ((error as Error).name !== 'AbortError') dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message }) } finally { @@ -216,8 +237,9 @@ export default createModel()({ abortController = null dispatch.chat.set({ streaming: false }) // A finished turn may have created (and titled) a new conversation — refresh the - // picker so it appears without a manual reload. + // picker; and the spend just moved, so refresh the usage meter too. dispatch.chat.loadConversations() + dispatch.chat.loadUsage() } }, /* The chat follows the app's active org (the sidebar selector) — the main @@ -298,6 +320,12 @@ export default createModel()({ await dispatch.chat.checkHealth() }, /* The history picker's list — refreshed on mount, after a turn, and after a delete. */ + /* The usage meter (docs/usage-limits.md D6) — refreshed on mount, after each turn, and + on open. Silent on failure; the last-known meter stands. */ + async loadUsage() { + const usage = await fetchUsage() + if (usage) dispatch.chat.set({ usage }) + }, async loadConversations() { try { dispatch.chat.set({ conversations: await listConversations() }) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 8e9940c1b..5f706dffe 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -36,6 +36,18 @@ export class AgentAuthError extends Error { } } +/* A usage window (session/weekly) or the fleet is spent — the turn was refused before it ran. + Carries which window and when it resets so the UI can say "resets at 4:30pm". */ +export class UsageLimitError extends Error { + constructor( + message: string, + readonly window: 'session' | 'weekly' | 'global', + readonly resetsAt: string | null, + ) { + super(message) + } +} + async function agentHeaders(method: string, path: string, json = true): Promise> { return { ...(json ? { 'Content-Type': 'application/json' } : {}), @@ -74,6 +86,11 @@ export async function streamChat(options: { signal, }) if (response.status === 401) throw new AgentAuthError() + if (response.status === 429 || response.status === 503) { + const body = (await response.json().catch(() => ({}))) as { error?: string; code?: string; window?: string; resetsAt?: string } + if (body.code === 'usage_limit') + throw new UsageLimitError(body.error || 'Usage limit reached', (body.window as 'session' | 'weekly' | 'global') ?? 'session', body.resetsAt ?? null) + } if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`) const reader = response.body.getReader() @@ -154,6 +171,28 @@ export async function deleteConversation(conversationId: string): Promise { + try { + const response = await fetch(`${agentURL()}/api/usage`, { headers: await agentHeaders('GET', '/api/usage', false) }) + if (!response.ok) return null + return (await response.json()) as Usage + } catch { + return null + } +} + // --- Background work (permitteer docs/remoteit-ai-agent.md D6/Phase 6) ----------------- /* Where the enrollment ceremony starts — a top-level navigation to the agent, which From a2b87212bfb4233e88fec36db3518deb5a32acf2 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 25 Aug 2026 15:18:43 -0700 Subject: [PATCH 097/238] =?UTF-8?q?The=20chat=20resets=20when=20the=20sign?= =?UTF-8?q?ed-in=20identity=20changes=20=E2=80=94=20never=20inherit=20anot?= =?UTF-8?q?her=20account's=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the 404-on-send when a persisted conversationId belongs to a different account than the one now signed in (the conversation, history, and usage are scoped by the permitteer subject the agent bills). Chat state gains ownerId — the signed-in user it belongs to; a useChatSync effect watching state.user.id resets the conversation + clears the conversation list and usage meter when it changes (an org switch keeps the account, so it doesn't trigger). Persisted, so same identity is preserved across reloads and only a real identity change (or the first load after this ships) resets. Typecheck clean; electron 29/29; build. Co-Authored-By: Claude Fable 5 --- frontend/src/hooks/useChatSync.ts | 12 ++++++++++++ frontend/src/models/chat.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index 664ee6296..9c2c1433b 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -14,8 +14,15 @@ const currentHandoff = () => toChatHandoff(store.getState().chat) export const useChatMainSync = (): void => { const open = useSelector((state: State) => state.chat.open) const activeId = useSelector((state: State) => state.accounts.activeId) + const userId = useSelector((state: State) => state.user.id) const dispatch = useDispatch() + // Reset the chat when the signed-in identity changes (a different account) — declared + // first so a persisted chat from a previous account is dropped before anything loads it. + useEffect(() => { + dispatch.chat.syncIdentity(userId) + }, [userId]) + useEffect(() => { // Mount-only: streaming state must not survive a reload, but reopening // the panel must not reset a still-running stream (closing the panel @@ -61,8 +68,13 @@ export const useChatMainSync = (): void => { display-only. */ export const useChatPopoutSync = (): void => { const { t } = useTranslation() + const userId = useSelector((state: State) => state.user.id) const dispatch = useDispatch() + useEffect(() => { + dispatch.chat.syncIdentity(userId) + }, [userId]) + useEffect(() => { document.title = t('chat.windowTitle', 'remote.it chat') dispatch.chat.resetTransient() diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 91d24f5c0..345da5832 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -51,6 +51,8 @@ export type IChatState = { title: string conversations: ConversationSummary[] usage: Usage | null + /** The signed-in user id this chat belongs to — reset the chat when it changes. */ + ownerId: string /** Org the agent is scoped to; null = uninitialized, user id = personal */ orgId: string | null /** Conversation currently lives in the popout window (main window only) */ @@ -70,6 +72,7 @@ export const defaultChatState: IChatState = { title: '', conversations: [], usage: null, + ownerId: '', orgId: null, poppedOut: false, streaming: false, @@ -320,6 +323,17 @@ export default createModel()({ await dispatch.chat.checkHealth() }, /* The history picker's list — refreshed on mount, after a turn, and after a delete. */ + /* Reset the chat when the signed-in IDENTITY changes (not an org switch — that keeps + your account). The conversations, transcript, and usage all belong to the permitteer + subject the agent scopes by; a persisted chat from a previous account must not carry + over (posting to it 404s, and its history isn't yours). Same identity → no-op. */ + async syncIdentity(userId: string, state) { + if (!userId || state.chat.ownerId === userId) return + dispatch.chat.clearConversation() + dispatch.chat.set({ ownerId: userId, conversations: [], usage: null }) + dispatch.chat.loadConversations() + dispatch.chat.loadUsage() + }, /* The usage meter (docs/usage-limits.md D6) — refreshed on mount, after each turn, and on open. Silent on failure; the last-known meter stands. */ async loadUsage() { From ee15f575d2018732e242353d95335f36a91dc600 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 12:31:47 -0700 Subject: [PATCH 098/238] feat(chat): use the Remote.It AI mark for the agent icon --- frontend/src/assets/RemoteAI.tsx | 25 ++++++++++++++++++++++ frontend/src/components/Chat/ChatBody.tsx | 4 ++-- frontend/src/components/Chat/ChatIntro.tsx | 2 +- frontend/src/components/Header/Header.tsx | 3 +-- frontend/src/components/Icon.tsx | 2 ++ 5 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 frontend/src/assets/RemoteAI.tsx diff --git a/frontend/src/assets/RemoteAI.tsx b/frontend/src/assets/RemoteAI.tsx new file mode 100644 index 000000000..759d10c6f --- /dev/null +++ b/frontend/src/assets/RemoteAI.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +/* The Remote.It AI mark — a bot head under signal arcs. Stroked in + currentColor so it takes the Icon component's color and size like any + other icon (see Icon.tsx's `remote-ai` case). */ +export const RemoteAI = props => { + return ( + + + + + ) +} diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 8d85d12dd..11ab26622 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -49,7 +49,7 @@ export const ChatBody: React.FC = () => { )} {signedOut ? ( - + {t('chat.signInNeeded', 'The AI agent needs permissions your session doesn\u2019t carry yet.')} {isChatPopout && ` ${t('chat.signInFromMain', 'Refresh permissions from the main app window.')}`} @@ -62,7 +62,7 @@ export const ChatBody: React.FC = () => { ) : unreachable && !messages.length ? ( - + {unavailableMessage} diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index 212a19dc4..786dbf45b 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -43,7 +43,7 @@ export const ChatIntro: React.FC = () => { marginBottom: 2, }} > - + {t('chat.introTitle', 'Remote.It AI')} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index c093a8eb6..4e40a1313 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -94,9 +94,8 @@ export const Header: React.FC = ({ panels = 1 }) => { )} {chatEnabled && !chatPoppedOut && ( dispatch.chat.set({ open: !chatOpen })} diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 3c40f2501..480b64f37 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -9,6 +9,7 @@ import { fal } from '@fortawesome/pro-light-svg-icons' import { far } from '@fortawesome/pro-regular-svg-icons' import { fas } from '@fortawesome/pro-solid-svg-icons' import { R3gray } from '../assets/R3gray' +import { RemoteAI } from '../assets/RemoteAI' library.add(fal, fab, far, fas) @@ -105,6 +106,7 @@ export const Icon = React.forwardRef( // Handle special icon cases if (name === 'r3') return + if (name === 'remote-ai') return let fontType: IconPrefix = 'far' From be4f4f09bd4ba32db9d57e7d9b54746ae37fc2bc Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 12:49:05 -0700 Subject: [PATCH 099/238] docs(env): document the permitteer OIDC and AI chat variables for local dev --- .env.example | 70 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index da9d89d2c..093939baf 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,73 @@ +# Copy to .env at the repo root AND to frontend/.env (vite reads frontend/.env for the +# renderer; the root copy is what the Electron main process and the build scripts read). +# Values below are the DEV stage — the setup local development is registered for. + # --- Permitteer sign-in (D8: renderer-owned, identical web/desktop) ------------------ # VITE_* reach the renderer (the whole OIDC client); bare OAUTH_ISSUER reaches the # Electron main process, which only bounces issuer navigations to the system browser. +# +# VITE_OAUTH_ISSUER is the ONLY var with no built-in fallback — without it the app logs +# "VITE_OAUTH_ISSUER is not configured" and sign-in never starts. +# +# Keep VITE_OAUTH_CLIENT_ID as remoteit_desktop for local work. Redirect URIs are +# registered PER CLIENT, and the deployed AI portal's client (remoteit_portal_ai, set on +# the Amplify branch) has only https://app.ai.remote.it/authCallback registered — using it +# locally fails the authorize call with a 400. remoteit_desktop carries both +# http://localhost:3003/authCallback and remoteit://authCallback, and both clients are +# granted the same agent/MCP authorization details, so the chat lane works either way. VITE_OAUTH_ISSUER="https://login.dev.remote.it" VITE_OAUTH_CLIENT_ID="remoteit_desktop" VITE_OAUTH_GRAPHQL_RESOURCE="https://graphql.dev.remote.it/graphql" +VITE_OAUTH_PASSPORT_RESOURCE="https://passport.dev.remote.it/account/api" +VITE_OAUTH_AGENT_RESOURCE="https://agent.dev.remote.it" +VITE_OAUTH_MCP_RESOURCE="https://mcp.dev.remote.it/mcp" +VITE_OAUTH_MCP_DETAIL="remoteit_mcp_dev" OAUTH_ISSUER="https://login.dev.remote.it" -BRAND=remoteit -AIRBRAKE_PROJECT_KEY="..." -VITE_PORTAL="..." -VITE_API_URL="..." -VITE_GRAPHQL_API="..." + +# --- Data plane — MUST name the same stage as the OIDC resources above --------------- +# The access token's audience IS the GraphQL/WebSocket URL: getApiResource() mints for +# VITE_OAUTH_GRAPHQL_RESOURCE while getApiURL() calls VITE_GRAPHQL_API. Point one at dev +# and the other at prod and every request 401s with nothing in the UI explaining why. +VITE_GRAPHQL_API="https://graphql.dev.remote.it/graphql" +VITE_WEBSOCKET_URL="wss://ws.dev.remote.it/v1" +VITE_API_URL="https://api.remote.it/apv/v27" VITE_GRAPHQL_BETA_API="..." -VITE_DEVELOPER_KEY="..." -VITE_WEBSOCKET_URL="..." VITE_WEBSOCKET_BETA_URL="..." + +# --- Remote.It AI chat --------------------------------------------------------------- +# On the AI portal (app.ai.remote.it) the chat is always on; elsewhere it soft-launches +# behind the Test UI (shift+option on the avatar menu). Set true locally to skip that. +VITE_CHAT_ALWAYS_ON="true" +# In dev, agentURL() ALWAYS returns the same-origin "/agent" vite proxy, so VITE_AGENT_URL +# is ignored locally — AGENT_PROXY_TARGET is the knob. Point it at the deployed dev agent, +# or at http://localhost:3001 to run the ai-agent service locally. DPoP proofs are signed +# over the canonical resource URL, so proxying does not break them. +AGENT_PROXY_TARGET="https://agent.dev.remote.it" +VITE_AGENT_URL="https://agent.dev.remote.it" + +# --- App ------------------------------------------------------------------------------ +BRAND=remoteit +VITE_PORTAL=false +VITE_DEVELOPER_KEY="..." + +# --- Third party ---------------------------------------------------------------------- +AIRBRAKE_PROJECT_KEY="..." +VITE_AIRBRAKE_ID="..." +VITE_AIRBRAKE_KEY="..." VITE_ZENDESK_URL="..." +VITE_ZENDESK_KEY="..." VITE_SEGMENT_PROJECT_PORTAL_KEY="..." -VITE_SEGMENT_PROJECT_KEY="..." \ No newline at end of file +VITE_SEGMENT_PROJECT_KEY="..." +VITE_GOOGLE_TAG_MANAGER_DESKTOP_KEY="..." +VITE_GOOGLE_TAG_MANAGER_PORTAL_KEY="..." +VITE_GOOGLE_TAG_MANAGER_ANDROID_KEY="..." +VITE_GOOGLE_TAG_MANAGER_IOS_KEY="..." + +# --- Desktop build signing (release builds only) -------------------------------------- +SKIP_SIGNING=true +APPLE_ID_PASSWORD="..." +APPLE_TEAM_ID="..." +WINDOWS_SIGN_USER_NAME="..." +WINDOWS_SIGN_USER_PASSWORD="..." +WINDOWS_SIGN_CREDENTIAL_ID="..." +WINDOWS_SIGN_USER_TOTP="..." From e09a8fabdd326704048a6958ca13a9a5ddf98d74 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 12:56:55 -0700 Subject: [PATCH 100/238] docs(env): stage and agent URLs move to Test Settings; document local dev setup --- .env.example | 32 +++++++++++++++----------------- readme.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 093939baf..77a90c98e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ -# Copy to .env at the repo root AND to frontend/.env (vite reads frontend/.env for the -# renderer; the root copy is what the Electron main process and the build scripts read). -# Values below are the DEV stage — the setup local development is registered for. +# Copy to .env in the REPO ROOT. `npm run copy-env` propagates it to frontend/ and +# electron/ for builds; the vite dev server reads frontend/.env, so copy it there too +# (or run copy-env) after changing anything. +# +# Values below are the DEV stage — the one local development is registered for. # --- Permitteer sign-in (D8: renderer-owned, identical web/desktop) ------------------ # VITE_* reach the renderer (the whole OIDC client); bare OAUTH_ISSUER reaches the @@ -12,7 +14,7 @@ # Keep VITE_OAUTH_CLIENT_ID as remoteit_desktop for local work. Redirect URIs are # registered PER CLIENT, and the deployed AI portal's client (remoteit_portal_ai, set on # the Amplify branch) has only https://app.ai.remote.it/authCallback registered — using it -# locally fails the authorize call with a 400. remoteit_desktop carries both +# locally fails authorize with a 400. remoteit_desktop carries both # http://localhost:3003/authCallback and remoteit://authCallback, and both clients are # granted the same agent/MCP authorization details, so the chat lane works either way. VITE_OAUTH_ISSUER="https://login.dev.remote.it" @@ -24,30 +26,26 @@ VITE_OAUTH_MCP_RESOURCE="https://mcp.dev.remote.it/mcp" VITE_OAUTH_MCP_DETAIL="remoteit_mcp_dev" OAUTH_ISSUER="https://login.dev.remote.it" -# --- Data plane — MUST name the same stage as the OIDC resources above --------------- -# The access token's audience IS the GraphQL/WebSocket URL: getApiResource() mints for -# VITE_OAUTH_GRAPHQL_RESOURCE while getApiURL() calls VITE_GRAPHQL_API. Point one at dev -# and the other at prod and every request 401s with nothing in the UI explaining why. -VITE_GRAPHQL_API="https://graphql.dev.remote.it/graphql" -VITE_WEBSOCKET_URL="wss://ws.dev.remote.it/v1" -VITE_API_URL="https://api.remote.it/apv/v27" -VITE_GRAPHQL_BETA_API="..." -VITE_WEBSOCKET_BETA_URL="..." +# The GraphQL and WebSocket URLs are NOT set here — pick the stage in the running app +# under Settings → Test Settings → API Target, which sets both together and mints the +# matching audience. (The access token's audience IS the GraphQL URL, so a hand-set URL +# that disagrees with VITE_OAUTH_GRAPHQL_RESOURCE 401s with nothing explaining why.) # --- Remote.It AI chat --------------------------------------------------------------- # On the AI portal (app.ai.remote.it) the chat is always on; elsewhere it soft-launches # behind the Test UI (shift+option on the avatar menu). Set true locally to skip that. VITE_CHAT_ALWAYS_ON="true" -# In dev, agentURL() ALWAYS returns the same-origin "/agent" vite proxy, so VITE_AGENT_URL -# is ignored locally — AGENT_PROXY_TARGET is the knob. Point it at the deployed dev agent, -# or at http://localhost:3001 to run the ai-agent service locally. DPoP proofs are signed -# over the canonical resource URL, so proxying does not break them. +# In dev, agentURL() returns the same-origin "/agent" vite proxy unless Test Settings +# overrides it, so AGENT_PROXY_TARGET is the knob here: the deployed dev agent, or +# http://localhost:3001 to run the ai-agent service locally. DPoP proofs are signed over +# the canonical resource URL, so proxying does not break them. AGENT_PROXY_TARGET="https://agent.dev.remote.it" VITE_AGENT_URL="https://agent.dev.remote.it" # --- App ------------------------------------------------------------------------------ BRAND=remoteit VITE_PORTAL=false +VITE_API_URL="https://api.remote.it/apv/v27" VITE_DEVELOPER_KEY="..." # --- Third party ---------------------------------------------------------------------- diff --git a/readme.md b/readme.md index be1c3ccf2..ec2eb3fd8 100644 --- a/readme.md +++ b/readme.md @@ -100,8 +100,46 @@ Windows C:\Users\%username%\AppData\Local\temp\remoteit.log ### Setup -Get a copy of the .env file -place the .env file in the root directory - it will be copied into the sub projects at start +Copy `.env.example` to `.env` in the root directory, then fill in the private values +(FontAwesome, Airbrake, Zendesk, Segment, signing) from a teammate or 1Password. +`npm run copy-env` propagates the root `.env` into `frontend/` and `electron/` for builds; +the vite dev server reads `frontend/.env`, so copy it there too after changing anything. + +#### Sign-in (permitteer OIDC) + +Sign-in is renderer-owned and identical on web and desktop — the backend never touches auth. +`VITE_OAUTH_ISSUER` is the only variable with no built-in fallback; without it the app logs +`VITE_OAUTH_ISSUER is not configured` and sign-in never starts. + +Leave `VITE_OAUTH_CLIENT_ID` as `remoteit_desktop` locally. Redirect URIs are registered +**per client**: the deployed AI portal uses `remoteit_portal_ai`, which only has +`https://app.ai.remote.it/authCallback` registered, so copying that value from the Amplify +branch config makes authorize fail with a 400. `remoteit_desktop` carries both +`http://localhost:3003/authCallback` and `remoteit://authCallback` (the Electron deep link). + +Browse to `http://localhost:3003` exactly. `npm start` binds `0.0.0.0`, but reaching the app +over a LAN IP is a non-secure origin, where `crypto.subtle` is unavailable and the DPoP +proofs every token call carries silently degrade. + +#### Choosing a stage + +The GraphQL and WebSocket URLs are not environment variables — pick the stage in the running +app under **Settings → Test Settings → API Target**, which sets both together and mints the +matching token audience. The options come from the auth server's own allowlist, so an +illegal target fails at mint with a legible error instead of ambient 403s later. + +This matters because the access token's audience *is* the GraphQL URL: a hand-set URL that +disagrees with `VITE_OAUTH_GRAPHQL_RESOURCE` returns 401s with nothing in the UI explaining +why. Reach Test Settings by holding **shift+option** and clicking your avatar → Test UI. + +#### AI chat + +The chat is always on where `VITE_CHAT_ALWAYS_ON=true` (the AI portal, and locally); elsewhere +it soft-launches behind the Test UI. In dev its requests go through the same-origin `/agent` +vite proxy, so `AGENT_PROXY_TARGET` is what selects the service — the deployed dev agent, or +`http://localhost:3001` to run `ai-agent` locally. **Settings → Test Settings → Override agent +service** overrides it at runtime without a restart (https only). DPoP proofs are signed over +the canonical resource URL, so neither the proxy nor the override invalidates them. To use the fontawesome fonts: [Installation Instructions](https://fontawesome.com/how-to-use/on-the-web/setup/using-package-managers#installing-pro) From f63ddc7b84764670912881b667d9ff95f3550588 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 13:03:23 -0700 Subject: [PATCH 101/238] fix(env): default the graphql and websocket URLs to the OIDC resource stage --- .env.example | 10 ++++++---- frontend/src/constants.ts | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 77a90c98e..b05b855f1 100644 --- a/.env.example +++ b/.env.example @@ -26,10 +26,12 @@ VITE_OAUTH_MCP_RESOURCE="https://mcp.dev.remote.it/mcp" VITE_OAUTH_MCP_DETAIL="remoteit_mcp_dev" OAUTH_ISSUER="https://login.dev.remote.it" -# The GraphQL and WebSocket URLs are NOT set here — pick the stage in the running app -# under Settings → Test Settings → API Target, which sets both together and mints the -# matching audience. (The access token's audience IS the GraphQL URL, so a hand-set URL -# that disagrees with VITE_OAUTH_GRAPHQL_RESOURCE 401s with nothing explaining why.) +# The GraphQL and WebSocket URLs are NOT set here. They default to the stage named by +# VITE_OAUTH_GRAPHQL_RESOURCE above (graphql.dev…/graphql -> wss://ws.dev…/v1), so a fresh +# checkout works out of the box; switch stages in the running app under +# Settings → Test Settings → API Target, which sets both together and mints the matching +# audience. Setting VITE_GRAPHQL_API/VITE_WEBSOCKET_URL by hand still works, but a value +# that disagrees with the OIDC resource 401s with nothing in the UI explaining why. # --- Remote.It AI chat --------------------------------------------------------------- # On the AI portal (app.ai.remote.it) the chat is always on; elsewhere it soft-launches diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 6fe5a8f62..96c7ae5c0 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -22,16 +22,25 @@ export const OAUTH_AGENT_ACTOR = 'svc_ai_agent' export const API_URL = env.VITE_API_URL || 'https://api.remote.it/apv/v27' export const AUTH_API_URL = env.VITE_AUTH_API_URL || env.AUTH_API_URL || 'https://auth.api.remote.it/v1' -export const GRAPHQL_API = env.VITE_GRAPHQL_API || 'https://api.remote.it/graphql/v1' -export const GRAPHQL_BETA_API = env.VITE_GRAPHQL_BETA_API || 'https://api.remote.it/graphql/beta' +// The access token's audience IS the GraphQL URL, so the data plane defaults to the +// resource we mint for rather than to a fixed stage — otherwise an install that sets only +// the OIDC vars calls one stage with another stage's token and 401s with nothing in the UI +// explaining why. Set VITE_GRAPHQL_API (or pick a stage in Test Settings) to override. +export const GRAPHQL_API = env.VITE_GRAPHQL_API || OAUTH_GRAPHQL_RESOURCE +export const GRAPHQL_BETA_API = env.VITE_GRAPHQL_BETA_API || GRAPHQL_API export const PORTAL = (env.VITE_PORTAL || env.PORTAL) === 'true' ? true : false export const PORTAL_URL = env.VITE_PORTAL_URL || brand.package?.homepage || 'https://app.remote.it' export const DEVELOPER_KEY = env.VITE_DEVELOPER_KEY || 'Mjc5REIzQUQtMTQyRC00NTcxLTlGRDktMTVGNzVGNDYxQkE3' export const PROTOCOL = env.PROTOCOL || `${brand.name}://` -export const WEBSOCKET_URL = env.VITE_WEBSOCKET_URL -export const WEBSOCKET_BETA_URL = env.VITE_WEBSOCKET_BETA_URL +// Pairs with the GraphQL stage above (graphql.dev…/graphql <-> wss://ws.dev…/v1), the same +// pairing Test Settings' stage picker applies. Previously these had NO fallback: dropping +// the env var left the socket URL undefined, which breaks the app before the UI that could +// fix it is reachable. +const graphqlStage = GRAPHQL_API.match(/^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/)?.[1] +export const WEBSOCKET_URL = env.VITE_WEBSOCKET_URL || `wss://ws${graphqlStage ? `.${graphqlStage}` : ''}.remote.it/v1` +export const WEBSOCKET_BETA_URL = env.VITE_WEBSOCKET_BETA_URL || WEBSOCKET_URL export const PORT = env.VITE_PORT || 29999 export const PASSWORD_MIN_LENGTH = env.PASSWORD_MIN_LENGTH ? Number(env.PASSWORD_MIN_LENGTH) : 7 export const PASSWORD_MAX_LENGTH = env.PASSWORD_MAX_LENGTH ? Number(env.PASSWORD_MAX_LENGTH) : 64 From 3861e0607cace63f93f2ac2c955a68945ad82474 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 13:24:32 -0700 Subject: [PATCH 102/238] style(chat): enlarge the agent icon in the header --- frontend/src/components/Header/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 4e40a1313..a5cd21ec3 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -95,7 +95,7 @@ export const Header: React.FC = ({ panels = 1 }) => { {chatEnabled && !chatPoppedOut && ( dispatch.chat.set({ open: !chatOpen })} From 4bc75d658951c69d87959c0b9b1a3ff252e7f862 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 14:31:15 -0700 Subject: [PATCH 103/238] refactor(test-settings): one Custom toggle for the graphql, websocket and agent URLs --- frontend/src/i18n/locales/en/app.json | 10 ++--- frontend/src/pages/TestPage.tsx | 57 ++++++++++++--------------- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index ebea6b168..0962db969 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -218,7 +218,7 @@ "send": "Send", "signIn": "Refresh permissions", "signInFromMain": "Refresh permissions from the main app window.", - "signInNeeded": "The AI agent needs permissions your session doesn\u2019t carry yet.", + "signInNeeded": "The AI agent needs permissions your session doesn’t carry yet.", "stop": "Stop", "toolRequest": "The agent wants to run {{tool}}", "toolsUsed_one": "Used {{count}} tool", @@ -2027,15 +2027,15 @@ "agentURLInvalid": "Agent service URL must start with https://", "apiTarget": "API Target", "backgroundWork": "Background work", - "backgroundWorkOff": "Off — the agent only works while you\u2019re here. Enabling grants it a separate, narrower permission you can revoke any time.", - "backgroundWorkOn": "Enabled — while you\u2019re away the agent can look and watch, not touch. Turns you start can finish without you.", + "backgroundWorkOff": "Off — the agent only works while you’re here. Enabling grants it a separate, narrower permission you can revoke any time.", + "backgroundWorkOn": "Enabled — while you’re away the agent can look and watch, not touch. Turns you start can finish without you.", "backgroundWorkUnknown": "Checking…", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", "customAPITarget": "Custom", "customAPITargetHint": "Point at a URL the authorization server has not registered.", - "customGraphQLURL": "Custom GraphQL URL (advanced)", - "customWebSocketURL": "Custom WebSocket URL (advanced)", + "customGraphQLURL": "GraphQL URL", + "customWebSocketURL": "WebSocket URL", "disableTestUI": "Disable Test UI", "disableTestUIHint": "To re-enable the alpha UI you will have to select the Avatar menu while holding alt-shift.", "featureLabel": "{{name}} (default {{state}})", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 6e881fec7..63da8c672 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -101,16 +101,24 @@ export const TestPage: React.FC = () => { const customSelected = customMode ?? (!!apis.switchApi && stagePairs.length > 0 && !stagePairs.some(p => p.graphql === currentGraphql)) - async function selectCustom() { + /* One switch owns the whole custom target — GraphQL, WebSocket and the agent service. + Off returns every one of them to the stage the build ships with. switchAgent is + browser-only, so it rides the ui state and never the backend preferences emit. */ + async function toggleCustom() { setMintError('') - setCustomMode(true) - const values = { - switchApi: true, - apiGraphqlURL: apis.apiGraphqlURL || getApiURL() || '', - webSocketURL: apis.webSocketURL || getWebSocketURL() || '', - } - await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) + const on = !customSelected + setCustomMode(on) + const values = on + ? { + switchApi: true, + apiGraphqlURL: apis.apiGraphqlURL || getApiURL() || '', + webSocketURL: apis.webSocketURL || getWebSocketURL() || '', + } + : { switchApi: false } + await dispatch.ui.setPersistent({ apis: { ...apis, ...values, switchAgent: on } }) emit('preferences', { ...preferences, ...values }) + emit('binaries/install') + cloudSync.all() } async function selectStage(pair: StagePair) { @@ -122,7 +130,7 @@ export const TestPage: React.FC = () => { apiGraphqlURL: pair.graphql!, ...(pair.ws ? { webSocketURL: pair.ws } : {}), } - await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) + await dispatch.ui.setPersistent({ apis: { ...apis, ...values, switchAgent: false } }) emit('preferences', { ...preferences, ...values }) try { if (!isDefault) { @@ -211,11 +219,12 @@ export const TestPage: React.FC = () => { onClick={() => selectStage(pair)} /> ))} - {!!mintError && ( @@ -231,7 +240,7 @@ export const TestPage: React.FC = () => { { /> { }} hideIcon /> - - - - setAgentPreference('switchAgent', !apis.switchAgent)} - toggle={!!apis.switchAgent} - /> - - - { From 1063e350b9127f21a7c7820889e53750f0bb28b5 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 14:42:39 -0700 Subject: [PATCH 104/238] refactor(test-settings): drop the stage picker and query header; restore the Override default APIs toggle --- frontend/src/constants.ts | 1 - frontend/src/helpers/apiHelper.ts | 10 +-- frontend/src/i18n/locales/de/app.json | 4 - frontend/src/i18n/locales/en/app.json | 6 -- frontend/src/i18n/locales/es/app.json | 4 - frontend/src/i18n/locales/ja/app.json | 4 - frontend/src/pages/TestPage.tsx | 98 ++---------------------- frontend/src/services/cloudController.ts | 3 +- frontend/src/services/get.ts | 4 +- frontend/src/services/jobLogs.ts | 4 +- frontend/src/services/post.ts | 4 +- 11 files changed, 13 insertions(+), 129 deletions(-) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 96c7ae5c0..65f4e0aed 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -57,7 +57,6 @@ export const BT_UUIDS = { export const DEMO_DEVICE_CLAIM_CODE = 'GUESTVPC' export const DEMO_DEVICE_ID = '80:00:01:7F:7E:00:48:1B' -export const TEST_HEADER = 'test-header' //Airbrake export const AIRBRAKE_ID = parseInt(env.VITE_AIRBRAKE_ID || '', 10) diff --git a/frontend/src/helpers/apiHelper.ts b/frontend/src/helpers/apiHelper.ts index 1762d0481..c3235d223 100644 --- a/frontend/src/helpers/apiHelper.ts +++ b/frontend/src/helpers/apiHelper.ts @@ -1,4 +1,4 @@ -import { GRAPHQL_API, GRAPHQL_BETA_API, API_URL, WEBSOCKET_BETA_URL, WEBSOCKET_URL, TEST_HEADER, OAUTH_GRAPHQL_RESOURCE } from '../constants' +import { GRAPHQL_API, GRAPHQL_BETA_API, API_URL, WEBSOCKET_BETA_URL, WEBSOCKET_URL, OAUTH_GRAPHQL_RESOURCE } from '../constants' import { graphQLRentANode } from '../services/graphQLMutation' import { version } from './versionHelper' import { store } from '../store' @@ -45,14 +45,6 @@ export function getWebSocketURL(): string | undefined { return apis?.webSocketURL && apis?.switchApi ? apis.webSocketURL : defaultURL } -export function getTestHeader(): { [key: string]: string } { - const testHeader = window.localStorage.getItem(TEST_HEADER) - if (!testHeader) return {} - const parts = testHeader.split(':') - console.log('USING TEST HEADER', { [parts[0].trim()]: parts[1].trim() }) - return { [parts[0].trim()]: parts[1].trim() } -} - export async function apiError(error: unknown) { const { ui } = store.dispatch console.error('API ERROR:', error) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index d4c1f83a6..1ba6bf5e7 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -1955,8 +1955,6 @@ "title": "Tags" }, "testPage": { - "addQueryHeader": "Abfrage-Header hinzufügen", - "addQueryHeaderPlaceholder": "Beispiel: \"key:value\"", "agentURL": "Agentendienst-URL", "agentURLInvalid": "Die Agentendienst-URL muss mit https:// beginnen", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", @@ -1969,8 +1967,6 @@ "licenseMessageCleared": "Lizenzmeldung gelöscht", "licensingOptions": "Lizenzierungsoptionen", "mcpAudience": "Agent-MCP-Audience", - "overrideAgent": "Agentendienst überschreiben", - "overrideAgentSub": "Richtet den Mycal-Chat auf einen bereitgestellten Agenten (nur https). dev-ai-agent gehört zur Audience https://mcp.demo.remote.it/mcp. Melden Sie sich nach Änderungen erneut beim Agenten an.", "overrideDefaultAPIs": "Standard-APIs überschreiben", "overrideLicensesAndLimits": "Lizenzen und Limits überschreiben", "reset": "Zurücksetzen", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 0962db969..6e43c0ac9 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2021,8 +2021,6 @@ "title": "Tags" }, "testPage": { - "addQueryHeader": "Add query header", - "addQueryHeaderPlaceholder": "Example: \"key:value\"", "agentURL": "Agent service URL", "agentURLInvalid": "Agent service URL must start with https://", "apiTarget": "API Target", @@ -2032,8 +2030,6 @@ "backgroundWorkUnknown": "Checking…", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", - "customAPITarget": "Custom", - "customAPITargetHint": "Point at a URL the authorization server has not registered.", "customGraphQLURL": "GraphQL URL", "customWebSocketURL": "WebSocket URL", "disableTestUI": "Disable Test UI", @@ -2044,8 +2040,6 @@ "licenseMessageCleared": "License message cleared", "licensingOptions": "Licensing Options", "mintError": "This target was refused at token mint: {{error}}", - "overrideAgent": "Override agent service", - "overrideAgentSub": "Point the Remote.It AI chat at a deployed agent (https only). Auth rides your app session either way — the token is minted for the agent audience, so the target must trust this stage.", "overrideDefaultAPIs": "Override default APIs", "overrideLicensesAndLimits": "Override licenses and limits", "reset": "Reset", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 0a8a63375..3663f5463 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -1991,8 +1991,6 @@ "title": "Etiquetas" }, "testPage": { - "addQueryHeader": "Agregar encabezado de consulta", - "addQueryHeaderPlaceholder": "Ejemplo: \"key:value\"", "agentURL": "URL del servicio del agente", "agentURLInvalid": "La URL del servicio del agente debe empezar por https://", "clearViewedAnnouncements": "Borrar anuncios vistos", @@ -2005,8 +2003,6 @@ "licenseMessageCleared": "Mensaje de licencia borrado", "licensingOptions": "Opciones de licencia", "mcpAudience": "Audiencia MCP del agente", - "overrideAgent": "Anular servicio del agente", - "overrideAgentSub": "Apunta el chat de Mycal a un agente desplegado (solo https). dev-ai-agent se empareja con la audiencia https://mcp.demo.remote.it/mcp. Vuelva a iniciar sesión en el agente después de cambiar estos valores.", "overrideDefaultAPIs": "Anular las API predeterminadas", "overrideLicensesAndLimits": "Anular licencias y límites", "reset": "Restablecer", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 8eb206576..fc0d67261 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -1919,8 +1919,6 @@ "title": "タグ" }, "testPage": { - "addQueryHeader": "クエリヘッダーを追加", - "addQueryHeaderPlaceholder": "例: \"key:value\"", "agentURL": "エージェントサービス URL", "agentURLInvalid": "エージェントサービス URL は https:// で始まる必要があります", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", @@ -1933,8 +1931,6 @@ "licenseMessageCleared": "ライセンスメッセージがクリアされました", "licensingOptions": "ライセンスオプション", "mcpAudience": "エージェント MCP オーディエンス", - "overrideAgent": "エージェントサービスを上書き", - "overrideAgentSub": "Mycal チャットをデプロイ済みエージェントに向けます(https のみ)。dev-ai-agent はオーディエンス https://mcp.demo.remote.it/mcp とペアです。変更後はエージェントに再サインインしてください。", "overrideDefaultAPIs": "デフォルトのAPIをオーバーライド", "overrideLicensesAndLimits": "ライセンスと制限をオーバーライド", "reset": "リセット", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 63da8c672..f57a4d51e 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,19 +1,15 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import cloudSync from '../services/CloudSync' -import { TEST_HEADER, OAUTH_GRAPHQL_RESOURCE } from '../constants' import { Dispatch, State } from '../store' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL } from '../helpers/apiHelper' -import { bindableResources } from '../services/permitteerAccount' import { oidcAccessToken } from '../services/oidc' import { selectLimitsLookup, selectLimits } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' import { ListItemSetting } from '../components/ListItemSetting' -import { ListItemRadio } from '../components/ListItemRadio' import { Container } from '../components/Container' -import { PortalUI } from '../components/PortalUI' import { Title } from '../components/Title' import { Quote } from '../components/Quote' import { emit } from '../services/Controller' @@ -23,7 +19,6 @@ import { windowOpen } from '../services/browser' export const TestPage: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() - const [testHeader, setTestHeader] = useState(window.localStorage.getItem(TEST_HEADER) || '') const { tests, informed } = useSelector((state: State) => state.plans) const apis = useSelector((state: State) => state.ui.apis) const testUI = useSelector((state: State) => state.ui.testUI) @@ -62,44 +57,11 @@ export const TestPage: React.FC = () => { setBackgroundEnrolled(await backgroundStatus()) } - // --- the stage-pair switcher (D10+D11a, permitteer docs/remoteit-desktop-login.md 4c) ---- - // The options come FROM the AS: the client's own allowlist joined to registry names, so the - // picker and the mint-time guardrail can never disagree. Identifiers group into stage pairs - // (graphql + events); one selection sets BOTH URLs and mints BOTH audiences immediately, so - // an illegal target fails here with a legible error, never as ambient 403s an hour later. - const [targets, setTargets] = useState>([]) + // `switchApi` is the override — the Electron backend reads it to configure the CLI binary. + const customSelected = !!apis.switchApi + // A hand-typed URL is only legal if the AS will mint for it; the GraphQL field reports + // the refusal here rather than letting it surface later as ambient 403s. const [mintError, setMintError] = useState('') - useEffect(() => { - bindableResources().then(setTargets) - }, []) - - type StagePair = { stage: string; name: string; graphql?: string; ws?: string } - const stagePairs: StagePair[] = React.useMemo(() => { - const pairs = new Map() - for (const target of targets) { - const gql = target.identifier.match(/^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/) - const ws = target.identifier.match(/^wss:\/\/ws(?:\.([a-z0-9-]+))?\.remote\.it\/v1$/) - if (!gql && !ws) continue // passport / account-api entries are not switch targets - const stage = (gql?.[1] ?? ws?.[1]) || 'prod' - const pair = pairs.get(stage) || { stage, name: stage } - if (gql) { - pair.graphql = target.identifier - pair.name = target.name - } else pair.ws = target.identifier - pairs.set(stage, pair) - } - return [...pairs.values()].filter(pair => pair.graphql) - }, [targets]) - - // Which radio is lit. The override flag is DERIVED from the choice — selecting the stage - // this build ships with is the same thing the old "Override default APIs" switch expressed, - // so the switch is gone and `switchApi` (still read by the Electron backend to configure - // the CLI binary) is set from here. `customMode` is held locally because a hand-typed URL - // may coincide with a registered stage, and the choice should not silently jump to it. - const currentGraphql = apis.switchApi && apis.apiGraphqlURL ? apis.apiGraphqlURL : OAUTH_GRAPHQL_RESOURCE - const [customMode, setCustomMode] = useState(undefined) - const customSelected = - customMode ?? (!!apis.switchApi && stagePairs.length > 0 && !stagePairs.some(p => p.graphql === currentGraphql)) /* One switch owns the whole custom target — GraphQL, WebSocket and the agent service. Off returns every one of them to the stage the build ships with. switchAgent is @@ -107,7 +69,6 @@ export const TestPage: React.FC = () => { async function toggleCustom() { setMintError('') const on = !customSelected - setCustomMode(on) const values = on ? { switchApi: true, @@ -121,29 +82,6 @@ export const TestPage: React.FC = () => { cloudSync.all() } - async function selectStage(pair: StagePair) { - setMintError('') - setCustomMode(false) - const isDefault = pair.graphql === OAUTH_GRAPHQL_RESOURCE - const values = { - switchApi: !isDefault, - apiGraphqlURL: pair.graphql!, - ...(pair.ws ? { webSocketURL: pair.ws } : {}), - } - await dispatch.ui.setPersistent({ apis: { ...apis, ...values, switchAgent: false } }) - emit('preferences', { ...preferences, ...values }) - try { - if (!isDefault) { - await oidcAccessToken(pair.graphql!) - if (pair.ws) await oidcAccessToken(pair.ws) - } - emit('binaries/install') - cloudSync.all() - } catch (error) { - setMintError(error instanceof Error ? error.message : String(error)) - } - } - return ( { subLabel={t('testPage.clearViewedAnnouncementsHint', 'Marks all loaded announcements unread for this account.')} onClick={() => dispatch.announcements.clearRead()} /> - - { - window.localStorage.setItem(TEST_HEADER, result.toString()) - setTestHeader(result.toString()) - }} - hideIcon - /> - {t('testPage.apiTarget', 'API Target')} - {stagePairs.map(pair => ( - selectStage(pair)} - /> - ))} diff --git a/frontend/src/services/cloudController.ts b/frontend/src/services/cloudController.ts index bf0992215..fcba0f83a 100644 --- a/frontend/src/services/cloudController.ts +++ b/frontend/src/services/cloudController.ts @@ -6,7 +6,7 @@ import { selectJob } from '../selectors/scripting' import { AxiosResponse } from 'axios' import { isReverseProxy } from '../models/applicationTypes' import { getAccountIds, accountFromDevice } from '../models/accounts' -import { getWebSocketURL, getTestHeader } from '../helpers/apiHelper' +import { getWebSocketURL } from '../helpers/apiHelper' import { DEVICE_TYPE } from '@common/applications' import { getToken } from './remoteit' import { oidcAccessToken } from './oidc' @@ -166,7 +166,6 @@ class CloudController { headers: { authorization: await wsAuthorization(), 'User-Agent': `remoteit/${version} ${agent()}`, - ...getTestHeader(), }, query: ` { diff --git a/frontend/src/services/get.ts b/frontend/src/services/get.ts index 7d40bff5a..53fd0466b 100644 --- a/frontend/src/services/get.ts +++ b/frontend/src/services/get.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' +import { getApiURL } from '../helpers/apiHelper' import { getToken } from './remoteit' import { apiError } from './post' import { store } from '../store' @@ -13,7 +13,7 @@ export async function get(path: string = '') { return } - const headers: any = { Authorization: token, ...getTestHeader() } + const headers: any = { Authorization: token } // Add x-r3-user header if in view-as mode const viewAsUser = store.getState().ui.viewAsUser diff --git a/frontend/src/services/jobLogs.ts b/frontend/src/services/jobLogs.ts index 6da420f30..66d145e1e 100644 --- a/frontend/src/services/jobLogs.ts +++ b/frontend/src/services/jobLogs.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' +import { getApiURL } from '../helpers/apiHelper' import { getToken } from './remoteit' import { store } from '../store' @@ -41,7 +41,7 @@ export async function getJobLogs(jobId: string): Promise { return { kind: 'error', status: 401, message: 'Not signed in' } } - const headers: any = { Authorization: token, ...getTestHeader() } + const headers: any = { Authorization: token } const viewAsUser = store.getState().ui.viewAsUser if (viewAsUser) headers['X-R3-User'] = viewAsUser.id diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index 30eb33c79..6bbbc6a2f 100644 --- a/frontend/src/services/post.ts +++ b/frontend/src/services/post.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' +import { getApiURL } from '../helpers/apiHelper' import { getToken } from './remoteit' import { store } from '../store' import network from './Network' @@ -20,7 +20,7 @@ export async function post(data: ILookup = {}, path: string = '') { return } - const headers: any = { Authorization: token, ...getTestHeader() } + const headers: any = { Authorization: token } // Add x-r3-user header if in view-as mode const viewAsUser = store.getState().ui.viewAsUser From 1d5f640a5e70a2f70943aff0622840474b198377 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 14:43:55 -0700 Subject: [PATCH 105/238] refactor(test-settings): move AI background work into test options with a shorter description --- frontend/src/i18n/locales/en/app.json | 6 +++--- frontend/src/pages/TestPage.tsx | 26 +++++++++++++------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 6e43c0ac9..9fdcd624b 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2024,9 +2024,9 @@ "agentURL": "Agent service URL", "agentURLInvalid": "Agent service URL must start with https://", "apiTarget": "API Target", - "backgroundWork": "Background work", - "backgroundWorkOff": "Off — the agent only works while you’re here. Enabling grants it a separate, narrower permission you can revoke any time.", - "backgroundWorkOn": "Enabled — while you’re away the agent can look and watch, not touch. Turns you start can finish without you.", + "backgroundWork": "AI background work", + "backgroundWorkOff": "The agent only works while you are here.", + "backgroundWorkOn": "The agent can read and watch while you are away.", "backgroundWorkUnknown": "Checking…", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index f57a4d51e..98244c8fe 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -128,6 +128,19 @@ export const TestPage: React.FC = () => { subLabel={t('testPage.clearViewedAnnouncementsHint', 'Marks all loaded announcements unread for this account.')} onClick={() => dispatch.announcements.clearRead()} /> + (backgroundEnrolled ? disableBackground() : connectBackground())} + /> {t('testPage.apiTarget', 'API Target')} @@ -206,19 +219,6 @@ export const TestPage: React.FC = () => { - (backgroundEnrolled ? disableBackground() : connectBackground())} - /> {t('testPage.features', 'Features')} From cda9b74f56c434240459801324011097c70331f6 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 14:44:49 -0700 Subject: [PATCH 106/238] fix(test-settings): show the resolved default in the agent service URL field --- frontend/src/pages/TestPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 98244c8fe..0d752c366 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import cloudSync from '../services/CloudSync' +import { OAUTH_AGENT_RESOURCE } from '../constants' import { Dispatch, State } from '../store' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL } from '../helpers/apiHelper' @@ -196,11 +197,10 @@ export const TestPage: React.FC = () => { hideIcon /> { const value = url.toString().trim() From 10985f6e0905dd2ad311dcc9a2dc7c8aed702bb0 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 14:59:05 -0700 Subject: [PATCH 107/238] fix(oidc): serialize token mints per audience and surface mint refusals --- frontend/src/services/oidc.ts | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index f18fb82aa..2c2043121 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -69,7 +69,11 @@ type Flow = { verifier: string; state: string; nonce: string; redirectUri: strin type Stored = { refresh_token: string; id_token?: string } let access: { [resource: string]: { token: string; exp: number; type?: string } } = {} -let refreshing: Promise | undefined +let minting: Promise = Promise.resolve() +/* Why the last mint for an audience failed. refresh() reports a refusal by returning + '' so callers can degrade quietly, which loses the AS's reason — keep it here so a + settings screen can say "not covered by this grant" instead of just "refused". */ +let mintErrors: { [resource: string]: string } = {} let discovery: { authorization_endpoint: string; token_endpoint: string; end_session_endpoint?: string; end_session_api_endpoint?: string } | undefined const b64u = (bytes: Uint8Array) => @@ -234,15 +238,22 @@ export async function oidcCompleteFromUrl(): Promise { return claims } -/** Current access token for the graphql audience ('' when signed out). Refreshes - * single-flight — the rotating single-use refresh makes a concurrent second refresh - * token REUSE, which revokes the whole family. */ +/** Current access token for `resource` ('' when signed out). Mints are SERIALIZED, not + * shared: the rotating single-use refresh makes a concurrent second refresh a token + * REUSE, which revokes the whole family — but a single shared promise handed a queued + * caller whichever audience happened to be minting, so an agent-audience token would go + * out to the account API and come back 401. Queue instead, and re-read the cache after + * the wait so N callers for one audience still cost one refresh. */ export async function oidcAccessToken(resource: string = OAUTH_GRAPHQL_RESOURCE): Promise { - const cached = access[resource] - const now = Math.floor(Date.now() / 1000) - if (cached && cached.exp - now > 30) return cached.token - if (!refreshing) refreshing = refresh(resource).finally(() => (refreshing = undefined)) - return refreshing + const fresh = () => { + const cached = access[resource] + return cached && cached.exp - Math.floor(Date.now() / 1000) > 30 ? cached.token : undefined + } + const hit = fresh() + if (hit) return hit + const next = minting.then(() => fresh() ?? refresh(resource)) + minting = next.catch(() => {}) + return next } async function refresh(resource: string): Promise { @@ -258,9 +269,11 @@ async function refresh(resource: string): Promise { persist({ refresh_token: body.refresh_token || current.refresh_token, id_token: body.id_token || current.id_token }) const at = decodeJwt(body.access_token) access[resource] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } + delete mintErrors[resource] return body.access_token } catch (error: any) { console.error('OIDC REFRESH FAILED', error?.message) + mintErrors[resource] = error?.message || 'token request failed' // A dead grant (revoked / expired session / family revoked on reuse) ends the // session; transient network errors keep it and the next call retries. if (error?.oauthError === 'invalid_grant') clearLocal() @@ -290,8 +303,12 @@ export async function oidcEndSessionSilently(): Promise { } } +/** Why the last mint for this audience was refused, if it was. */ +export const oidcMintError = (resource: string): string | undefined => mintErrors[resource] + export function invalidateOidcToken() { access = {} + mintErrors = {} } /** Local-only teardown: clears this app's tokens and NOTHING else. App sign-out never From 48a25d5af5449eb0c6276ce949cfe44a2f967724 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 14:59:05 -0700 Subject: [PATCH 108/238] feat(test-settings): restore the AS-served stage picker --- frontend/src/pages/TestPage.tsx | 101 +++++++++++++++++++-- frontend/src/services/permitteerAccount.ts | 10 +- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 0d752c366..1bea9029f 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,14 +1,16 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import cloudSync from '../services/CloudSync' import { OAUTH_AGENT_RESOURCE } from '../constants' import { Dispatch, State } from '../store' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL } from '../helpers/apiHelper' -import { oidcAccessToken } from '../services/oidc' +import { oidcAccessToken, oidcMintError } from '../services/oidc' import { selectLimitsLookup, selectLimits } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' +import { bindableResources } from '../services/permitteerAccount' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' +import { ListItemRadio } from '../components/ListItemRadio' import { ListItemSetting } from '../components/ListItemSetting' import { Container } from '../components/Container' import { Title } from '../components/Title' @@ -64,6 +66,71 @@ export const TestPage: React.FC = () => { // the refusal here rather than letting it surface later as ambient 403s. const [mintError, setMintError] = useState('') + /* `oidcAccessToken` reports a refusal by RETURNING '' so callers can degrade quietly, + so a try/catch around it never fires — check the token itself and read the AS's + reason ("not covered by this grant") off the oidc module. */ + async function mintCheck(...resources: string[]): Promise { + for (const resource of resources) { + if (!(await oidcAccessToken(resource))) { + setMintError(oidcMintError(resource) || resource) + return false + } + } + return true + } + + /* The stage-pair picker (D10/D11a, permitteer docs/remoteit-desktop-login.md 4c). + Options come FROM the AS — this client's own allowlist joined to registry names — so + the picker and the mint-time guardrail read one source and can never disagree. + Identifiers group into stage pairs (graphql + events); picking one sets BOTH URLs and + mints BOTH audiences here, so an illegal target fails legibly instead of as ambient + 403s an hour later. Status is kept so a refused fetch cannot masquerade as an empty + allowlist, which is how this sat broken unnoticed. */ + const [targets, setTargets] = useState>([]) + const [targetsStatus, setTargetsStatus] = useState(undefined) + useEffect(() => { + bindableResources().then(({ status, resources }) => { + setTargets(resources) + setTargetsStatus(status) + }) + }, []) + + type StagePair = { stage: string; name: string; graphql?: string; ws?: string } + const stagePairs: StagePair[] = useMemo(() => { + const pairs = new Map() + for (const target of targets) { + const gql = target.identifier.match(/^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/) + const ws = target.identifier.match(/^wss:\/\/ws(?:\.([a-z0-9-]+))?\.remote\.it\/v1$/) + if (!gql && !ws) continue // passport / account-api entries are not switch targets + const stage = (gql?.[1] ?? ws?.[1]) || 'prod' + const pair = pairs.get(stage) || { stage, name: stage } + if (gql) { + pair.graphql = target.identifier + pair.name = target.name + } else pair.ws = target.identifier + pairs.set(stage, pair) + } + return [...pairs.values()].filter(pair => pair.graphql) + }, [targets]) + + /* Which radio is lit is DERIVED from the effective GraphQL URL, so a hand-typed URL + that matches no stage simply lights none and there is no selection state to drift. + The override switch stays ON throughout — it owns whether we override at all, and a + stage only fills in the pair of URLs. */ + async function selectStage(pair: StagePair) { + setMintError('') + const values = { + switchApi: true, + apiGraphqlURL: pair.graphql!, + ...(pair.ws ? { webSocketURL: pair.ws } : {}), + } + await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) + emit('preferences', { ...preferences, ...values }) + if (!(await mintCheck(pair.graphql!, ...(pair.ws ? [pair.ws] : [])))) return + emit('binaries/install') + cloudSync.all() + } + /* One switch owns the whole custom target — GraphQL, WebSocket and the agent service. Off returns every one of them to the stage the build ships with. switchAgent is browser-only, so it rides the ui state and never the backend preferences emit. */ @@ -164,6 +231,30 @@ export const TestPage: React.FC = () => { + {stagePairs.map(pair => ( + selectStage(pair)} + /> + ))} + {customSelected && !stagePairs.length && ( + + + {targetsStatus === undefined + ? t('testPage.stagesLoading', 'Loading available targets\u2026') + : targetsStatus === 200 + ? t('testPage.stagesEmpty', 'The authorization server lists no switchable targets for this client.') + : t('testPage.stagesError', 'Could not load available targets ({{status}}) \u2014 enter a URL below.', { + status: targetsStatus, + })} + + + )} + {!!stagePairs.length && } { const url = result.toString() setMintError('') await setAPIPreference('apiGraphqlURL', url) - try { - await oidcAccessToken(url) - } catch (error) { - setMintError(error instanceof Error ? error.message : String(error)) - } + if (!(await mintCheck(url))) return emit('binaries/install') cloudSync.all() }} diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index d25207f1f..36a3f338d 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -12,10 +12,14 @@ export type AccountApiResult = { status: number; body?: T } /** The legal token targets for THIS client — the AS's allowlist joined to registry names * (D10). The stage picker and the mint-time guardrail read the SAME source, so they can - * never disagree; adding a stage to the tf allowlist puts it here on the next fetch. */ -export async function bindableResources(): Promise> { + * never disagree; adding a stage to the tf allowlist puts it here on the next fetch. + * + * Returns the status alongside the list: collapsing every failure into `[]` made a + * refused token and a genuinely empty allowlist render identically (an empty picker, + * no error), which is exactly how this went unnoticed. */ +export async function bindableResources(): Promise<{ status: number; resources: Array<{ identifier: string; name: string }> }> { const r = await call>('/bindable-resources') - return r.status === 200 && Array.isArray(r.body) ? r.body : [] + return { status: r.status, resources: r.status === 200 && Array.isArray(r.body) ? r.body : [] } } async function call(path: string, init: RequestInit = {}): Promise> { From f34e5b5755f4ebaaaeb020e458c50efb3977c6bf Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 15:00:45 -0700 Subject: [PATCH 109/238] fix(test-settings): light a stage radio only when both its URLs still match --- frontend/src/pages/TestPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 1bea9029f..454d3cb23 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -237,7 +237,11 @@ export const TestPage: React.FC = () => { label={pair.name} subLabel={pair.graphql} disabled={!customSelected} - checked={customSelected && getApiURL() === pair.graphql} + // Lit only when the WHOLE pair still matches — editing either URL by hand + // drops the light, so a half-custom target can never read as a stage. + checked={ + customSelected && getApiURL() === pair.graphql && (!pair.ws || getWebSocketURL() === pair.ws) + } onClick={() => selectStage(pair)} /> ))} From 4637764bc56a80640273993ea942c1e85b1c4eb8 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 15:53:44 -0700 Subject: [PATCH 110/238] fix(test-settings): revert the agent service URL to its default when the override is off --- frontend/src/pages/TestPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 454d3cb23..35470c5ae 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -288,7 +288,10 @@ export const TestPage: React.FC = () => { hideIcon /> Date: Wed, 26 Aug 2026 15:59:21 -0700 Subject: [PATCH 111/238] fix(test-settings): don't emit preferences before the backend has sent its own --- frontend/src/pages/TestPage.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 35470c5ae..a50e3a275 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -29,9 +29,20 @@ export const TestPage: React.FC = () => { const limitsOverride = useSelector(selectLimitsLookup) const limits = useSelector(selectLimits) + /* The backend replaces its WHOLE preferences object with what we send, and our copy + starts out as `{ version: '', cliVersion: '' }` until the backend pushes its own + (Controller 'preferences'). Emitting before then would persist that near-empty + default over autoUpdate / openAtLogin / language. `version` only ever arrives from + the backend, so it doubles as "preferences have landed". Returns false when the + backend did not get the change — emit() is also a silent no-op with no socket. */ + function emitPreferences(values: Partial): boolean { + if (!preferences.version) return false + return emit('preferences', { ...preferences, ...values }) + } + async function setAPIPreference(key: string, value: string | number | boolean) { await dispatch.ui.setPersistent({ apis: { ...apis, [key]: value } }) - emit('preferences', { ...preferences, [key]: value }) + emitPreferences({ [key]: value }) } // Agent overrides are browser-only (the chat never touches the desktop @@ -125,7 +136,7 @@ export const TestPage: React.FC = () => { ...(pair.ws ? { webSocketURL: pair.ws } : {}), } await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) - emit('preferences', { ...preferences, ...values }) + emitPreferences(values) if (!(await mintCheck(pair.graphql!, ...(pair.ws ? [pair.ws] : [])))) return emit('binaries/install') cloudSync.all() @@ -145,7 +156,7 @@ export const TestPage: React.FC = () => { } : { switchApi: false } await dispatch.ui.setPersistent({ apis: { ...apis, ...values, switchAgent: on } }) - emit('preferences', { ...preferences, ...values }) + emitPreferences(values) emit('binaries/install') cloudSync.all() } @@ -169,7 +180,7 @@ export const TestPage: React.FC = () => { )} onClick={() => { dispatch.ui.setPersistent({ testUI: undefined }) - emit('preferences', { ...preferences, allowPrerelease: false, switchApi: false }) + emitPreferences({ allowPrerelease: false, switchApi: false }) }} /> Date: Wed, 26 Aug 2026 16:17:18 -0700 Subject: [PATCH 112/238] style(header): fixed-width AI agent icon button --- frontend/src/components/Header/Header.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index a5cd21ec3..c8810380b 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -80,7 +80,13 @@ export const Header: React.FC = ({ panels = 1 }) => { )} {(layout.hideSidebar || browser.isMobile) && ( - + )} {!isRootMenu && ( @@ -94,6 +100,7 @@ export const Header: React.FC = ({ panels = 1 }) => { )} {chatEnabled && !chatPoppedOut && ( Date: Wed, 26 Aug 2026 16:17:36 -0700 Subject: [PATCH 113/238] feat(chat): maximize toggle, effective-width layout breakpoints, open by default --- frontend/src/components/App.tsx | 24 +++++--- frontend/src/components/AvatarMenu.tsx | 6 +- frontend/src/components/Chat/ChatPanel.tsx | 36 +++++++----- frontend/src/components/Header/Header.tsx | 6 +- frontend/src/components/ScriptsListHeader.tsx | 6 +- frontend/src/constants.ts | 1 - frontend/src/hooks/useChatEnabled.ts | 58 ++++++++++++------- frontend/src/models/chat.ts | 5 +- frontend/src/store.ts | 1 + 9 files changed, 89 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 214c07040..96ca061f4 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -10,14 +10,13 @@ import { PersistGate } from 'redux-persist/integration/react' import { selectResellerRef } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { - HIDE_SIDEBAR_WIDTH, HIDE_TWO_PANEL_WIDTH, MOBILE_WIDTH, REGEX_FIRST_PATH, SHOW_TRIPLE_PANEL_WIDTH, } from '../constants' import { State, Dispatch } from '../store' -import { useMediaQuery, Box } from '@mui/material' +import { Box } from '@mui/material' import { InstallationNotice } from './InstallationNotice' import { LoadingMessage } from './LoadingMessage' import { ResellerLogo } from './ResellerLogo' @@ -25,7 +24,14 @@ import { SidebarMenu } from './SidebarMenu' import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' -import { useChatEnabled, useChatDocked, useChatWidth, useSidebarWidth } from '../hooks/useChatEnabled' +import { + useChatEnabled, + useChatDocked, + useChatWidth, + useSidebarWidth, + useEffectiveWidth, + useHideSidebar, +} from '../hooks/useChatEnabled' import { Router } from '../routers/Router' import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' @@ -60,10 +66,14 @@ export const App: React.FC = () => { const sidebarWidth = useSidebarWidth() const reseller = useSelector(selectResellerRef) const dispatch = useDispatch() - const hideSidebar = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) - const singlePanel = useMediaQuery(`(max-width:${HIDE_TWO_PANEL_WIDTH}px)`) - const triplePanel = useMediaQuery(`(min-width:${SHOW_TRIPLE_PANEL_WIDTH}px)`) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + // Breakpoints measure the EFFECTIVE width — the window minus the docked chat + // column — so opening or widening the chat reflows the app (sidebar → hamburger, + // two panels → one) exactly the way shrinking the window does + const effectiveWidth = useEffectiveWidth() + const hideSidebar = useHideSidebar() + const singlePanel = effectiveWidth <= HIDE_TWO_PANEL_WIDTH + const triplePanel = effectiveWidth >= SHOW_TRIPLE_PANEL_WIDTH + const mobile = effectiveWidth <= MOBILE_WIDTH // The docked chat column reserves layout space the same way the sidebar // does; useChatDocked only docks when the panels still fit beside it — // otherwise the chat renders as an overlay and reserves nothing diff --git a/frontend/src/components/AvatarMenu.tsx b/frontend/src/components/AvatarMenu.tsx index 833c7b6a9..3cd4f0e46 100644 --- a/frontend/src/components/AvatarMenu.tsx +++ b/frontend/src/components/AvatarMenu.tsx @@ -2,8 +2,8 @@ import React, { useState, useRef, useCallback } from 'react' import { useHistory } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { State, Dispatch } from '../store' -import { HIDE_SIDEBAR_WIDTH } from '../constants' -import { useMediaQuery, ButtonBase, Divider, Menu } from '@mui/material' +import { ButtonBase, Divider, Menu } from '@mui/material' +import { useHideSidebar } from '../hooks/useChatEnabled' import { useSelector, useDispatch } from 'react-redux' import { selectLicenseIndicator } from '../models/plans' import { ListItemLocation } from './ListItemLocation' @@ -30,7 +30,7 @@ export const AvatarMenu: React.FC = () => { const leaveTimer = useRef() const dispatch = useDispatch() const { t } = useTranslation() - const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + const sidebarHidden = useHideSidebar() const user = useSelector((state: State) => state.auth.user) const remoteUI = useSelector(isRemoteUI) const testUI = useSelector((state: State) => ['ON', 'HIGHLIGHT'].includes(state.ui?.testUI || '')) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 5c33c33d8..116974ce1 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next' import { useSelector, useDispatch } from 'react-redux' import { Box } from '@mui/material' import { State, Dispatch } from '../../store' -import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED, CHAT_PANEL_WIDTH_MIN } from '../../constants' -import { useChatDocked, useChatWidth, useChatMaxWidth } from '../../hooks/useChatEnabled' +import { CHAT_PANEL_WIDTH_MIN } from '../../constants' +import { useChatDocked, useChatWidth, useChatMaxWidth, useSidebarWidth } from '../../hooks/useChatEnabled' import { useChatMainSync } from '../../hooks/useChatSync' import { usePanelDrag } from '../../hooks/usePanelDrag' import { PanelHandle } from '../PanelHandle' @@ -19,12 +19,14 @@ import browser from '../../services/browser' export const ChatPanel: React.FC = () => { const { t } = useTranslation() const open = useSelector((state: State) => state.chat.open) + const expanded = useSelector((state: State) => state.chat.expanded) const insets = useSelector((state: State) => state.ui.layout.insets) const showBottomMenu = useSelector((state: State) => state.ui.layout.showBottomMenu) const layout = useSelector((state: State) => state.ui.layout) const docked = useChatDocked() const chatWidth = useChatWidth() const maxWidth = useChatMaxWidth() + const sidebarWidth = useSidebarWidth() const panelRef = useRef(null) const dispatch = useDispatch() @@ -41,8 +43,6 @@ export const ChatPanel: React.FC = () => { layoutDep: layout, anchor: 'right', }) - const expanded = chatWidth >= CHAT_PANEL_WIDTH_EXPANDED - if (!open) return null return ( @@ -51,22 +51,32 @@ export const ChatPanel: React.FC = () => { display: 'flex', flexFlow: 'column', flexShrink: 0, - // Docked column beside the panels when it fits; full-screen overlay - // otherwise, matching how pages collapse on small windows + // Docked column beside the panels when it fits; otherwise an overlay + // over the CONTENT area — expanded (maximized) or on a small window. + // The overlay's left edge stops at the sidebar chrome so it never + // covers the left nav; when the sidebar is hidden it spans the window ...(docked ? { position: 'relative', height: '100%', width: drag.width, } - : { position: 'absolute', inset: 0, width: '100%', zIndex: 15, paddingLeft: insets?.leftPx }), + : { + position: 'absolute', + top: 0, + bottom: 0, + right: 0, + left: sidebarWidth, + zIndex: 15, + paddingLeft: sidebarWidth ? undefined : insets?.leftPx, + }), // Match the page panels' safe-area handling (Panel.tsx): keep the // header clear of the notch and the input clear of the home // indicator on mobile; the bottom menu carries its own inset paddingTop: insets?.topPx, paddingRight: insets?.rightPx, bgcolor: 'white.main', - borderLeft: docked ? 1 : 0, + borderLeft: docked || sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', paddingBottom: showBottomMenu ? 1 : insets?.bottomPx || 1, }} @@ -74,15 +84,11 @@ export const ChatPanel: React.FC = () => { > {docked && } - {docked && ( + {(docked || expanded) && ( - dispatch.chat.set({ - width: Math.min(expanded ? CHAT_PANEL_WIDTH : CHAT_PANEL_WIDTH_EXPANDED, maxWidth), - }) - } + onClick={() => dispatch.chat.set({ expanded: !expanded })} /> )} {!browser.isMobile && ( diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index c8810380b..8aab1683b 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,5 +1,5 @@ -import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH } from '../../constants' -import { useChatEnabled } from '../../hooks/useChatEnabled' +import { REGEX_FIRST_PATH, MOBILE_WIDTH } from '../../constants' +import { useChatEnabled, useHideSidebar } from '../../hooks/useChatEnabled' import React, { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import useNavigationUp from '../../hooks/useNavigationUp' @@ -38,7 +38,7 @@ export const Header: React.FC = ({ panels = 1 }) => { const navigateUp = useNavigationUp(panels) const [showSearch, setShowSearch] = useState(false) - const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + const sidebarHidden = useHideSidebar() const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) const inputRef = useRef(null) const dispatch = useDispatch() diff --git a/frontend/src/components/ScriptsListHeader.tsx b/frontend/src/components/ScriptsListHeader.tsx index 92b194743..d1082ba77 100644 --- a/frontend/src/components/ScriptsListHeader.tsx +++ b/frontend/src/components/ScriptsListHeader.tsx @@ -1,15 +1,15 @@ import React from 'react' import { useHistory } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' -import { Box, Button, Typography, Tooltip, useMediaQuery } from '@mui/material' +import { Box, Button, Typography, Tooltip } from '@mui/material' import { useTranslation } from 'react-i18next' import { selectPermissions } from '../selectors/organizations' +import { useHideSidebar } from '../hooks/useChatEnabled' import { IconButton } from '../buttons/IconButton' import { RefreshButton } from '../buttons/RefreshButton' import { Icon } from '../components/Icon' import { Title } from '../components/Title' import { spacing } from '../styling' -import { HIDE_SIDEBAR_WIDTH } from '../constants' import { Dispatch } from '../store' type Props = { @@ -21,7 +21,7 @@ type Props = { export const ScriptsListHeader: React.FC = ({ showBack, onBack, scripts }) => { const history = useHistory() const dispatch = useDispatch() - const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + const sidebarHidden = useHideSidebar() const permissions = useSelector(selectPermissions) const { t } = useTranslation() diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 65f4e0aed..81830c41f 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -108,7 +108,6 @@ export const MAX_CONNECTION_NAME_LENGTH = 62 export const MAX_DESCRIPTION_LENGTH = 1024 export const SIDEBAR_WIDTH = 250 export const CHAT_PANEL_WIDTH = 400 -export const CHAT_PANEL_WIDTH_EXPANDED = 640 export const CHAT_PANEL_WIDTH_MIN = 320 export const ORGANIZATION_BAR_WIDTH = 70 export const HIDE_SIDEBAR_WIDTH = 1150 diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index c6b0769ad..10b5af45d 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react' -import { useMediaQuery } from '@mui/material' import { useSelector } from 'react-redux' import { State } from '../store' import { @@ -33,14 +32,13 @@ const useViewportWidth = (): number => { return width } -/* The widest the chat column may be dragged: what is left once the sidebar - chrome and the two content panels keep their minimums. Bounding the drag - here is also what keeps the column docked — a wider chat would fail the - fit-check below and snap to a full-screen overlay mid-drag. */ +/* The widest the chat column may be dragged: whatever the window holds once the + content area keeps its single-panel minimum. The sidebar no longer caps the drag — + the layout breakpoints below measure the REMAINING width, so a chat dragged wide + collapses the sidebar into the hamburger menu exactly as narrowing the window would. */ export const useChatMaxWidth = (): number => { const viewport = useViewportWidth() - const sidebarWidth = useSidebarWidth() - return Math.max(CHAT_PANEL_WIDTH_MIN, viewport - sidebarWidth - HIDE_TWO_PANEL_WIDTH) + return Math.max(CHAT_PANEL_WIDTH_MIN, viewport - HIDE_TWO_PANEL_WIDTH) } /* Width the docked chat column occupies — single source for the fit-check @@ -54,22 +52,40 @@ export const useChatWidth = (): number => { return Math.min(Math.max(stored || CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN), max) } +/* Whether the open chat reserves layout width (docked) or floats as an + overlay. Docked only when at least one content panel still fits beside the + column, and never while maximized — the expanded chat covers the content + area as an overlay so the layout underneath keeps its full width. */ +export const useChatDocked = (): boolean => { + const enabled = useChatEnabled() + const open = useSelector((state: State) => state.chat.open) + const expanded = useSelector((state: State) => state.chat.expanded) + const viewport = useViewportWidth() + const chatWidth = useChatWidth() + return enabled && open && !expanded && viewport - chatWidth >= HIDE_TWO_PANEL_WIDTH +} + +/* The width the app layout actually has left: the window minus the docked chat + column. Every layout breakpoint (sidebar, single/triple panel, mobile) measures + THIS instead of the raw window, so docking the chat reflows the app the same way + shrinking the window does. Consistency is arithmetic, not luck: docked guarantees + HIDE_TWO_PANEL_WIDTH remains, and a visible sidebar implies more than + HIDE_SIDEBAR_WIDTH remains — which more than covers sidebar chrome plus a panel. */ +export const useEffectiveWidth = (): number => { + const viewport = useViewportWidth() + const docked = useChatDocked() + const chatWidth = useChatWidth() + return docked ? viewport - chatWidth : viewport +} + +/* max-width media query semantics (≤) against the effective width — shared by App + and the components that mirror its sidebar breakpoint (Header, AvatarMenu…) */ +export const useHideSidebar = (): boolean => useEffectiveWidth() <= HIDE_SIDEBAR_WIDTH + /* Width of the left chrome (sidebar + org bar) the layout reserves — - shared by App's sidePanelWidth and the chat docking fit-check */ + shared by App's sidePanelWidth and the chat overlay's left edge */ export const useSidebarWidth = (): number => { const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) - const hideSidebar = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + const hideSidebar = useHideSidebar() return hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0) } - -/* Whether the open chat reserves layout width (docked) or floats as a - full-screen overlay. Docked only when the window still fits two content - panels beside the sidebar chrome and the chat column — reserving width - past that point drives the panel resize math below its minimums. */ -export const useChatDocked = (): boolean => { - const enabled = useChatEnabled() - const open = useSelector((state: State) => state.chat.open) - const required = useChatWidth() + HIDE_TWO_PANEL_WIDTH + useSidebarWidth() - const fits = useMediaQuery(`(min-width:${required}px)`) - return enabled && open && fits -} diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 345da5832..6f7dd6d23 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -43,6 +43,8 @@ export type ChatTranscriptMessage = export type IChatState = { open: boolean + /** Maximized over the content area (the left nav stays); toggled by the header expand button */ + expanded: boolean /** Docked column width in px — drag-resized, persisted */ width: number messages: ChatTranscriptMessage[] @@ -64,7 +66,8 @@ export type IChatState = { } export const defaultChatState: IChatState = { - open: false, + open: true, + expanded: false, width: CHAT_PANEL_WIDTH, messages: [], conversationId: '', diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 9961ba53e..bc53bac45 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -22,6 +22,7 @@ const chatTransform = createTransform( conversationId: inbound.conversationId, orgId: inbound.orgId, open: inbound.open, + expanded: inbound.expanded, width: inbound.width, poppedOut: inbound.poppedOut, }), From 6dca30c655c917f077d14b4ee53fc110311edac9 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 16:58:58 -0700 Subject: [PATCH 114/238] fix(chat): track the drag live so the content panels shrink instead of overflowing --- frontend/src/components/Chat/ChatPanel.tsx | 10 ++++++++-- frontend/src/hooks/usePanelDrag.ts | 20 ++++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 116974ce1..cb95d79c6 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -33,13 +33,19 @@ export const ChatPanel: React.FC = () => { useChatMainSync() // Drag-to-resize, same mechanism as the content panels — anchored right, so - // pulling the handle left widens the chat. The width persists on release. + // pulling the handle left widens the chat. Unlike those panels the width has + // to publish on every frame, not just on release: App reserves this column's + // width in the layout and DoublePanel sizes the content area from it, so a + // width held back until mouseup leaves the content on a stale minWidth that + // will not shrink — the column then overflows the window until it snaps. const getMaxWidth = useCallback(() => maxWidth, [maxWidth]) + const setWidth = useCallback((width: number) => dispatch.chat.set({ width }), [dispatch]) const drag = usePanelDrag(chatWidth, { panelRef, minWidth: CHAT_PANEL_WIDTH_MIN, getMaxWidth, - onPersist: width => dispatch.chat.set({ width }), + onChange: setWidth, + onPersist: setWidth, layoutDep: layout, anchor: 'right', }) diff --git a/frontend/src/hooks/usePanelDrag.ts b/frontend/src/hooks/usePanelDrag.ts index cc81d3e48..77113c945 100644 --- a/frontend/src/hooks/usePanelDrag.ts +++ b/frontend/src/hooks/usePanelDrag.ts @@ -5,6 +5,12 @@ interface UsePanelDragOptions { minWidth: number getMaxWidth: () => number onPersist?: (width: number) => void + /** Called on every drag frame. Panels whose siblings size themselves from + * shared state (the chat column: App reserves its width, DoublePanel sizes + * the content from that) must publish the width DURING the drag, or those + * siblings keep a stale minWidth, refuse to shrink, and the panel overflows + * until mouseup snaps it back. */ + onChange?: (width: number) => void layoutDep?: unknown /** Which edge the panel is fixed to. A right-anchored panel (the chat * column) grows when the handle is dragged LEFT, so the pointer delta @@ -24,7 +30,7 @@ interface UsePanelDragOptions { * @param options.layoutDep - Dependency to trigger re-measurement (e.g., layout object) */ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) { - const { panelRef, minWidth, getMaxWidth, onPersist, layoutDep, anchor = 'left' } = options + const { panelRef, minWidth, getMaxWidth, onPersist, onChange, layoutDep, anchor = 'left' } = options const handleRef = useRef(initialWidth) const moveRef = useRef(0) @@ -41,13 +47,15 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) (event: MouseEvent) => { const maxWidth = getMaxWidth() const delta = event.clientX - moveRef.current - handleRef.current += anchor === 'right' ? -delta : delta moveRef.current = event.clientX - if (handleRef.current > minWidth && handleRef.current < maxWidth) { - setWidth(handleRef.current) - } + // CLAMP the accumulator rather than ignoring out-of-range values: letting + // it run past the limit meant a drag beyond the edge had to retrace the + // whole overshoot before the panel moved again, which reads as sticking. + handleRef.current = Math.min(Math.max(handleRef.current + (anchor === 'right' ? -delta : delta), minWidth), maxWidth) + setWidth(handleRef.current) + onChange?.(handleRef.current) }, - [minWidth, getMaxWidth, anchor] + [minWidth, getMaxWidth, anchor, onChange] ) const onUp = useCallback( From faf21ed9e9bd9169d59287531a502a73e265ddd6 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:16:44 -0700 Subject: [PATCH 115/238] fix(chat): keep the chat docked until the column and content minimums no longer fit --- frontend/src/constants.ts | 4 ++++ frontend/src/hooks/useChatEnabled.ts | 30 ++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 81830c41f..5e21b6a33 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -109,6 +109,10 @@ export const MAX_DESCRIPTION_LENGTH = 1024 export const SIDEBAR_WIDTH = 250 export const CHAT_PANEL_WIDTH = 400 export const CHAT_PANEL_WIDTH_MIN = 320 +/* Content that must survive beside a docked chat column. The column shrinks to + preserve it, so the chat keeps its column on small desktop windows instead of + taking the screen — that only happens at phone size (MOBILE_WIDTH). */ +export const CHAT_MIN_CONTENT_WIDTH = 500 export const ORGANIZATION_BAR_WIDTH = 70 export const HIDE_SIDEBAR_WIDTH = 1150 export const HIDE_TWO_PANEL_WIDTH = 750 diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index 10b5af45d..2aa3385ce 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -1,12 +1,13 @@ import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { State } from '../store' +import browser from '../services/browser' import { MODE, CHAT_ALWAYS_ON, CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN, - HIDE_TWO_PANEL_WIDTH, + CHAT_MIN_CONTENT_WIDTH, HIDE_SIDEBAR_WIDTH, SIDEBAR_WIDTH, ORGANIZATION_BAR_WIDTH, @@ -33,12 +34,14 @@ const useViewportWidth = (): number => { } /* The widest the chat column may be dragged: whatever the window holds once the - content area keeps its single-panel minimum. The sidebar no longer caps the drag — - the layout breakpoints below measure the REMAINING width, so a chat dragged wide - collapses the sidebar into the hamburger menu exactly as narrowing the window would. */ + content keeps CHAT_MIN_CONTENT_WIDTH. The sidebar does not cap the drag — the layout + breakpoints below measure the REMAINING width, so a chat dragged wide collapses the + sidebar into the hamburger exactly as narrowing the window would. This is also what + keeps the column docked as the WINDOW shrinks: the chat gives up its own width first, + down to CHAT_PANEL_WIDTH_MIN, rather than the app flipping to a full-screen chat. */ export const useChatMaxWidth = (): number => { const viewport = useViewportWidth() - return Math.max(CHAT_PANEL_WIDTH_MIN, viewport - HIDE_TWO_PANEL_WIDTH) + return Math.max(CHAT_PANEL_WIDTH_MIN, viewport - CHAT_MIN_CONTENT_WIDTH) } /* Width the docked chat column occupies — single source for the fit-check @@ -52,17 +55,22 @@ export const useChatWidth = (): number => { return Math.min(Math.max(stored || CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN), max) } -/* Whether the open chat reserves layout width (docked) or floats as an - overlay. Docked only when at least one content panel still fits beside the - column, and never while maximized — the expanded chat covers the content - area as an overlay so the layout underneath keeps its full width. */ +/* Whether the open chat reserves layout width (docked) or covers the app as an + overlay. Taking the whole screen is a PHONE behaviour, not a small-window one: + a narrow desktop window keeps the column and lets the chat and the content share + what there is. Maximizing is the one way to get the overlay on a real screen. + The threshold is DERIVED — the window must hold the column at its minimum and the + content at its minimum — rather than borrowed from the two-panel breakpoint, which + is what used to un-dock the chat on windows as wide as 1150px. Deliberately + independent of the chat's CURRENT width: gating docking on the width that docking + determines is what let the panel flip out from under a drag. */ export const useChatDocked = (): boolean => { const enabled = useChatEnabled() const open = useSelector((state: State) => state.chat.open) const expanded = useSelector((state: State) => state.chat.expanded) const viewport = useViewportWidth() - const chatWidth = useChatWidth() - return enabled && open && !expanded && viewport - chatWidth >= HIDE_TWO_PANEL_WIDTH + const fits = viewport >= CHAT_PANEL_WIDTH_MIN + CHAT_MIN_CONTENT_WIDTH + return enabled && open && !expanded && !browser.isMobile && fits } /* The width the app layout actually has left: the window minus the docked chat From b29dc74b102646fcda7dda906aa0c013ad6ad95d Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:16:44 -0700 Subject: [PATCH 116/238] fix(chat): lead the header with the expand toggle and align it to the app header --- frontend/src/components/Chat/ChatHeader.tsx | 20 +++++++++++++++----- frontend/src/components/Chat/ChatPanel.tsx | 21 +++++++++++++-------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index bba7dd279..582aefc1d 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -6,15 +6,25 @@ import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' -/* Title row shared by the docked panel and the popout window — the - window-specific buttons render as children in each caller's order */ -export const ChatHeader: React.FC<{ children?: React.ReactNode }> = ({ children }) => { +/* Title row shared by the docked panel and the popout window — `leading` takes the + panel-chrome control (expand/collapse) at the far left, the window-specific actions + render as children on the right in each caller's order. + The row mirrors the app Header's box exactly — same height, same top margin, centered + — so the two icon rows share a baseline across the divider instead of the chat's + sitting high on an auto-height row. */ +export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React.ReactNode }> = ({ + leading, + children, +}) => { const { t } = useTranslation() const sessionName = useSelector((state: State) => state.chat.title) return ( - + + {leading} - + {t('chat.title', 'Remote.It AI')} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index cb95d79c6..a4b2a1248 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -89,14 +89,19 @@ export const ChatPanel: React.FC = () => { ref={panelRef} > {docked && } - - {(docked || expanded) && ( - dispatch.chat.set({ expanded: !expanded })} - /> - )} + dispatch.chat.set({ expanded: !expanded })} + /> + ) + } + > {!browser.isMobile && ( Date: Wed, 26 Aug 2026 17:28:01 -0700 Subject: [PATCH 117/238] feat(chat): pale blue panel ground and a usage ring under the composer --- frontend/src/components/Chat/ChatApproval.tsx | 2 +- frontend/src/components/Chat/ChatInput.tsx | 6 +++ frontend/src/components/Chat/ChatIntro.tsx | 2 +- .../src/components/Chat/ChatMessageItem.tsx | 2 +- frontend/src/components/Chat/ChatPanel.tsx | 4 +- frontend/src/components/Chat/ChatUsage.tsx | 41 +++++++++++++++---- frontend/src/components/Chat/ChatWindow.tsx | 4 +- 7 files changed, 43 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Chat/ChatApproval.tsx b/frontend/src/components/Chat/ChatApproval.tsx index 750e09d7a..912806a9c 100644 --- a/frontend/src/components/Chat/ChatApproval.tsx +++ b/frontend/src/components/Chat/ChatApproval.tsx @@ -12,7 +12,7 @@ type Props = { export const ChatApproval: React.FC = ({ toolName, input, onRespond }) => { const { t } = useTranslation() return ( - + = ({ disabled, placeholder, streaming, o /> )} + {/* Usage rides under the composer, trailing edge — it reports on what the next + turn will spend. Renders nothing when both windows are unlimited. */} + + + ) } diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index 786dbf45b..141b10fb6 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -36,7 +36,7 @@ export const ChatIntro: React.FC = () => { width: 64, height: 64, borderRadius: '50%', - bgcolor: 'primaryHighlight.main', + bgcolor: 'primaryLighter.main', display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 72ca879af..d1854efea 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -24,7 +24,7 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ if (message.role === 'user') return ( - + {message.text} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index a4b2a1248..f91abeb73 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -10,7 +10,6 @@ import { usePanelDrag } from '../../hooks/usePanelDrag' import { PanelHandle } from '../PanelHandle' import { IconButton } from '../../buttons/IconButton' import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' -import { ChatUsage } from './ChatUsage' import { ChatBody } from './ChatBody' import browser from '../../services/browser' @@ -81,7 +80,7 @@ export const ChatPanel: React.FC = () => { // indicator on mobile; the bottom menu carries its own inset paddingTop: insets?.topPx, paddingRight: insets?.rightPx, - bgcolor: 'white.main', + bgcolor: 'primaryHighlight.main', borderLeft: docked || sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', paddingBottom: showBottomMenu ? 1 : insets?.bottomPx || 1, @@ -109,7 +108,6 @@ export const ChatPanel: React.FC = () => { onClick={() => dispatch.chat.popOut()} /> )} - dispatch.chat.set({ open: false })} /> diff --git a/frontend/src/components/Chat/ChatUsage.tsx b/frontend/src/components/Chat/ChatUsage.tsx index f20e933a4..eba916e01 100644 --- a/frontend/src/components/Chat/ChatUsage.tsx +++ b/frontend/src/components/Chat/ChatUsage.tsx @@ -1,11 +1,10 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { Box, Popover, Typography, LinearProgress } from '@mui/material' +import { Box, Popover, Typography, LinearProgress, CircularProgress, IconButton as MuiIconButton } from '@mui/material' import { State } from '../../store' import { formatReset } from '../../models/chat' import { UsageWindow } from '../../services/agent' -import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' const pct = (w: UsageWindow) => (w.unlimited || w.limitUsd <= 0 ? 0 : Math.min(100, Math.round((w.spentUsd / w.limitUsd) * 100))) @@ -39,9 +38,26 @@ const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, wi ) } -/* The header usage affordance (docs/usage-limits.md D6): quiet until it matters. A gauge - icon that tints as the tighter window fills; clicking opens both windows' meters. Hidden - entirely when both windows are unlimited (nothing to show). */ +/* A ring: a full-circle track with the used arc drawn over it. Two stacked determinate + progress circles is the MUI idiom for a donut — there is no dedicated gauge. */ +const UsageRing: React.FC<{ value: number; color: 'primary' | 'warning' | 'error' }> = ({ value, color }) => ( + + + + +) + +/* The usage affordance (docs/usage-limits.md D6): quiet until it matters. Sits under the + composer rather than in the header — it reports on what you are about to spend, so it + belongs with the send box. The ring fills as the tighter of the two windows does; + clicking opens both meters. Hidden entirely when both are unlimited. */ export const ChatUsage: React.FC = () => { const { t } = useTranslation() const usage = useSelector((state: State) => state.chat.usage) @@ -50,17 +66,24 @@ export const ChatUsage: React.FC = () => { if (!usage || (usage.session.unlimited && usage.weekly.unlimited)) return null const worst = Math.max(pct(usage.session), pct(usage.weekly)) - const color = worst >= 90 ? 'error' : worst >= 70 ? 'warning' : 'grayDarker' + const color = worst >= 90 ? 'error' : worst >= 70 ? 'warning' : 'primary' return ( <> - setAnchorEl(e.currentTarget)} /> + setAnchorEl(e.currentTarget)} + sx={{ padding: 0.5 }} + > + + setAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'right' }} slotProps={{ paper: { sx: { width: 260, padding: 2 } } }} > diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 7bc4cdb9e..bf80b6994 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -6,7 +6,6 @@ import { Dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { useChatPopoutSync } from '../../hooks/useChatSync' import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' -import { ChatUsage } from './ChatUsage' import { ChatBody } from './ChatBody' /* Full-page chat for the popped-out window (?chatPopout boot flag). Display @@ -25,12 +24,11 @@ export const ChatWindow: React.FC = () => { flexFlow: 'column', height: '100%', width: '100%', - bgcolor: 'white.main', + bgcolor: 'primaryHighlight.main', paddingBottom: 1, }} > - Date: Wed, 26 Aug 2026 17:28:01 -0700 Subject: [PATCH 118/238] refactor(chat): drop the header title and org line; the history menu names the conversation --- frontend/src/components/Chat/ChatBody.tsx | 2 - frontend/src/components/Chat/ChatHeader.tsx | 39 +++++++++---------- frontend/src/components/Chat/ChatOrgLabel.tsx | 25 ------------ 3 files changed, 19 insertions(+), 47 deletions(-) delete mode 100644 frontend/src/components/Chat/ChatOrgLabel.tsx diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 11ab26622..38032d7e6 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -7,7 +7,6 @@ import { ChatMessages } from './ChatMessages' import { ChatApproval } from './ChatApproval' import { ChatInput } from './ChatInput' import { ChatIntro } from './ChatIntro' -import { ChatOrgLabel } from './ChatOrgLabel' import { Notice } from '../Notice' import { Body } from '../Body' import { Icon } from '../Icon' @@ -41,7 +40,6 @@ export const ChatBody: React.FC = () => { return ( <> - {unreachable && !!messages.length && ( {unavailableMessage} diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 582aefc1d..d00b5b8c6 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -1,36 +1,25 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import { Box, Typography, Menu, MenuItem, ListItemText, IconButton as MuiIconButton } from '@mui/material' +import { Box, Menu, MenuItem, ListItemText, IconButton as MuiIconButton } from '@mui/material' import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' -/* Title row shared by the docked panel and the popout window — `leading` takes the +/* Control row shared by the docked panel and the popout window — `leading` takes the panel-chrome control (expand/collapse) at the far left, the window-specific actions - render as children on the right in each caller's order. + render as children on the right in each caller's order. Carries no title: the + conversation names itself in the history menu, where picking one is the point. The row mirrors the app Header's box exactly — same height, same top margin, centered - — so the two icon rows share a baseline across the divider instead of the chat's - sitting high on an auto-height row. */ + — so the two icon rows share a baseline across the divider. */ export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React.ReactNode }> = ({ leading, children, }) => { - const { t } = useTranslation() - const sessionName = useSelector((state: State) => state.chat.title) return ( - + {leading} - - - {t('chat.title', 'Remote.It AI')} - - - {sessionName || t('chat.newSession', 'New chat')} - - + {children} ) @@ -49,6 +38,16 @@ export const HistoryButton: React.FC = () => { const dispatch = useDispatch() const conversations = useSelector((state: State) => state.chat.conversations) const currentId = useSelector((state: State) => state.chat.conversationId) + const currentTitle = useSelector((state: State) => state.chat.title) + + /* The open conversation is the panel's only name now that the header carries none, + so it must appear here even before the server list catches up with it — a turn + just started, or the title was set moments ago. Prepend it when missing. */ + const listed = conversations.some(c => c.id === currentId) + const items = + currentId && !listed + ? [{ id: currentId, title: currentTitle, createdAt: '', updatedAt: '' }, ...conversations] + : conversations const [anchorEl, setAnchorEl] = React.useState(null) const open = (e: React.MouseEvent) => { @@ -61,12 +60,12 @@ export const HistoryButton: React.FC = () => { <> - {conversations.length === 0 && ( + {items.length === 0 && ( )} - {conversations.map(c => ( + {items.map(c => ( { - const { t } = useTranslation() - const org = useSelector(resolveChatOrg, (a, b) => a?.id === b?.id && a?.name === b?.name) - const orgName = org ? org.name || t('chat.organization', 'Organization') : t('chat.personal', 'Personal') - - return ( - - - - {t('chat.actingIn', 'Acting in {{org}}', { org: orgName })} - - - ) -} From 4a1622af8e18ec96bc1bf75156812e8f64f9c87f Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:28:01 -0700 Subject: [PATCH 119/238] fix(test-settings): lead the API target radios with the environment name --- frontend/src/pages/TestPage.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index a50e3a275..112b197bd 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -106,6 +106,11 @@ export const TestPage: React.FC = () => { }) }, []) + // Registry names describe the GraphQL resource ("remote.it GraphQL (dev)"), but a + // stage switches GraphQL, WebSocket and the agent together — so the radio leads with + // the ENVIRONMENT and keeps the registry name underneath as the detail. + const stageLabel = (stage: string) => stage.charAt(0).toUpperCase() + stage.slice(1) + type StagePair = { stage: string; name: string; graphql?: string; ws?: string } const stagePairs: StagePair[] = useMemo(() => { const pairs = new Map() @@ -245,8 +250,8 @@ export const TestPage: React.FC = () => { {stagePairs.map(pair => ( Date: Wed, 26 Aug 2026 17:34:14 -0700 Subject: [PATCH 120/238] fix(chat): inline the usage ring in the composer, tint the input, drop the dead gutter --- frontend/src/components/Chat/ChatInput.tsx | 10 +++------- frontend/src/components/Chat/ChatUsage.tsx | 11 ++++++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx index b64d579c5..753e9bbe5 100644 --- a/frontend/src/components/Chat/ChatInput.tsx +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -23,12 +23,12 @@ export const ChatInput: React.FC = ({ disabled, placeholder, streaming, o setText('') } return ( - + = ({ disabled, placeholder, streaming, o } }} /> + {streaming ? ( ) : ( @@ -67,11 +68,6 @@ export const ChatInput: React.FC = ({ disabled, placeholder, streaming, o /> )} - {/* Usage rides under the composer, trailing edge — it reports on what the next - turn will spend. Renders nothing when both windows are unlimited. */} - - - ) } diff --git a/frontend/src/components/Chat/ChatUsage.tsx b/frontend/src/components/Chat/ChatUsage.tsx index eba916e01..38ffe5243 100644 --- a/frontend/src/components/Chat/ChatUsage.tsx +++ b/frontend/src/components/Chat/ChatUsage.tsx @@ -42,7 +42,7 @@ const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, wi progress circles is the MUI idiom for a donut — there is no dedicated gauge. */ const UsageRing: React.FC<{ value: number; color: 'primary' | 'warning' | 'error' }> = ({ value, color }) => ( - + ) -/* The usage affordance (docs/usage-limits.md D6): quiet until it matters. Sits under the - composer rather than in the header — it reports on what you are about to spend, so it - belongs with the send box. The ring fills as the tighter of the two windows does; - clicking opens both meters. Hidden entirely when both are unlimited. */ +/* The usage affordance (docs/usage-limits.md D6): quiet until it matters. Rides INSIDE + the composer, between the field and send — it reports on what you are about to spend, + so it belongs with the send box, and sitting in that row costs no extra height. The + ring fills as the tighter of the two windows does; clicking opens both meters. Hidden + entirely when both are unlimited. */ export const ChatUsage: React.FC = () => { const { t } = useTranslation() const usage = useSelector((state: State) => state.chat.usage) From 048fc5f5bf9b1098879a780ad2baedeb46bd9621 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:40:32 -0700 Subject: [PATCH 121/238] feat(chat): white composer card with the usage ring alongside it --- frontend/src/components/Chat/ChatInput.tsx | 8 +++++--- frontend/src/components/Chat/ChatUsage.tsx | 14 ++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx index 753e9bbe5..f4f8f3257 100644 --- a/frontend/src/components/Chat/ChatInput.tsx +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -23,12 +23,14 @@ export const ChatInput: React.FC = ({ disabled, placeholder, streaming, o setText('') } return ( - + = ({ disabled, placeholder, streaming, o } }} /> - {streaming ? ( ) : ( @@ -68,6 +69,7 @@ export const ChatInput: React.FC = ({ disabled, placeholder, streaming, o /> )} + ) } diff --git a/frontend/src/components/Chat/ChatUsage.tsx b/frontend/src/components/Chat/ChatUsage.tsx index 38ffe5243..fbf476356 100644 --- a/frontend/src/components/Chat/ChatUsage.tsx +++ b/frontend/src/components/Chat/ChatUsage.tsx @@ -5,7 +5,6 @@ import { Box, Popover, Typography, LinearProgress, CircularProgress, IconButton import { State } from '../../store' import { formatReset } from '../../models/chat' import { UsageWindow } from '../../services/agent' -import { Icon } from '../Icon' const pct = (w: UsageWindow) => (w.unlimited || w.limitUsd <= 0 ? 0 : Math.min(100, Math.round((w.spentUsd / w.limitUsd) * 100))) @@ -42,12 +41,12 @@ const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, wi progress circles is the MUI idiom for a donut — there is no dedicated gauge. */ const UsageRing: React.FC<{ value: number; color: 'primary' | 'warning' | 'error' }> = ({ value, color }) => ( - + @@ -87,10 +86,9 @@ export const ChatUsage: React.FC = () => { transformOrigin={{ vertical: 'bottom', horizontal: 'right' }} slotProps={{ paper: { sx: { width: 260, padding: 2 } } }} > - - - {t('chat.usageTitle', 'Usage')} - + + {t('chat.usageTitle', 'Usage')} + From 68639153a91bdaf2df720cc06c85ef5f5ef0f111 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:43:16 -0700 Subject: [PATCH 122/238] copy(chat): composer placeholder reads Chat with Remote.It --- frontend/src/components/Chat/ChatBody.tsx | 2 +- frontend/src/i18n/locales/en/app.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 38032d7e6..867a9e644 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -88,7 +88,7 @@ export const ChatBody: React.FC = () => { placeholder={ pendingConfirmation ? t('chat.waitingApproval', 'Waiting for approval…') - : t('chat.inputPlaceholder', 'Message Remote.It AI…') + : t('chat.inputPlaceholder', 'Chat with Remote.It') } streaming={streaming} onSend={text => dispatch.chat.send(text)} diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 9fdcd624b..839d946fc 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -206,7 +206,7 @@ "personal": "Personal", "actingIn": "Acting in {{org}}", "newSession": "New chat", - "inputPlaceholder": "Message Remote.It AI…", + "inputPlaceholder": "Chat with Remote.It", "introTitle": "Remote.It AI", "introBody": "Ask about your devices, connections, and services — I can look things up and take actions for you.", "tryAsking": "Try asking", From e89f85881c0efb713d69836669ddd7b16d9fc5b8 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:53:54 -0700 Subject: [PATCH 123/238] feat(chat): quieter empty state with prompt chips against the composer --- frontend/src/components/Chat/ChatIntro.tsx | 67 +++++++++------------- frontend/src/i18n/locales/en/app.json | 3 +- 2 files changed, 29 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index 141b10fb6..2cf481144 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -1,12 +1,14 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' -import { Box, Button, Typography } from '@mui/material' +import { Box, Chip, Typography } from '@mui/material' import { Dispatch } from '../../store' import { Icon } from '../Icon' /* Empty-state introduction: shown before the first message so the panel reads as a chat, - not a blank column. The example prompts are one tap into a first turn. */ + not a blank column. The headline floats in the open space; the example prompts sit at + the BOTTOM, against the composer, because that is where a first turn actually starts — + they are one tap into the conversation, not decoration under the title. */ export const ChatIntro: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() @@ -18,55 +20,42 @@ export const ChatIntro: React.FC = () => { ] return ( - + - - - {t('chat.introTitle', 'Remote.It AI')} - - {t( - 'chat.introBody', - 'Ask about your devices, connections, and services — I can look things up and take actions for you.' - )} - - - - {t('chat.tryAsking', 'Try asking')} + + + {t('chat.introTitle', 'Remote.It AI')} + + + {t('chat.introBody', 'Ask about your devices, connections, and services.')} + + {/* Left-aligned and content-width so they read as suggestions to pick up, + rather than full-width buttons competing with the composer below. */} + {prompts.map(prompt => ( - + sx={{ + bgcolor: 'white.main', + color: 'grayDarkest.main', + maxWidth: '100%', + '&:hover': { bgcolor: 'primaryLighter.main' }, + }} + /> ))} diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 839d946fc..a7875fc2c 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -208,8 +208,7 @@ "newSession": "New chat", "inputPlaceholder": "Chat with Remote.It", "introTitle": "Remote.It AI", - "introBody": "Ask about your devices, connections, and services — I can look things up and take actions for you.", - "tryAsking": "Try asking", + "introBody": "Ask about your devices, connections, and services.", "prompt1": "Which of my devices are offline?", "prompt2": "Show my recent connections", "prompt3": "Restart a service on one of my devices", From 572df2a3ff1d71d4d6323445711e1a7d3ae90d3e Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:55:36 -0700 Subject: [PATCH 124/238] style(chat): soften the prompt chip text --- frontend/src/components/Chat/ChatIntro.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index 2cf481144..c5393213e 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -51,7 +51,7 @@ export const ChatIntro: React.FC = () => { onClick={() => dispatch.chat.send(prompt)} sx={{ bgcolor: 'white.main', - color: 'grayDarkest.main', + color: 'grayDarker.main', maxWidth: '100%', '&:hover': { bgcolor: 'primaryLighter.main' }, }} From 51fe16b4841ad06958edc18a799510242bc1329f Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 17:59:10 -0700 Subject: [PATCH 125/238] copy(chat): empty state says the agent can make changes, not just answer --- frontend/src/components/Chat/ChatIntro.tsx | 2 +- frontend/src/i18n/locales/en/app.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index c5393213e..2c79506b8 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -37,7 +37,7 @@ export const ChatIntro: React.FC = () => { {t('chat.introTitle', 'Remote.It AI')} - {t('chat.introBody', 'Ask about your devices, connections, and services.')} + {t('chat.introBody', 'Ask about your devices, connections, and services \u2014 or make changes.')} {/* Left-aligned and content-width so they read as suggestions to pick up, diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index a7875fc2c..d4b78ce36 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -208,7 +208,7 @@ "newSession": "New chat", "inputPlaceholder": "Chat with Remote.It", "introTitle": "Remote.It AI", - "introBody": "Ask about your devices, connections, and services.", + "introBody": "Ask about your devices, connections, and services — or make changes.", "prompt1": "Which of my devices are offline?", "prompt2": "Show my recent connections", "prompt3": "Restart a service on one of my devices", From f2363515dc9ec63daa454509689ce9228b377934 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 26 Aug 2026 18:02:07 -0700 Subject: [PATCH 126/238] copy(chat): empty state leads with manage --- frontend/src/components/Chat/ChatIntro.tsx | 2 +- frontend/src/i18n/locales/en/app.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index 2c79506b8..d379c5d1f 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -37,7 +37,7 @@ export const ChatIntro: React.FC = () => { {t('chat.introTitle', 'Remote.It AI')} - {t('chat.introBody', 'Ask about your devices, connections, and services \u2014 or make changes.')} + {t('chat.introBody', 'Manage your devices, connections, and services \u2014 just ask.')} {/* Left-aligned and content-width so they read as suggestions to pick up, diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index d4b78ce36..2c43ddb2b 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -208,7 +208,7 @@ "newSession": "New chat", "inputPlaceholder": "Chat with Remote.It", "introTitle": "Remote.It AI", - "introBody": "Ask about your devices, connections, and services — or make changes.", + "introBody": "Manage your devices, connections, and services — just ask.", "prompt1": "Which of my devices are offline?", "prompt2": "Show my recent connections", "prompt3": "Restart a service on one of my devices", From cea7668576e2fb29e72f91807d5a8dffb41a2d9a Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 08:53:11 -0700 Subject: [PATCH 127/238] feat(chat): white cards for agent replies and roomier panel padding --- frontend/src/components/Chat/ChatHeader.tsx | 2 +- frontend/src/components/Chat/ChatInput.tsx | 2 +- frontend/src/components/Chat/ChatIntro.tsx | 2 +- .../src/components/Chat/ChatMessageItem.tsx | 114 +++++++++--------- frontend/src/components/Chat/ChatMessages.tsx | 2 +- frontend/src/components/Chat/ChatPanel.tsx | 2 +- 6 files changed, 65 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index d00b5b8c6..97d3fe6f2 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -17,7 +17,7 @@ export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React. children, }) => { return ( - + {leading} {children} diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx index f4f8f3257..b56a1868d 100644 --- a/frontend/src/components/Chat/ChatInput.tsx +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -23,7 +23,7 @@ export const ChatInput: React.FC = ({ disabled, placeholder, streaming, o setText('') } return ( - + { ] return ( - + (({ return ( - ({ '& pre, & table': scrollbarStyles(theme) }), - { - fontSize: fontSizes.base, - lineHeight: 1.5, - wordBreak: 'break-word', - '& p': { marginY: 0.75 }, - '& ul, & ol': { paddingLeft: 3, marginY: 0.5 }, - '& li': { marginY: 0.25 }, - '& h1, & h2, & h3, & h4': { fontSize: 15, marginTop: 1.5, marginBottom: 0.5 }, - '& a': { color: 'primary.main' }, - '& code': { - fontFamily: "'Roboto Mono', monospace", - fontSize: fontSizes.sm, - bgcolor: 'grayLightest.main', - borderRadius: 1, - paddingX: 0.5, - paddingY: 0.25, - }, - '& pre': { - overflowX: 'auto', - bgcolor: 'grayLightest.main', + {!!message.text && ( + ({ '& pre, & table': scrollbarStyles(theme) }), + { + bgcolor: 'white.main', borderRadius: 2, - padding: 1.5, - '& code': { padding: 0, bgcolor: 'transparent' }, - }, - '& table': { - display: 'block', - overflowX: 'auto', - borderCollapse: 'collapse', - fontSize: fontSizes.sm, - marginY: 1, - }, - '& th, & td': { - border: '1px solid', - borderColor: 'grayLighter.main', - paddingX: 1, - paddingY: 0.5, - textAlign: 'left', - whiteSpace: 'nowrap', - }, - '& blockquote': { - borderLeft: '3px solid', - borderColor: 'grayLighter.main', - marginX: 0, - paddingLeft: 1.5, - color: 'grayDark.main', + paddingX: 1.5, + paddingY: 1, + fontSize: fontSizes.base, + lineHeight: 1.5, + wordBreak: 'break-word', + '& p': { marginY: 0.75 }, + '& ul, & ol': { paddingLeft: 3, marginY: 0.5 }, + '& li': { marginY: 0.25 }, + '& h1, & h2, & h3, & h4': { fontSize: 15, marginTop: 1.5, marginBottom: 0.5 }, + '& a': { color: 'primary.main' }, + '& code': { + fontFamily: "'Roboto Mono', monospace", + fontSize: fontSizes.sm, + bgcolor: 'grayLighter.main', + borderRadius: 1, + paddingX: 0.5, + paddingY: 0.25, + }, + '& pre': { + overflowX: 'auto', + bgcolor: 'grayLighter.main', + borderRadius: 2, + padding: 1.5, + '& code': { padding: 0, bgcolor: 'transparent' }, + }, + '& table': { + display: 'block', + overflowX: 'auto', + borderCollapse: 'collapse', + fontSize: fontSizes.sm, + marginY: 1, + }, + '& th, & td': { + border: '1px solid', + borderColor: 'grayLighter.main', + paddingX: 1, + paddingY: 0.5, + textAlign: 'left', + whiteSpace: 'nowrap', + }, + '& blockquote': { + borderLeft: '3px solid', + borderColor: 'grayLighter.main', + marginX: 0, + paddingLeft: 1.5, + color: 'grayDark.main', + }, }, - }, - ]} - > - - {message.text} - - + ]} + > + + {message.text} + + + )} {message.interrupted && ( {t('chat.interrupted', 'Interrupted')} diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 0ee11d1eb..62356637c 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -28,7 +28,7 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi const el = ref.current if (el) setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 40) }} - sx={[{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }, scrollbarStyles]} + sx={[{ flexGrow: 1, overflowY: 'auto', paddingX: 2.5 }, scrollbarStyles]} > {messages.map((message, index) => ( diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index f91abeb73..907a061a8 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -83,7 +83,7 @@ export const ChatPanel: React.FC = () => { bgcolor: 'primaryHighlight.main', borderLeft: docked || sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', - paddingBottom: showBottomMenu ? 1 : insets?.bottomPx || 1, + paddingBottom: showBottomMenu ? 1.5 : insets?.bottomPx || 1.5, }} ref={panelRef} > From 6d6268cec48d7e9c1ecfc4842ad55a49beb5327f Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 09:02:25 -0700 Subject: [PATCH 128/238] style(chat): theme text tokens, roomier bubbles, edge shadow, standard overflow fade --- .../src/components/Chat/ChatMessageItem.tsx | 20 +++++++--- frontend/src/components/Chat/ChatMessages.tsx | 37 ++++++++++++------- frontend/src/components/Chat/ChatPanel.tsx | 3 ++ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 89049ee13..40f170105 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -24,8 +24,8 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ if (message.role === 'user') return ( - - + + {message.text} @@ -42,15 +42,25 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ { bgcolor: 'white.main', borderRadius: 2, - paddingX: 1.5, - paddingY: 1, + paddingX: 2, + paddingY: 1.5, fontSize: fontSizes.base, lineHeight: 1.5, wordBreak: 'break-word', + // Theme tokens, and the app's own emphasis convention (see theme.ts + // body1/caption): bold is a COLOR step plus weight 500 — never 700. + color: 'grayDarker.main', + '& strong, & b': { fontWeight: 500, color: 'grayDarkest.main' }, '& p': { marginY: 0.75 }, '& ul, & ol': { paddingLeft: 3, marginY: 0.5 }, '& li': { marginY: 0.25 }, - '& h1, & h2, & h3, & h4': { fontSize: 15, marginTop: 1.5, marginBottom: 0.5 }, + '& h1, & h2, & h3, & h4': { + fontSize: 15, + fontWeight: 500, + color: 'grayDarkest.main', + marginTop: 1.5, + marginBottom: 0.5, + }, '& a': { color: 'primary.main' }, '& code': { fontFamily: "'Roboto Mono', monospace", diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 62356637c..286aee847 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -3,7 +3,7 @@ import { Box } from '@mui/material' import { ChatTranscriptMessage } from '../../models/chat' import { ChatMessageItem } from './ChatMessageItem' import { ChatTyping } from './ChatTyping' -import { scrollbarStyles } from './chatScrollbar' +import { Body } from '../Body' type Props = { messages: ChatTranscriptMessage[] @@ -21,20 +21,29 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi if (pinned) ref.current?.scrollTo({ top: ref.current.scrollHeight }) }, [messages, streaming, typing, pinned, children]) + /* Body owns the scroll element, so the pin check listens on its node instead of + an onScroll prop. Bound once — the handler only reads live layout. */ + useEffect(() => { + const element = ref.current + if (!element) return + const onScroll = () => setPinned(element.scrollHeight - element.scrollTop - element.clientHeight < 40) + element.addEventListener('scroll', onScroll) + return () => element.removeEventListener('scroll', onScroll) + }, []) + + /* The app's standard scroll surface: Body draws the bottom overflow fade the + settings and device pages use. Its fade is absolutely positioned, so it needs + this relative wrapper to sit at the transcript's bottom edge rather than the + panel's — otherwise it lands under the composer. */ return ( - { - const el = ref.current - if (el) setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 40) - }} - sx={[{ flexGrow: 1, overflowY: 'auto', paddingX: 2.5 }, scrollbarStyles]} - > - {messages.map((message, index) => ( - - ))} - {typing && } - {children} + + + {messages.map((message, index) => ( + + ))} + {typing && } + {children} + ) } diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 907a061a8..f7d065672 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -83,6 +83,9 @@ export const ChatPanel: React.FC = () => { bgcolor: 'primaryHighlight.main', borderLeft: docked || sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', + // Lifts the column off the content behind it — inset so it reads as depth + // at the seam rather than a drop shadow cast onto the page + boxShadow: theme => `inset 10px 0 12px -10px ${theme.palette.shadow.main}`, paddingBottom: showBottomMenu ? 1.5 : insets?.bottomPx || 1.5, }} ref={panelRef} From 049a2a2eb0ca182003ab8b29408554f948dac502 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 09:12:17 -0700 Subject: [PATCH 129/238] fix(chat): add a top overflow fade and lift the edge shadow above it --- frontend/src/components/Body.tsx | 18 +++++++++++++++ frontend/src/components/Chat/ChatMessages.tsx | 2 +- frontend/src/components/Chat/ChatPanel.tsx | 22 ++++++++++++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Body.tsx b/frontend/src/components/Body.tsx index f98087d50..af4cac348 100644 --- a/frontend/src/components/Body.tsx +++ b/frontend/src/components/Body.tsx @@ -14,6 +14,9 @@ export type BodyProps = { gutterBottom?: boolean gutterTop?: boolean verticalOverflow?: boolean + /** Also fade the TOP edge of the vertical overflow. Opt-in so existing + * scroll surfaces keep their single bottom fade. */ + fadeTop?: boolean horizontalOverflow?: boolean scrollbarBackground?: Color children?: React.ReactNode @@ -30,6 +33,7 @@ export const Body: React.FC = ({ gutterBottom, gutterTop, verticalOverflow, + fadeTop, horizontalOverflow, scrollbarBackground, children, @@ -54,6 +58,20 @@ export const Body: React.FC = ({ })} /> )} + {verticalOverflow && fadeTop && ( + ({ + position: 'absolute', + height: 30, + zIndex: 7, + width: '100%', + right: horizontalOverflow ? `${scrollbarWidth}px` : undefined, + top: 0, + backgroundImage: `linear-gradient(${theme.palette[bg].main}, transparent)`, + pointerEvents: 'none', + })} + /> + )} {horizontalOverflow && ( ({ diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 286aee847..9c4fcdf9f 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -37,7 +37,7 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi panel's — otherwise it lands under the composer. */ return ( - + {messages.map((message, index) => ( ))} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index f7d065672..5eda2225c 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -83,13 +83,29 @@ export const ChatPanel: React.FC = () => { bgcolor: 'primaryHighlight.main', borderLeft: docked || sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', - // Lifts the column off the content behind it — inset so it reads as depth - // at the seam rather than a drop shadow cast onto the page - boxShadow: theme => `inset 10px 0 12px -10px ${theme.palette.shadow.main}`, paddingBottom: showBottomMenu ? 1.5 : insets?.bottomPx || 1.5, }} ref={panelRef} > + {/* Leading-edge shadow drawn as an OVERLAY rather than an inset box-shadow on + the panel: a box-shadow paints beneath the element's own children, so Body's + overflow fade (zIndex 7) cut across it. zIndex 8 clears the fade, and + PanelHandle follows at the same level in DOM order so the drag divider still + sits on top of it. */} + {(docked || !!sidebarWidth) && ( + ({ + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 12, + zIndex: 8, + pointerEvents: 'none', + backgroundImage: `linear-gradient(90deg, ${theme.palette.shadow.main}, transparent)`, + })} + /> + )} {docked && } Date: Thu, 27 Aug 2026 09:19:11 -0700 Subject: [PATCH 130/238] refactor(chat): move radii, sizes and weights onto theme tokens; use an elevation shadow --- frontend/src/components/Chat/ChatApproval.tsx | 5 +++-- .../src/components/Chat/ChatMessageItem.tsx | 16 +++++++-------- frontend/src/components/Chat/ChatPanel.tsx | 20 +------------------ .../src/components/Chat/ChatToolCalls.tsx | 3 ++- frontend/src/components/Chat/ChatTyping.tsx | 3 ++- frontend/src/components/Chat/ChatUsage.tsx | 3 ++- 6 files changed, 18 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/Chat/ChatApproval.tsx b/frontend/src/components/Chat/ChatApproval.tsx index 912806a9c..90a64304c 100644 --- a/frontend/src/components/Chat/ChatApproval.tsx +++ b/frontend/src/components/Chat/ChatApproval.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Trans, useTranslation } from 'react-i18next' import { Paper, Typography, Button, Box } from '@mui/material' +import { radius } from '../../styling' type Props = { toolName: string @@ -12,7 +13,7 @@ type Props = { export const ChatApproval: React.FC = ({ toolName, input, onRespond }) => { const { t } = useTranslation() return ( - + = ({ toolName, input, onRespond }) => {JSON.stringify(input, null, 2)} diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 40f170105..658ecfb67 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -3,7 +3,7 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { useTranslation } from 'react-i18next' import { Box, Typography } from '@mui/material' -import { fontSizes } from '../../styling' +import { fontSizes, radius } from '../../styling' import { ChatTranscriptMessage } from '../../models/chat' import { ChatToolCalls } from './ChatToolCalls' import { scrollbarStyles } from './chatScrollbar' @@ -24,7 +24,7 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ if (message.role === 'user') return ( - + {message.text} @@ -41,7 +41,7 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ theme => ({ '& pre, & table': scrollbarStyles(theme) }), { bgcolor: 'white.main', - borderRadius: 2, + borderRadius: `${radius.lg}px`, paddingX: 2, paddingY: 1.5, fontSize: fontSizes.base, @@ -50,13 +50,13 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ // Theme tokens, and the app's own emphasis convention (see theme.ts // body1/caption): bold is a COLOR step plus weight 500 — never 700. color: 'grayDarker.main', - '& strong, & b': { fontWeight: 500, color: 'grayDarkest.main' }, + '& strong, & b': { fontWeight: 'medium', color: 'grayDarkest.main' }, '& p': { marginY: 0.75 }, '& ul, & ol': { paddingLeft: 3, marginY: 0.5 }, '& li': { marginY: 0.25 }, '& h1, & h2, & h3, & h4': { - fontSize: 15, - fontWeight: 500, + fontSize: fontSizes.md, + fontWeight: 'medium', color: 'grayDarkest.main', marginTop: 1.5, marginBottom: 0.5, @@ -66,14 +66,14 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ fontFamily: "'Roboto Mono', monospace", fontSize: fontSizes.sm, bgcolor: 'grayLighter.main', - borderRadius: 1, + borderRadius: `${radius.sm}px`, paddingX: 0.5, paddingY: 0.25, }, '& pre': { overflowX: 'auto', bgcolor: 'grayLighter.main', - borderRadius: 2, + borderRadius: `${radius.lg}px`, padding: 1.5, '& code': { padding: 0, bgcolor: 'transparent' }, }, diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 5eda2225c..862bc25f7 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -83,29 +83,11 @@ export const ChatPanel: React.FC = () => { bgcolor: 'primaryHighlight.main', borderLeft: docked || sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', + boxShadow: docked || sidebarWidth ? 3 : 0, paddingBottom: showBottomMenu ? 1.5 : insets?.bottomPx || 1.5, }} ref={panelRef} > - {/* Leading-edge shadow drawn as an OVERLAY rather than an inset box-shadow on - the panel: a box-shadow paints beneath the element's own children, so Body's - overflow fade (zIndex 7) cut across it. zIndex 8 clears the fade, and - PanelHandle follows at the same level in DOM order so the drag divider still - sits on top of it. */} - {(docked || !!sidebarWidth) && ( - ({ - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: 12, - zIndex: 8, - pointerEvents: 'none', - backgroundImage: `linear-gradient(90deg, ${theme.palette.shadow.main}, transparent)`, - })} - /> - )} {docked && } = ({ toolCalls }) => { const { t } = useTranslation() @@ -11,7 +12,7 @@ export const ChatToolCalls: React.FC<{ toolCalls: ChatToolCall[] }> = ({ toolCal const running = toolCalls.some(c => c.status === 'running') return ( - setOpen(!open)} sx={{ borderRadius: 1, paddingX: 0.5, color: 'grayDark.main' }}> + setOpen(!open)} sx={{ borderRadius: `${radius.sm}px`, paddingX: 0.5, color: 'grayDark.main' }}> {running ? ( ) : ( diff --git a/frontend/src/components/Chat/ChatTyping.tsx b/frontend/src/components/Chat/ChatTyping.tsx index 88ff6dd3e..c475264d2 100644 --- a/frontend/src/components/Chat/ChatTyping.tsx +++ b/frontend/src/components/Chat/ChatTyping.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Box } from '@mui/material' +import { radius } from '../../styling' /* "The agent is working" — three pulsing dots in an assistant-aligned bubble, shown while a turn is in flight but nothing else is visibly moving (before the first token, and between @@ -13,7 +14,7 @@ export const ChatTyping: React.FC = () => ( alignItems: 'center', gap: 0.75, bgcolor: 'grayLightest.main', - borderRadius: 2, + borderRadius: `${radius.lg}px`, paddingX: 1.5, paddingY: 1, '@keyframes chatTypingPulse': { diff --git a/frontend/src/components/Chat/ChatUsage.tsx b/frontend/src/components/Chat/ChatUsage.tsx index fbf476356..bb9d69979 100644 --- a/frontend/src/components/Chat/ChatUsage.tsx +++ b/frontend/src/components/Chat/ChatUsage.tsx @@ -5,6 +5,7 @@ import { Box, Popover, Typography, LinearProgress, CircularProgress, IconButton import { State } from '../../store' import { formatReset } from '../../models/chat' import { UsageWindow } from '../../services/agent' +import { radius } from '../../styling' const pct = (w: UsageWindow) => (w.unlimited || w.limitUsd <= 0 ? 0 : Math.min(100, Math.round((w.spentUsd / w.limitUsd) * 100))) @@ -25,7 +26,7 @@ const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, wi {!window.unlimited && ( <> - + {window.resetsAt && ( {t('chat.usageResets', 'Resets {{when}}', { when: formatReset(window.resetsAt) })} From bc04eb0ea21ef47e6baa9f18cc2c988fece61aa8 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 09:50:50 -0700 Subject: [PATCH 131/238] refactor(styling): one scrollbar implementation with an optional narrow width --- frontend/src/components/Body.tsx | 26 +++----------- .../src/components/Chat/ChatMessageItem.tsx | 7 ++-- frontend/src/components/Chat/chatScrollbar.ts | 16 --------- frontend/src/styling/index.ts | 35 +++++++++++++++++++ 4 files changed, 43 insertions(+), 41 deletions(-) delete mode 100644 frontend/src/components/Chat/chatScrollbar.ts diff --git a/frontend/src/components/Body.tsx b/frontend/src/components/Body.tsx index af4cac348..13c2a49e0 100644 --- a/frontend/src/components/Body.tsx +++ b/frontend/src/components/Body.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react' +import React from 'react' import browser from '../services/browser' import { Box, SxProps, Theme } from '@mui/material' -import { spacing, toSxArray } from '../styling' +import { spacing, toSxArray, scrollbarStyles, SCROLLBAR_WIDTH } from '../styling' export type BodyProps = { inset?: boolean @@ -38,8 +38,7 @@ export const Body: React.FC = ({ scrollbarBackground, children, }) => { - const [hover, setHover] = useState(false) - const scrollbarWidth = browser.isMobile ? 0 : 15 + const scrollbarWidth = browser.isMobile ? 0 : SCROLLBAR_WIDTH const bg: Color = scrollbarBackground || 'white' return ( @@ -90,9 +89,8 @@ export const Body: React.FC = ({ ref={bodyRef} className={className} style={maxHeight ? { maxHeight } : undefined} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} sx={[ + theme => scrollbarStyles(theme, { background: bg, width: scrollbarWidth }), theme => ({ flexGrow: 1, height: '100%', @@ -100,15 +98,6 @@ export const Body: React.FC = ({ overscrollBehaviorX: 'none', position: 'relative', WebkitOverflowScrolling: 'touch', - '&::-webkit-scrollbar': { WebkitAppearance: 'none' }, - '&::-webkit-scrollbar:vertical': { width: `${scrollbarWidth}px` }, - '&::-webkit-scrollbar:horizontal': { height: `${scrollbarWidth}px` }, - '&::-webkit-scrollbar-corner': { background: theme.palette[bg].main }, - '&::-webkit-scrollbar-thumb': { - borderRadius: '8px', - border: `4px solid ${theme.palette[bg].main}`, - backgroundColor: theme.palette[bg].main, - }, [theme.breakpoints.down('sm')]: { overflowX: 'hidden', }, @@ -135,13 +124,6 @@ export const Body: React.FC = ({ inset ? { padding: `${spacing.sm}px ${spacing.xl}px` } : {}, gutterBottom ? { paddingBottom: `${spacing.xxl}px` } : {}, gutterTop ? { paddingTop: `${spacing.sm}px` } : {}, - hover - ? (theme: Theme) => ({ - '&::-webkit-scrollbar-thumb': { - backgroundColor: `${theme.palette.grayLight.main} !important`, - }, - }) - : {}, ...toSxArray(sx), ]} > diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 658ecfb67..5ddda0068 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -3,10 +3,9 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { useTranslation } from 'react-i18next' import { Box, Typography } from '@mui/material' -import { fontSizes, radius } from '../../styling' +import { fontSizes, radius, scrollbarStyles, SCROLLBAR_WIDTH_NARROW } from '../../styling' import { ChatTranscriptMessage } from '../../models/chat' import { ChatToolCalls } from './ChatToolCalls' -import { scrollbarStyles } from './chatScrollbar' // Links open in a new tab: a bare anchor is a top-level navigation, which in // Electron replaces the app window with the external site (will-navigate only @@ -38,7 +37,9 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ {!!message.text && ( ({ '& pre, & table': scrollbarStyles(theme) }), + theme => ({ + '& pre, & table': scrollbarStyles(theme, { background: 'grayLighter', width: SCROLLBAR_WIDTH_NARROW }), + }), { bgcolor: 'white.main', borderRadius: `${radius.lg}px`, diff --git a/frontend/src/components/Chat/chatScrollbar.ts b/frontend/src/components/Chat/chatScrollbar.ts deleted file mode 100644 index dec8fd065..000000000 --- a/frontend/src/components/Chat/chatScrollbar.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Theme } from '@mui/material/styles' - -/* Slim, theme-matched scrollbars for the chat panel's scroll surfaces, - replacing the default browser bars */ -export const scrollbarStyles = (theme: Theme) => ({ - scrollbarWidth: 'thin' as const, // Firefox - scrollbarColor: `${theme.palette.grayLight.main} transparent`, // Firefox - '&::-webkit-scrollbar': { width: 8, height: 8, WebkitAppearance: 'none' as const }, - '&::-webkit-scrollbar-track': { background: 'transparent' }, - '&::-webkit-scrollbar-thumb': { - borderRadius: 4, - backgroundColor: theme.palette.grayLight.main, - '&:hover': { backgroundColor: theme.palette.gray.main }, - }, - '&::-webkit-scrollbar-corner': { background: 'transparent' }, -}) diff --git a/frontend/src/styling/index.ts b/frontend/src/styling/index.ts index b3a6466c4..9f867f721 100644 --- a/frontend/src/styling/index.ts +++ b/frontend/src/styling/index.ts @@ -116,3 +116,38 @@ export const radius = { sm: 7, lg: 14, } + +/* The app's scrollbar. One implementation, so every scroll surface matches: + the thumb hides against its own background and only appears while the pointer + is over the surface. `width` is the knob — the page default, or NARROW for + small overflow areas (inline code, tables) where the full bar would swamp the + content. Body passes 0 on mobile, where the OS draws its own overlay bar. + + Hover is CSS rather than React state so a scroll surface does not re-render + on pointer enter. */ +export const SCROLLBAR_WIDTH = 15 +export const SCROLLBAR_WIDTH_NARROW = 8 + +export const scrollbarStyles = (theme: Theme, options: { background?: Color; width?: number } = {}) => { + const { background = 'white', width = SCROLLBAR_WIDTH } = options + const bg = theme.palette[background].main + const thumb = theme.palette.grayLight.main + const narrow = width <= SCROLLBAR_WIDTH_NARROW + return { + // Firefox has no thumb pseudo-element; it takes the pair directly + scrollbarWidth: (narrow ? 'thin' : 'auto') as 'thin' | 'auto', + scrollbarColor: `${bg} transparent`, + '&:hover': { scrollbarColor: `${thumb} transparent` }, + '&::-webkit-scrollbar': { WebkitAppearance: 'none' as const }, + '&::-webkit-scrollbar:vertical': { width }, + '&::-webkit-scrollbar:horizontal': { height: width }, + '&::-webkit-scrollbar-corner': { background: bg }, + '&::-webkit-scrollbar-thumb': { + borderRadius: radius.sm, + // The inset border is what makes the thumb read as slim inside a wide track + border: `${narrow ? 2 : 4}px solid ${bg}`, + backgroundColor: bg, + }, + '&:hover::-webkit-scrollbar-thumb': { backgroundColor: thumb }, + } +} From 9a90add1a8aee506b154c8b162f4b8268e7934d1 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 09:56:39 -0700 Subject: [PATCH 132/238] feat(chat): the conversation name is the history picker, beside the expand button --- frontend/src/components/Chat/ChatHeader.tsx | 77 ++++++++++++++++++--- frontend/src/components/Chat/ChatPanel.tsx | 3 +- frontend/src/components/Chat/ChatWindow.tsx | 3 +- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 97d3fe6f2..e334395b7 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -1,17 +1,19 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import { Box, Menu, MenuItem, ListItemText, IconButton as MuiIconButton } from '@mui/material' +import { Box, ButtonBase, Menu, MenuItem, ListItemText, IconButton as MuiIconButton } from '@mui/material' import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' +import { fontSizes, radius } from '../../styling' /* Control row shared by the docked panel and the popout window — `leading` takes the - panel-chrome control (expand/collapse) at the far left, the window-specific actions - render as children on the right in each caller's order. Carries no title: the - conversation names itself in the history menu, where picking one is the point. - The row mirrors the app Header's box exactly — same height, same top margin, centered - — so the two icon rows share a baseline across the divider. */ + panel-chrome control (expand/collapse) at the far left, then the conversation's name, + which doubles as the history picker; window-specific actions render as children on the + right in each caller's order. The row mirrors the app Header's box exactly — same + height, same top margin, centered — so the two icon rows share a baseline across the + divider. The name is the only thing allowed to shrink: it takes the slack and yields + it back, so the action icons never compress. */ export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React.ReactNode }> = ({ leading, children, @@ -19,8 +21,10 @@ export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React. return ( {leading} - - {children} + + + + {children} ) } @@ -31,11 +35,15 @@ export const NewChatButton: React.FC = () => { return dispatch.chat.clearConversation()} /> } -/* History picker: the server-side conversation list (D11), newest first. Selecting one - loads its transcript; the trash affordance deletes it for real. */ +/* History picker: the conversation's NAME is the control — the header carries no + separate title, so the thing you read is the thing you click. Opens the server-side + list (D11), newest first; selecting one loads its transcript and the trash affordance + deletes it for real. */ export const HistoryButton: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() + const labelRef = React.useRef(null) + const [cropped, setCropped] = React.useState(false) const conversations = useSelector((state: State) => state.chat.conversations) const currentId = useSelector((state: State) => state.chat.conversationId) const currentTitle = useSelector((state: State) => state.chat.title) @@ -56,9 +64,56 @@ export const HistoryButton: React.FC = () => { } const close = () => setAnchorEl(null) + const label = currentTitle || t('chat.newSession', 'New chat') + + /* Fade the trailing edge only while the name is ACTUALLY cut off — an unconditional + mask would dissolve the last characters of a name that fits. Observed rather than + measured once, so dragging the panel narrower re-evaluates it. */ + React.useEffect(() => { + const element = labelRef.current + if (!element) return + const measure = () => setCropped(element.scrollWidth > element.clientWidth + 1) + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + return () => observer.disconnect() + }, [label]) + + const fade = 'linear-gradient(90deg, #000 calc(100% - 20px), transparent)' + return ( <> - + + + {label} + + + {items.length === 0 && ( diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 862bc25f7..6c646a565 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -9,7 +9,7 @@ import { useChatMainSync } from '../../hooks/useChatSync' import { usePanelDrag } from '../../hooks/usePanelDrag' import { PanelHandle } from '../PanelHandle' import { IconButton } from '../../buttons/IconButton' -import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' +import { ChatHeader, NewChatButton } from './ChatHeader' import { ChatBody } from './ChatBody' import browser from '../../services/browser' @@ -109,7 +109,6 @@ export const ChatPanel: React.FC = () => { onClick={() => dispatch.chat.popOut()} /> )} - dispatch.chat.set({ open: false })} /> diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index bf80b6994..d18867ac2 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -5,7 +5,7 @@ import { Box } from '@mui/material' import { Dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { useChatPopoutSync } from '../../hooks/useChatSync' -import { ChatHeader, NewChatButton, HistoryButton } from './ChatHeader' +import { ChatHeader, NewChatButton } from './ChatHeader' import { ChatBody } from './ChatBody' /* Full-page chat for the popped-out window (?chatPopout boot flag). Display @@ -29,7 +29,6 @@ export const ChatWindow: React.FC = () => { }} > - Date: Thu, 27 Aug 2026 10:02:18 -0700 Subject: [PATCH 133/238] feat(chat): standard hit box for the history control, History subheader, hover-only remove --- frontend/src/components/Chat/ChatHeader.tsx | 33 ++++++++++----------- frontend/src/components/Chat/ChatPanel.tsx | 2 +- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index e334395b7..783cad7bf 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -1,11 +1,11 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import { Box, ButtonBase, Menu, MenuItem, ListItemText, IconButton as MuiIconButton } from '@mui/material' +import { Box, Menu, MenuItem, ListItemText, ListSubheader, IconButton as MuiIconButton } from '@mui/material' import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' -import { fontSizes, radius } from '../../styling' +import { fontSizes } from '../../styling' /* Control row shared by the docked panel and the popout window — `leading` takes the panel-chrome control (expand/collapse) at the far left, then the conversation's name, @@ -83,21 +83,10 @@ export const HistoryButton: React.FC = () => { return ( <> - { > {label} - - + + + + {t('chat.history', 'History')} + {items.length === 0 && ( @@ -125,6 +117,10 @@ export const HistoryButton: React.FC = () => { key={c.id} dense selected={c.id === currentId} + sx={{ + '& .remove': { opacity: 0, transition: 'opacity 100ms' }, + '&:hover .remove, &:focus-within .remove': { opacity: 1 }, + }} onClick={() => { dispatch.chat.openConversation(c.id) close() @@ -137,6 +133,7 @@ export const HistoryButton: React.FC = () => { { @@ -144,7 +141,7 @@ export const HistoryButton: React.FC = () => { dispatch.chat.removeConversation(c.id) }} > - + ))} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 6c646a565..cb8f76aab 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -102,7 +102,7 @@ export const ChatPanel: React.FC = () => { ) } > - {!browser.isMobile && ( + {!browser.isMobile && !layout.mobile && ( Date: Thu, 27 Aug 2026 10:11:27 -0700 Subject: [PATCH 134/238] style(chat): line chevron that flips when open, menu rows aligned to the subheader --- frontend/src/components/Chat/ChatHeader.tsx | 25 ++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 783cad7bf..1053b2614 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -5,7 +5,7 @@ import { Box, Menu, MenuItem, ListItemText, ListSubheader, IconButton as MuiIcon import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' -import { fontSizes } from '../../styling' +import { fontSizes, spacing } from '../../styling' /* Control row shared by the docked panel and the popout window — `leading` takes the panel-chrome control (expand/collapse) at the far left, then the conversation's name, @@ -101,9 +101,28 @@ export const HistoryButton: React.FC = () => { > {label} - + + + - + {t('chat.history', 'History')} From 75fcc58a8ad173ddd8ffd0a0e8b5a7288f0a1981 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 10:14:44 -0700 Subject: [PATCH 135/238] style(chat): match the avatar menu's elevation and transparent list --- frontend/src/components/Chat/ChatHeader.tsx | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 1053b2614..6a3b3d861 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -32,7 +32,9 @@ export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React. export const NewChatButton: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() - return dispatch.chat.clearConversation()} /> + return ( + dispatch.chat.clearConversation()} /> + ) } /* History picker: the conversation's NAME is the control — the header carries no @@ -86,7 +88,14 @@ export const HistoryButton: React.FC = () => { { - { '& .MuiMenuItem-root.MuiMenuItem-dense': { paddingLeft: `${spacing.sm}px` }, }, }, - }}> + }} + > {t('chat.history', 'History')} From 5d0c1130ca1c333d46701e320b267c27ec3fad5f Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 10:27:47 -0700 Subject: [PATCH 136/238] i18n(catalogs): extract missing keys, drop dead ones, translate ja/de/es --- frontend/src/i18n/locales/de/app.json | 101 ++++++++++++++++++++-- frontend/src/i18n/locales/de/notices.json | 3 +- frontend/src/i18n/locales/en/app.json | 77 +++++++++++++++-- frontend/src/i18n/locales/es/app.json | 101 ++++++++++++++++++++-- frontend/src/i18n/locales/es/notices.json | 3 +- frontend/src/i18n/locales/ja/app.json | 101 ++++++++++++++++++++-- frontend/src/i18n/locales/ja/notices.json | 3 +- 7 files changed, 353 insertions(+), 36 deletions(-) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 1ba6bf5e7..388d60da2 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -176,6 +176,8 @@ }, "changePassword": { "currentPassword": "Aktuelles Passwort", + "mfaCode": "Authentifizierungscode", + "mfaPrompt": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein, um die Änderung Ihres Passworts abzuschließen.", "noticeAfter": "automatisch von anderen Sitzungen abgemeldet.", "noticeBefore": "Durch das Ändern Ihres Passworts werden Sie", "noticeEmphasis": "NICHT", @@ -187,15 +189,22 @@ "approve": "Genehmigen", "close": "Schließen", "collapse": "Verkleinern", - "currentOrg": "Aktuelle Organisation", + "deleteConversation": "Löschen", "deny": "Ablehnen", "expand": "Vergrößern", + "history": "Verlauf", + "historyEmpty": "Keine früheren Unterhaltungen", + "inputPlaceholder": "Mit Remote.It chatten", "interrupted": "Unterbrochen", + "introBody": "Verwalten Sie Ihre Geräte, Verbindungen und Dienste — fragen Sie einfach.", + "introTitle": "Remote.It AI", "newChat": "Neuer Chat", - "organization": "Organisation", - "personal": "Persönlich", + "newSession": "Neuer Chat", "popIn": "Wieder andocken", "popOut": "In eigenem Fenster öffnen", + "prompt1": "Welche meiner Geräte sind offline?", + "prompt2": "Meine letzten Verbindungen anzeigen", + "prompt3": "Einen Dienst auf einem meiner Geräte neu starten", "send": "Senden", "signIn": "Mit remote.it anmelden", "signInFromMain": "Melden Sie sich im Hauptfenster der App an.", @@ -205,6 +214,13 @@ "toolsUsed_one": "{{count}} Tool verwendet", "toolsUsed_other": "{{count}} Tools verwendet", "unavailable": "Mycal ist vorübergehend nicht verfügbar. Überprüfen Sie Ihre Internetverbindung oder versuchen Sie es in einigen Minuten erneut.", + "untitled": "Neue Unterhaltung", + "usage": "Nutzung", + "usageResets": "Zurücksetzung {{when}}", + "usageSession": "5-Stunden-Sitzung", + "usageTitle": "Nutzung", + "usageUnlimited": "Kein Limit", + "usageWeekly": "Diese Woche", "waitingApproval": "Warten auf Genehmigung…", "windowTitle": "remote.it Chat" }, @@ -325,6 +341,7 @@ "close": "Schließen", "confirm": "Bestätigen", "confirmingEllipsis": "Wird bestätigt...", + "continue": "Weiter", "delete": "Löschen", "disable": "Deaktivieren", "done": "Fertig", @@ -340,6 +357,7 @@ "saved": "Gespeichert", "saving": "Wird gespeichert", "savingEllipsis": "Wird gespeichert...", + "verify": "Bestätigen", "yes": "Ja" }, "connect": { @@ -365,26 +383,48 @@ "resetConnection": "Verbindung zurücksetzen" }, "connectedAppDetailPage": { + "accounts": "Konten", + "addAccount": "{{label}} — hinzufügen", + "allAccounts": "Alle Konten, einschließlich später hinzugefügter", + "allAccountsAdd": "Alle Konten, einschließlich später hinzugefügter — hinzufügen", + "allAccountsPlain": "jedes Konto, einschließlich derer, denen Sie später beitreten", "authorized": "Autorisiert", + "confirmExtend": "{{name}} Zugriff gewähren, den die App noch nicht hat?\n\nWird hinzugefügt: {{list}}", + "delayedReach": "Bereits laufender Zugriff auf {{apis}} endet innerhalb von {{window}}.", "details": "Details", "deviceAccess": "Gerätezugriff", + "editHint": "Gewährt, als {{name}} sich angemeldet hat. Tippen Sie auf eine Berechtigung, um sie zu deaktivieren — sie bleibt aufgeführt, sodass Sie sie später wieder aktivieren können.", "grantedWhenSignedIn": "Gewährt, als {{name}} sich angemeldet hat. Um sie zu ändern, widerrufen Sie den Zugriff und melden Sie sich erneut an.", "lastActive": "Zuletzt aktiv", + "lastUsed": "Zuletzt verwendet", "noActivityYet": "Noch keine Aktivität", "noDeviceAccess": "Kein Gerätezugriff – die App kann Ihre Identität bestätigen, aber keine Geräte sehen oder steuern.", "noLongerAuthorized": "Diese App ist nicht mehr autorisiert.", + "notGranted": "{{label}} — nicht gewährt", + "notGrantedHint": "Diese App hat dies angefragt und Sie haben es nicht gewährt. Sie können es hier aktivieren.", "permissions": "Berechtigungen", "requestAgain": "Sie kann durch erneutes Anmelden erneut Zugriff anfordern.", "revoke": "Widerrufen", "revokeAccess": "Zugriff widerrufen", "revokeAccessConfirmTitle": "Zugriff widerrufen?", + "revokeExplain": "Meldet {{name}} von Ihrem Konto ab und blockiert neuen Zugriff. Die App kann durch erneute Anmeldung wieder Zugriff anfragen.", + "revokeSection": "Zugriff widerrufen", + "revoked": "widerrufen", + "revokedHint": "Dieser Zugriff wurde widerrufen — zur Dokumentation angezeigt. {{name}} kann durch erneute Anmeldung wieder Zugriff anfragen.", + "save": "Änderungen speichern", + "saveFailed": "Diese Änderung konnte nicht gespeichert werden.", "service": "Dienst", + "signInOnly": "Nur Anmeldung — die App kann Ihre Identität bestätigen, hat aber sonst keine Berechtigungen erhalten.", + "signInScopes": "Anmelde-Scopes", "signOutBefore": "wird abgemeldet. Neuer Zugriff wird sofort blockiert; bereits laufende Sitzungen enden innerhalb von", - "title": "Verbundene App" + "title": "Verbundene App", + "willAdd": "Dies gewährt der App Zugriff, den sie noch nicht hat: {{list}}" }, "connectedApps": { "empty": "Sie haben noch keine Apps autorisiert.", "loading": "Wird geladen…", + "reauth": "Melden Sie sich erneut an, um Ihre verbundenen Apps zu sehen — Ihre aktuelle Sitzung begann, bevor diese Seite sie anfragen konnte.", + "reauthAction": "Erneut anmelden", "title": "Apps & KI-Agenten" }, "connectedAppsPage": { @@ -965,6 +1005,32 @@ "logsPage": { "title": "Protokolle" }, + "mfa": { + "choose": "Wie möchten Sie Ihren Code erhalten? (totp oder sms)", + "codesTitle": "Speichern Sie Ihre Wiederherstellungscodes — jeder kann einmal verwendet werden, falls Sie Ihren Authenticator verlieren. Sie werden nicht erneut angezeigt.", + "confirmPassword": "Bestätigen Sie Ihr Passwort, um fortzufahren — beim Ändern einer Anmeldeinformation weisen Sie die vorhandene erneut nach.", + "disable": "Deaktivieren", + "enable": "Einrichten", + "failed": "Etwas ist schiefgelaufen — bitte erneut versuchen.", + "federated": "Sie melden sich über einen Identitätsanbieter (z. B. Google) an, daher werden Ihr Passwort und die Zwei-Faktor-Authentifizierung dort verwaltet. Um ein Remote.It-Passwort hinzuzufügen — nutzbar zusätzlich zu Ihrem Anbieter — richten Sie zuerst eines ein.", + "method": "Methode", + "off": "Aus", + "on": "An", + "phone": "Mobilnummer (+15555550123)", + "prefer": "Als bevorzugt festlegen", + "preferred": "An · bevorzugt", + "protects": "Die bevorzugte Methode prüft jede Anmeldung bei diesem Konto.", + "relay": "Geben Sie den 6-stelligen Code Ihres aktuellen zweiten Faktors ein.", + "relayHint": "Geben Sie den an {{hint}} gesendeten Code ein.", + "scan": "Scannen Sie den Code mit Ihrer Authenticator-App und geben Sie dann deren 6-stelligen Code ein.", + "secret": "Oder geben Sie den Schlüssel manuell ein:", + "setPassword": "Passwort festlegen", + "smsSent": "Wir haben einen Code an Ihr Telefon gesendet — geben Sie ihn ein, um SMS-Codes zu aktivieren.", + "suggest": "Schützen Sie Ihr Konto mit einer Authenticator-App oder per SMS.", + "title": "Zwei-Faktor-Authentifizierung", + "wrongCode": "Dieser Code stimmt nicht — bitte erneut versuchen.", + "wrongPassword": "Dieses Passwort stimmt nicht." + }, "mfaConfigureApp": { "code": "Code: {{code}}", "generateNewCode": "Neuen QR-Code generieren", @@ -1046,6 +1112,7 @@ "signOutConfirm": "Durch das Abmelden kann dieses Gerät übertragen oder ein anderer Benutzer angemeldet werden. Dadurch werden alle Verbindungen beendet.", "support": "Support", "supportForum": "Support-Forum", + "switchAccount": "Konto wechseln", "testUI": "Test-UI", "testUIConfirm": "Das Aktivieren von Alpha-Funktionen kann zu Instabilität führen. Dies ist nur für Test- und Entwicklungszwecke vorgesehen.", "thisDevice": "Dieses Gerät", @@ -1390,6 +1457,15 @@ "noPartnersFound": "Keine Partner gefunden", "searchPlaceholder": "Partner suchen..." }, + "passkeys": { + "add": "Passkey hinzufügen", + "added": "Passkey hinzugefügt — verwenden Sie ihn bei der nächsten Anmeldung anstelle eines Codes.", + "defaultName": "Dieses Gerät", + "explainer": "Mit einem Passkey melden Sie sich hier per Fingertipp statt mit einem Code an. SMS- oder Authenticator-Codes schützen weiterhin Anmeldungen aus älteren Apps.", + "failed": "Etwas ist schiefgelaufen — bitte erneut versuchen.", + "needFactor": "Richten Sie zuerst einen Authenticator oder SMS-Codes ein.", + "title": "Passkeys" + }, "passwordStrengthInput": { "confirmNewPassword": "Neues Passwort bestätigen", "enterNewPassword": "Neues Passwort eingeben", @@ -1957,8 +2033,15 @@ "testPage": { "agentURL": "Agentendienst-URL", "agentURLInvalid": "Die Agentendienst-URL muss mit https:// beginnen", + "apiTarget": "API-Ziel", + "backgroundWork": "KI-Hintergrundarbeit", + "backgroundWorkOff": "Der Agent arbeitet nur, während Sie hier sind.", + "backgroundWorkOn": "Der Agent kann lesen und beobachten, während Sie abwesend sind.", + "backgroundWorkUnknown": "Wird geprüft…", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", "clearViewedAnnouncementsHint": "Markiert alle geladenen Ankündigungen für dieses Konto als ungelesen.", + "customGraphQLURL": "GraphQL-URL", + "customWebSocketURL": "WebSocket-URL", "disableTestUI": "Test-UI deaktivieren", "disableTestUIHint": "Um die Alpha-UI wieder zu aktivieren, müssen Sie das Avatar-Menü bei gedrückter Alt-Umschalt-Taste auswählen.", "featureLabel": "{{name}} (Standard {{state}})", @@ -1966,7 +2049,7 @@ "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", "licensingOptions": "Lizenzierungsoptionen", - "mcpAudience": "Agent-MCP-Audience", + "mintError": "Dieses Ziel wurde bei der Token-Ausstellung abgelehnt: {{error}}", "overrideDefaultAPIs": "Standard-APIs überschreiben", "overrideLicensesAndLimits": "Lizenzen und Limits überschreiben", "reset": "Zurücksetzen", @@ -1975,10 +2058,11 @@ "setServiceLicensesHint": "Legt die Lizenzierung aller Geräte in dieser Reihenfolge fest: UNKNOWN, EVALUATION, LICENSED, UNLICENSED, NON_COMMERCIAL, LEGACY", "showLatestAnnouncement": "Neueste Ankündigung anzeigen", "showLatestAnnouncementHint": "Zeigt eine Vorschau der neuesten Ankündigung, ohne deren Lesestatus zu ändern.", - "switchGraphQLAPIs": "GraphQL-APIs wechseln", + "stagesEmpty": "Der Autorisierungsserver führt für diesen Client keine umschaltbaren Ziele auf.", + "stagesError": "Verfügbare Ziele konnten nicht geladen werden ({{status}}) — geben Sie unten eine URL ein.", + "stagesLoading": "Verfügbare Ziele werden geladen…", "testOptions": "Testoptionen", - "title": "Testeinstellungen", - "webSocketURL": "WebSocket-URL" + "title": "Testeinstellungen" }, "timeSeriesSelect": { "graphLength": "Diagrammlänge", @@ -2027,6 +2111,7 @@ }, "viewAsBanner": { "exit": "Ansichtsmodus verlassen", + "supportSession": "Support-Sitzung — Ansicht als {{email}}. Tokens tragen Ihre Identität; die Person kann diese Sitzung sehen und beenden.", "viewingAs": "Anzeige als: {{email}}" }, "graphType": { diff --git a/frontend/src/i18n/locales/de/notices.json b/frontend/src/i18n/locales/de/notices.json index aca31a99c..d29430438 100644 --- a/frontend/src/i18n/locales/de/notices.json +++ b/frontend/src/i18n/locales/de/notices.json @@ -13,7 +13,8 @@ "authRequired": "Agentenauthentifizierung erforderlich — melden Sie sich an, um fortzufahren.", "popupBlocked": "Pop-out blockiert — erlauben Sie Pop-ups für diese Seite und versuchen Sie es erneut.", "sessionExpired": "Agentensitzung abgelaufen — melden Sie sich erneut an, um fortzufahren.", - "signInFailed": "Agentenanmeldung fehlgeschlagen — {{error}}" + "signInFailed": "Agentenanmeldung fehlgeschlagen — {{error}}", + "usageReset": "{{msg}} Zurücksetzung {{when}}." }, "connection": { "surveyFailed": "Die Übermittlung der Verbindungsumfrage ist fehlgeschlagen. Bitte wenden Sie sich an den Support." diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 2c43ddb2b..dd652fd66 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -176,6 +176,8 @@ }, "changePassword": { "currentPassword": "Current Password", + "mfaCode": "Authentication code", + "mfaPrompt": "Enter the 6-digit code from your authenticator to finish changing your password.", "noticeAfter": "automatically sign you out of other sessions.", "noticeBefore": "Changing your password will", "noticeEmphasis": "NOT", @@ -187,7 +189,6 @@ "approve": "Approve", "close": "Close", "collapse": "Collapse", - "currentOrg": "Current Org", "deny": "Deny", "expand": "Expand", "interrupted": "Interrupted", @@ -202,9 +203,6 @@ "historyEmpty": "No past conversations", "untitled": "New conversation", "deleteConversation": "Delete", - "organization": "Organization", - "personal": "Personal", - "actingIn": "Acting in {{org}}", "newSession": "New chat", "inputPlaceholder": "Chat with Remote.It", "introTitle": "Remote.It AI", @@ -222,7 +220,6 @@ "toolRequest": "The agent wants to run {{tool}}", "toolsUsed_one": "Used {{count}} tool", "toolsUsed_other": "Used {{count}} tools", - "title": "Remote.It AI", "unavailable": "Remote.It AI is temporarily unavailable. Check your internet connection or try again in a few minutes.", "waitingApproval": "Waiting for approval…", "windowTitle": "remote.it chat" @@ -344,6 +341,7 @@ "close": "Close", "confirm": "Confirm", "confirmingEllipsis": "Confirming...", + "continue": "Continue", "delete": "Delete", "disable": "Disable", "done": "Done", @@ -359,6 +357,7 @@ "saved": "Saved", "saving": "Saving", "savingEllipsis": "Saving...", + "verify": "Verify", "yes": "Yes" }, "connect": { @@ -384,26 +383,48 @@ "resetConnection": "Reset connection" }, "connectedAppDetailPage": { + "accounts": "Accounts", + "addAccount": "{{label}} — add", + "allAccounts": "All accounts, including ones added later", + "allAccountsAdd": "All accounts, including ones added later — add", + "allAccountsPlain": "every account, including ones you join later", "authorized": "Authorized", + "confirmExtend": "Give {{name}} access it does not have yet?\n\nAdding: {{list}}", + "delayedReach": "Access already in progress at {{apis}} ends within {{window}}.", "details": "Details", "deviceAccess": "Device access", + "editHint": "Granted when {{name}} signed in. Tap a permission to disable it — it stays listed so you can re-enable it later.", "grantedWhenSignedIn": "Granted when {{name}} signed in. To change them, revoke access and have it sign in again.", "lastActive": "Last active", + "lastUsed": "Last used", "noActivityYet": "No activity yet", "noDeviceAccess": "No device access — it can confirm your identity, but cannot see or control any devices.", "noLongerAuthorized": "This app is no longer authorized.", + "notGranted": "{{label}} — not granted", + "notGrantedHint": "This app asked for this and you did not grant it. You can turn it on here.", "permissions": "Permissions", "requestAgain": "It can request access again by signing in.", "revoke": "Revoke", "revokeAccess": "Revoke access", "revokeAccessConfirmTitle": "Revoke access?", + "revokeExplain": "Signs {{name}} out of your account and blocks it from getting new access. It can request access again by signing in.", + "revokeSection": "Revoke access", + "revoked": "revoked", + "revokedHint": "This access was revoked — shown for the record. {{name}} can request access again by signing in.", + "save": "Save changes", + "saveFailed": "That change could not be saved.", "service": "Service", + "signInOnly": "Sign-in only — it can confirm your identity, but was granted nothing else.", + "signInScopes": "Sign-in scopes", "signOutBefore": "will be signed out. New access is blocked immediately; any session already in progress ends within", - "title": "Connected App" + "title": "Connected App", + "willAdd": "This gives the app access it does not have yet: {{list}}" }, "connectedApps": { "empty": "You have not authorized any apps yet.", "loading": "Loading…", + "reauth": "Sign in again to see your connected apps — your current session started before this page could ask for them.", + "reauthAction": "Sign in again", "title": "Apps & AI agents" }, "connectedAppsPage": { @@ -1008,6 +1029,32 @@ "logsPage": { "title": "Logs" }, + "mfa": { + "choose": "How would you like to get your code? (totp or sms)", + "codesTitle": "Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.", + "confirmPassword": "Confirm your password to continue — changing a credential re-proves the one you hold.", + "disable": "Turn Off", + "enable": "Set Up", + "failed": "Something went wrong — try again.", + "federated": "You sign in with an identity provider (like Google), so your password and two-factor are managed there. To add a Remote.It password — usable alongside your provider — set one up first.", + "method": "Method", + "off": "Off", + "on": "On", + "phone": "Mobile number (+15555550123)", + "prefer": "Make preferred", + "preferred": "On · preferred", + "protects": "The preferred method challenges every sign-in with this account.", + "relay": "Enter the 6-digit code from your current second factor.", + "relayHint": "Enter the code sent to {{hint}}.", + "scan": "Scan with your authenticator app, then enter its 6-digit code.", + "secret": "Or enter the key manually:", + "setPassword": "Set a Password", + "smsSent": "We texted a code to your phone — enter it to finish turning on text-message codes.", + "suggest": "Protect your account with an authenticator app or text messages.", + "title": "Two-Factor Authentication", + "wrongCode": "That code didn't match — try again.", + "wrongPassword": "That password didn't match." + }, "mfaConfigureApp": { "code": "Code: {{code}}", "generateNewCode": "Generate new QR Code", @@ -1089,6 +1136,7 @@ "signOutConfirm": "Signing out will allow this device to be transferred or another user to sign in. It will stop all connections.", "support": "Support", "supportForum": "Support Forum", + "switchAccount": "Switch account", "testUI": "Test UI", "testUIConfirm": "Enabling alpha features may be unstable. It is only intended for testing and development.", "thisDevice": "This Device", @@ -1433,6 +1481,15 @@ "noPartnersFound": "No partners found", "searchPlaceholder": "Search partners..." }, + "passkeys": { + "add": "Add a Passkey", + "added": "Passkey added — next sign-in, use it instead of typing a code.", + "defaultName": "This device", + "explainer": "A passkey signs you in here with a touch instead of a code. Text or authenticator codes still protect sign-ins from older apps.", + "failed": "Something went wrong — try again.", + "needFactor": "Set up an authenticator or text codes first.", + "title": "Passkeys" + }, "passwordStrengthInput": { "confirmNewPassword": "Confirm new password", "enterNewPassword": "Enter new password", @@ -2047,10 +2104,11 @@ "setServiceLicensesHint": "Will set all devices licensing in order to: UNKNOWN, EVALUATION, LICENSED, UNLICENSED, NON_COMMERCIAL, LEGACY", "showLatestAnnouncement": "Show latest announcement", "showLatestAnnouncementHint": "Previews the latest announcement without changing its read status.", - "switchGraphQLAPIs": "Switch GraphQL APIs", + "stagesEmpty": "The authorization server lists no switchable targets for this client.", + "stagesError": "Could not load available targets ({{status}}) — enter a URL below.", + "stagesLoading": "Loading available targets…", "testOptions": "Test Options", - "title": "Test Settings", - "webSocketURL": "WebSocket URL" + "title": "Test Settings" }, "timeSeriesSelect": { "graphLength": "Graph length", @@ -2099,6 +2157,7 @@ }, "viewAsBanner": { "exit": "Exit view-as mode", + "supportSession": "Support session — viewing as {{email}}. Tokens are stamped with your identity; the user can see and end this session.", "viewingAs": "Viewing as: {{email}}" }, "appType": { diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 3663f5463..d48a9b81b 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -179,6 +179,8 @@ }, "changePassword": { "currentPassword": "Contraseña actual", + "mfaCode": "Código de autenticación", + "mfaPrompt": "Introduzca el código de 6 dígitos de su autenticador para terminar de cambiar la contraseña.", "noticeAfter": "cerrará automáticamente tu sesión en otros dispositivos.", "noticeBefore": "Cambiar tu contraseña", "noticeEmphasis": "NO", @@ -190,15 +192,22 @@ "approve": "Aprobar", "close": "Cerrar", "collapse": "Contraer", - "currentOrg": "Organización actual", + "deleteConversation": "Eliminar", "deny": "Denegar", "expand": "Expandir", + "history": "Historial", + "historyEmpty": "No hay conversaciones anteriores", + "inputPlaceholder": "Chatea con Remote.It", "interrupted": "Interrumpido", + "introBody": "Gestione sus dispositivos, conexiones y servicios — solo pregunte.", + "introTitle": "Remote.It AI", "newChat": "Nuevo chat", - "organization": "Organización", - "personal": "Personal", + "newSession": "Nuevo chat", "popIn": "Volver a acoplar", "popOut": "Abrir en ventana propia", + "prompt1": "¿Qué dispositivos míos están desconectados?", + "prompt2": "Mostrar mis conexiones recientes", + "prompt3": "Reiniciar un servicio en uno de mis dispositivos", "send": "Enviar", "signIn": "Iniciar sesión con remote.it", "signInFromMain": "Inicie sesión desde la ventana principal de la aplicación.", @@ -209,6 +218,13 @@ "toolsUsed_many": "{{count}} de herramientas utilizadas", "toolsUsed_other": "{{count}} herramientas utilizadas", "unavailable": "Mycal no está disponible temporalmente. Compruebe su conexión a internet o inténtelo de nuevo en unos minutos.", + "untitled": "Nueva conversación", + "usage": "Uso", + "usageResets": "Se restablece {{when}}", + "usageSession": "Sesión de 5 horas", + "usageTitle": "Uso", + "usageUnlimited": "Sin límite", + "usageWeekly": "Esta semana", "waitingApproval": "Esperando aprobación…", "windowTitle": "Chat de remote.it" }, @@ -329,6 +345,7 @@ "close": "Cerrar", "confirm": "Confirmar", "confirmingEllipsis": "Confirmando...", + "continue": "Continuar", "delete": "Eliminar", "disable": "Deshabilitar", "done": "Listo", @@ -344,6 +361,7 @@ "saved": "Guardado", "saving": "Guardando", "savingEllipsis": "Guardando...", + "verify": "Verificar", "yes": "Sí" }, "connect": { @@ -369,26 +387,48 @@ "resetConnection": "Restablecer conexión" }, "connectedAppDetailPage": { + "accounts": "Cuentas", + "addAccount": "{{label}} — añadir", + "allAccounts": "Todas las cuentas, incluidas las añadidas más adelante", + "allAccountsAdd": "Todas las cuentas, incluidas las añadidas más adelante — añadir", + "allAccountsPlain": "todas las cuentas, incluidas aquellas a las que se una más adelante", "authorized": "Autorizado", + "confirmExtend": "¿Conceder a {{name}} un acceso que aún no tiene?\n\nSe añadirá: {{list}}", + "delayedReach": "El acceso ya en curso en {{apis}} finaliza en un plazo de {{window}}.", "details": "Detalles", "deviceAccess": "Acceso a dispositivos", + "editHint": "Concedido cuando {{name}} inició sesión. Toque un permiso para desactivarlo — seguirá en la lista para que pueda volver a activarlo más adelante.", "grantedWhenSignedIn": "Concedido cuando {{name}} inició sesión. Para cambiarlos, revoca el acceso y haz que vuelva a iniciar sesión.", "lastActive": "Última actividad", + "lastUsed": "Último uso", "noActivityYet": "Aún sin actividad", "noDeviceAccess": "Sin acceso a dispositivos: puede confirmar tu identidad, pero no puede ver ni controlar ningún dispositivo.", "noLongerAuthorized": "Esta aplicación ya no está autorizada.", + "notGranted": "{{label}} — no concedido", + "notGrantedHint": "Esta aplicación lo solicitó y usted no lo concedió. Puede activarlo aquí.", "permissions": "Permisos", "requestAgain": "Puede solicitar acceso nuevamente al iniciar sesión.", "revoke": "Revocar", "revokeAccess": "Revocar acceso", "revokeAccessConfirmTitle": "¿Revocar el acceso?", + "revokeExplain": "Cierra la sesión de {{name}} en su cuenta y le impide obtener nuevos accesos. Podrá solicitar acceso de nuevo iniciando sesión.", + "revokeSection": "Revocar acceso", + "revoked": "revocado", + "revokedHint": "Este acceso fue revocado — se muestra como registro. {{name}} puede solicitar acceso de nuevo iniciando sesión.", + "save": "Guardar cambios", + "saveFailed": "No se pudo guardar ese cambio.", "service": "Servicio", + "signInOnly": "Solo inicio de sesión — puede confirmar su identidad, pero no se le concedió nada más.", + "signInScopes": "Ámbitos de inicio de sesión", "signOutBefore": "cerrará sesión. El nuevo acceso se bloquea de inmediato; cualquier sesión ya en curso finaliza dentro de", - "title": "Aplicación conectada" + "title": "Aplicación conectada", + "willAdd": "Esto concede a la aplicación un acceso que aún no tiene: {{list}}" }, "connectedApps": { "empty": "Aún no has autorizado ninguna aplicación.", "loading": "Cargando…", + "reauth": "Inicie sesión de nuevo para ver sus aplicaciones conectadas — su sesión actual comenzó antes de que esta página pudiera solicitarlas.", + "reauthAction": "Iniciar sesión de nuevo", "title": "Aplicaciones y agentes de IA" }, "connectedAppsPage": { @@ -978,6 +1018,32 @@ "logsPage": { "title": "Registros" }, + "mfa": { + "choose": "¿Cómo desea recibir su código? (totp o sms)", + "codesTitle": "Guarde sus códigos de recuperación — cada uno puede usarse una vez si pierde su autenticador. No volverán a mostrarse.", + "confirmPassword": "Confirme su contraseña para continuar — al cambiar una credencial se vuelve a comprobar la que ya posee.", + "disable": "Desactivar", + "enable": "Configurar", + "failed": "Algo salió mal — inténtelo de nuevo.", + "federated": "Inicia sesión con un proveedor de identidad (como Google), por lo que su contraseña y la verificación en dos pasos se gestionan allí. Para añadir una contraseña de Remote.It — utilizable junto a su proveedor — configure una primero.", + "method": "Método", + "off": "Desactivado", + "on": "Activado", + "phone": "Número de móvil (+15555550123)", + "prefer": "Marcar como preferido", + "preferred": "Activado · preferido", + "protects": "El método preferido se solicita en cada inicio de sesión de esta cuenta.", + "relay": "Introduzca el código de 6 dígitos de su segundo factor actual.", + "relayHint": "Introduzca el código enviado a {{hint}}.", + "scan": "Escanee con su aplicación de autenticación y luego introduzca su código de 6 dígitos.", + "secret": "O introduzca la clave manualmente:", + "setPassword": "Establecer una contraseña", + "smsSent": "Enviamos un código por SMS a su teléfono — introdúzcalo para terminar de activar los códigos por mensaje.", + "suggest": "Proteja su cuenta con una aplicación de autenticación o mensajes de texto.", + "title": "Verificación en dos pasos", + "wrongCode": "Ese código no coincide — inténtelo de nuevo.", + "wrongPassword": "Esa contraseña no coincide." + }, "mfaConfigureApp": { "code": "Código: {{code}}", "generateNewCode": "Generar nuevo código QR", @@ -1059,6 +1125,7 @@ "signOutConfirm": "Al cerrar sesión, este dispositivo podrá transferirse o permitir que otro usuario inicie sesión. Esto detendrá todas las conexiones.", "support": "Soporte", "supportForum": "Foro de soporte", + "switchAccount": "Cambiar de cuenta", "testUI": "UI de prueba", "testUIConfirm": "Activar las funciones alfa puede provocar inestabilidad. Está pensado únicamente para pruebas y desarrollo.", "thisDevice": "Este dispositivo", @@ -1410,6 +1477,15 @@ "noPartnersFound": "No se encontraron socios", "searchPlaceholder": "Buscar socios..." }, + "passkeys": { + "add": "Añadir una clave de acceso", + "added": "Clave de acceso añadida — en el próximo inicio de sesión, úsela en lugar de escribir un código.", + "defaultName": "Este dispositivo", + "explainer": "Una clave de acceso le permite iniciar sesión aquí con un toque en lugar de un código. Los códigos por SMS o del autenticador siguen protegiendo los inicios de sesión desde aplicaciones más antiguas.", + "failed": "Algo salió mal — inténtelo de nuevo.", + "needFactor": "Configure primero un autenticador o los códigos por SMS.", + "title": "Claves de acceso" + }, "passwordStrengthInput": { "confirmNewPassword": "Confirmar nueva contraseña", "enterNewPassword": "Introduce la nueva contraseña", @@ -1993,8 +2069,15 @@ "testPage": { "agentURL": "URL del servicio del agente", "agentURLInvalid": "La URL del servicio del agente debe empezar por https://", + "apiTarget": "Destino de API", + "backgroundWork": "Trabajo en segundo plano de la IA", + "backgroundWorkOff": "El agente solo funciona mientras usted está aquí.", + "backgroundWorkOn": "El agente puede leer y observar mientras usted no está.", + "backgroundWorkUnknown": "Comprobando…", "clearViewedAnnouncements": "Borrar anuncios vistos", "clearViewedAnnouncementsHint": "Marca todos los anuncios cargados como no leídos para esta cuenta.", + "customGraphQLURL": "URL de GraphQL", + "customWebSocketURL": "URL de WebSocket", "disableTestUI": "Deshabilitar la interfaz de prueba", "disableTestUIHint": "Para volver a habilitar la interfaz alfa, debes seleccionar el menú de avatar mientras mantienes presionado alt-shift.", "featureLabel": "{{name}} (predeterminado {{state}})", @@ -2002,7 +2085,7 @@ "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", "licensingOptions": "Opciones de licencia", - "mcpAudience": "Audiencia MCP del agente", + "mintError": "Este destino fue rechazado al emitir el token: {{error}}", "overrideDefaultAPIs": "Anular las API predeterminadas", "overrideLicensesAndLimits": "Anular licencias y límites", "reset": "Restablecer", @@ -2011,10 +2094,11 @@ "setServiceLicensesHint": "Establecerá la licencia de todos los dispositivos en este orden: UNKNOWN, EVALUATION, LICENSED, UNLICENSED, NON_COMMERCIAL, LEGACY", "showLatestAnnouncement": "Mostrar el último anuncio", "showLatestAnnouncementHint": "Muestra una vista previa del último anuncio sin cambiar su estado de lectura.", - "switchGraphQLAPIs": "Cambiar las API de GraphQL", + "stagesEmpty": "El servidor de autorización no indica destinos conmutables para este cliente.", + "stagesError": "No se pudieron cargar los destinos disponibles ({{status}}) — introduzca una URL abajo.", + "stagesLoading": "Cargando destinos disponibles…", "testOptions": "Opciones de prueba", - "title": "Configuración de prueba", - "webSocketURL": "URL de WebSocket" + "title": "Configuración de prueba" }, "timeSeriesSelect": { "graphLength": "Duración del gráfico", @@ -2064,6 +2148,7 @@ }, "viewAsBanner": { "exit": "Salir del modo de vista como", + "supportSession": "Sesión de soporte — viendo como {{email}}. Los tokens llevan su identidad; el usuario puede ver y finalizar esta sesión.", "viewingAs": "Viendo como: {{email}}" }, "graphType": { diff --git a/frontend/src/i18n/locales/es/notices.json b/frontend/src/i18n/locales/es/notices.json index c79a1067c..932384bca 100644 --- a/frontend/src/i18n/locales/es/notices.json +++ b/frontend/src/i18n/locales/es/notices.json @@ -13,7 +13,8 @@ "authRequired": "Se requiere autenticación del agente — inicie sesión para continuar.", "popupBlocked": "Ventana emergente bloqueada — permita las ventanas emergentes para este sitio e inténtelo de nuevo.", "sessionExpired": "La sesión del agente ha expirado — inicie sesión de nuevo para continuar.", - "signInFailed": "Error al iniciar sesión en el agente — {{error}}" + "signInFailed": "Error al iniciar sesión en el agente — {{error}}", + "usageReset": "{{msg}} Se restablece {{when}}." }, "connection": { "surveyFailed": "No se pudo enviar la encuesta de conexión. Ponte en contacto con soporte." diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index fc0d67261..a55d3d63a 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -173,6 +173,8 @@ }, "changePassword": { "currentPassword": "現在のパスワード", + "mfaCode": "認証コード", + "mfaPrompt": "パスワードの変更を完了するには、認証アプリに表示されている6桁のコードを入力してください。", "noticeAfter": "他のセッションからサインアウトされることはありません。", "noticeBefore": "パスワードの変更では、", "noticeEmphasis": "自動的に", @@ -184,15 +186,22 @@ "approve": "承認", "close": "閉じる", "collapse": "縮小", - "currentOrg": "現在の組織", + "deleteConversation": "削除", "deny": "拒否", "expand": "拡大", + "history": "履歴", + "historyEmpty": "過去の会話はありません", + "inputPlaceholder": "Remote.It とチャット", "interrupted": "中断されました", + "introBody": "デバイス、接続、サービスの管理を、話しかけるだけで。", + "introTitle": "Remote.It AI", "newChat": "新しいチャット", - "organization": "組織", - "personal": "個人", + "newSession": "新しいチャット", "popIn": "元に戻す", "popOut": "別ウィンドウで開く", + "prompt1": "オフラインのデバイスは?", + "prompt2": "最近の接続を表示", + "prompt3": "デバイスのサービスを再起動", "send": "送信", "signIn": "remote.it でサインイン", "signInFromMain": "メインのアプリウィンドウからサインインしてください。", @@ -201,6 +210,13 @@ "toolRequest": "エージェントが {{tool}} の実行を求めています", "toolsUsed_other": "{{count}} 個のツールを使用", "unavailable": "Mycal は一時的に利用できません。インターネット接続を確認するか、しばらくしてからもう一度お試しください。", + "untitled": "新しい会話", + "usage": "使用量", + "usageResets": "{{when}}にリセット", + "usageSession": "5時間セッション", + "usageTitle": "使用量", + "usageUnlimited": "無制限", + "usageWeekly": "今週", "waitingApproval": "承認を待っています…", "windowTitle": "remote.it チャット" }, @@ -321,6 +337,7 @@ "close": "閉じる", "confirm": "確認", "confirmingEllipsis": "確認中...", + "continue": "続ける", "delete": "削除", "disable": "無効にする", "done": "完了", @@ -336,6 +353,7 @@ "saved": "保存しました", "saving": "保存中", "savingEllipsis": "保存中...", + "verify": "確認", "yes": "はい" }, "connect": { @@ -361,26 +379,48 @@ "resetConnection": "接続をリセット" }, "connectedAppDetailPage": { + "accounts": "アカウント", + "addAccount": "{{label}} — 追加", + "allAccounts": "後から追加されるものを含む、すべてのアカウント", + "allAccountsAdd": "後から追加されるものを含む、すべてのアカウント — 追加", + "allAccountsPlain": "後から参加するものを含む、すべてのアカウント", "authorized": "承認済み", + "confirmExtend": "{{name}} にまだ付与されていないアクセス権を与えますか?\n\n追加する内容: {{list}}", + "delayedReach": "{{apis}} で進行中のアクセスは {{window}} 以内に終了します。", "details": "詳細", "deviceAccess": "デバイスアクセス", + "editHint": "{{name}} がサインインしたときに付与されました。権限をタップすると無効にできます。一覧には残るため、後で再度有効にできます。", "grantedWhenSignedIn": "{{name}} がサインインしたときに付与されました。変更するには、アクセスを取り消してから再度サインインさせてください。", "lastActive": "最終アクティブ", + "lastUsed": "最終使用", "noActivityYet": "アクティビティはまだありません", "noDeviceAccess": "デバイスアクセスなし — 本人確認は可能ですが、デバイスの表示や制御はできません。", "noLongerAuthorized": "このアプリは承認が取り消されています。", + "notGranted": "{{label}} — 未付与", + "notGrantedHint": "このアプリから要求されましたが、付与されていません。ここで有効にできます。", "permissions": "権限", "requestAgain": "サインインすることで、再度アクセスを要求できます。", "revoke": "取り消す", "revokeAccess": "アクセスを取り消す", "revokeAccessConfirmTitle": "アクセスを取り消しますか?", + "revokeExplain": "{{name}} をアカウントからサインアウトさせ、新たなアクセス権の取得をブロックします。再度サインインすればアクセスを要求できます。", + "revokeSection": "アクセスを取り消す", + "revoked": "取り消し済み", + "revokedHint": "このアクセスは取り消されました — 記録として表示しています。{{name}} は再度サインインすればアクセスを要求できます。", + "save": "変更を保存", + "saveFailed": "変更を保存できませんでした。", "service": "サービス", + "signInOnly": "サインインのみ — 本人確認はできますが、他の権限は付与されていません。", + "signInScopes": "サインインのスコープ", "signOutBefore": "はサインアウトされます。新しいアクセスは即座にブロックされ、進行中のセッションはこの時間内に終了します:", - "title": "連携アプリ" + "title": "連携アプリ", + "willAdd": "これにより、アプリにまだ付与されていないアクセス権が与えられます: {{list}}" }, "connectedApps": { "empty": "まだアプリを承認していません。", "loading": "読み込み中…", + "reauth": "接続済みアプリを表示するには、再度サインインしてください。現在のセッションは、このページが要求できるようになる前に開始されました。", + "reauthAction": "再度サインイン", "title": "アプリとAIエージェント" }, "connectedAppsPage": { @@ -952,6 +992,32 @@ "logsPage": { "title": "ログ" }, + "mfa": { + "choose": "コードの受け取り方法を選択してください(totp または sms)", + "codesTitle": "リカバリコードを保存してください。認証アプリを紛失した場合に、それぞれ1回ずつ使用できます。この画面は二度と表示されません。", + "confirmPassword": "続けるにはパスワードを確認してください。認証情報の変更には、現在の認証情報の再確認が必要です。", + "disable": "オフにする", + "enable": "設定する", + "failed": "問題が発生しました。もう一度お試しください。", + "federated": "Google などの ID プロバイダーでサインインしているため、パスワードと二要素認証はそちらで管理されています。プロバイダーと併用できる Remote.It のパスワードを追加するには、まずパスワードを設定してください。", + "method": "方法", + "off": "オフ", + "on": "オン", + "phone": "携帯電話番号 (+15555550123)", + "prefer": "優先に設定", + "preferred": "オン・優先", + "protects": "優先の方法で、このアカウントへのすべてのサインインを確認します。", + "relay": "現在の第2要素に表示されている6桁のコードを入力してください。", + "relayHint": "{{hint}} に送信されたコードを入力してください。", + "scan": "認証アプリでスキャンし、表示される6桁のコードを入力してください。", + "secret": "またはキーを手動で入力:", + "setPassword": "パスワードを設定", + "smsSent": "お使いの電話にコードを送信しました。入力してSMSコードの設定を完了してください。", + "suggest": "認証アプリまたはSMSでアカウントを保護しましょう。", + "title": "二要素認証", + "wrongCode": "コードが一致しませんでした。もう一度お試しください。", + "wrongPassword": "パスワードが一致しませんでした。" + }, "mfaConfigureApp": { "code": "コード: {{code}}", "generateNewCode": "新しいQRコードを生成", @@ -1033,6 +1099,7 @@ "signOutConfirm": "サインアウトすると、このデバイスの譲渡や他のユーザーのサインインが可能になります。すべての接続が停止します。", "support": "サポート", "supportForum": "サポートフォーラム", + "switchAccount": "アカウントを切り替え", "testUI": "テストUI", "testUIConfirm": "アルファ機能を有効にすると、動作が不安定になることがあります。テストおよび開発目的のみを想定しています。", "thisDevice": "このデバイス", @@ -1370,6 +1437,15 @@ "noPartnersFound": "パートナーが見つかりません", "searchPlaceholder": "パートナーを検索..." }, + "passkeys": { + "add": "パスキーを追加", + "added": "パスキーを追加しました。次回のサインインでは、コードの入力に代えて使用できます。", + "defaultName": "このデバイス", + "explainer": "パスキーを使うと、コードの代わりにワンタッチでサインインできます。SMSや認証アプリのコードは、古いアプリからのサインインを引き続き保護します。", + "failed": "問題が発生しました。もう一度お試しください。", + "needFactor": "先に認証アプリまたはSMSコードを設定してください。", + "title": "パスキー" + }, "passwordStrengthInput": { "confirmNewPassword": "新しいパスワードを確認", "enterNewPassword": "新しいパスワードを入力", @@ -1921,8 +1997,15 @@ "testPage": { "agentURL": "エージェントサービス URL", "agentURLInvalid": "エージェントサービス URL は https:// で始まる必要があります", + "apiTarget": "API ターゲット", + "backgroundWork": "AI のバックグラウンド処理", + "backgroundWorkOff": "エージェントは、あなたがいる間だけ動作します。", + "backgroundWorkOn": "エージェントは、離席中も読み取りと監視ができます。", + "backgroundWorkUnknown": "確認中…", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", + "customGraphQLURL": "GraphQL URL", + "customWebSocketURL": "WebSocket URL", "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", "featureLabel": "{{name}}(デフォルト: {{state}})", @@ -1930,7 +2013,7 @@ "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", "licensingOptions": "ライセンスオプション", - "mcpAudience": "エージェント MCP オーディエンス", + "mintError": "このターゲットはトークン発行時に拒否されました: {{error}}", "overrideDefaultAPIs": "デフォルトのAPIをオーバーライド", "overrideLicensesAndLimits": "ライセンスと制限をオーバーライド", "reset": "リセット", @@ -1939,10 +2022,11 @@ "setServiceLicensesHint": "すべてのデバイスのライセンスを次の順序で設定します: UNKNOWN, EVALUATION, LICENSED, UNLICENSED, NON_COMMERCIAL, LEGACY", "showLatestAnnouncement": "最新のお知らせを表示", "showLatestAnnouncementHint": "既読状態を変更せずに、最新のお知らせをプレビューします。", - "switchGraphQLAPIs": "GraphQL APIを切り替え", + "stagesEmpty": "認可サーバーは、このクライアントで切り替え可能なターゲットを提示していません。", + "stagesError": "利用可能なターゲットを読み込めませんでした ({{status}})。下に URL を入力してください。", + "stagesLoading": "利用可能なターゲットを読み込み中…", "testOptions": "テストオプション", - "title": "テスト設定", - "webSocketURL": "WebSocket URL" + "title": "テスト設定" }, "timeSeriesSelect": { "graphLength": "グラフの長さ", @@ -1990,6 +2074,7 @@ }, "viewAsBanner": { "exit": "表示モードを終了", + "supportSession": "サポートセッション — {{email}} として表示中。トークンにはあなたの ID が記録され、ユーザーはこのセッションを確認して終了できます。", "viewingAs": "表示中: {{email}}" }, "graphType": { diff --git a/frontend/src/i18n/locales/ja/notices.json b/frontend/src/i18n/locales/ja/notices.json index 16c2ca103..da2f2164c 100644 --- a/frontend/src/i18n/locales/ja/notices.json +++ b/frontend/src/i18n/locales/ja/notices.json @@ -13,7 +13,8 @@ "authRequired": "エージェントの認証が必要です — サインインして続行してください。", "popupBlocked": "ポップアウトがブロックされました — このサイトのポップアップを許可してから、もう一度お試しください。", "sessionExpired": "エージェントのセッションの期限が切れました — もう一度サインインして続行してください。", - "signInFailed": "エージェントのサインインに失敗しました — {{error}}" + "signInFailed": "エージェントのサインインに失敗しました — {{error}}", + "usageReset": "{{msg}} {{when}}にリセットされます。" }, "connection": { "surveyFailed": "接続アンケートの送信に失敗しました。サポートにお問い合わせください。" From 0851abe5dd28b0704a58ba03830be7e89b00ef11 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 10:32:57 -0700 Subject: [PATCH 137/238] ci: run i18n:check alongside typecheck --- .github/workflows/typecheck.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9f37eecd7..306d3763c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -38,3 +38,8 @@ jobs: - name: Run typecheck run: npm run typecheck + + # Catalog parity: fails on a key missing from ja/de/es, a dead key, or an + # empty English value. Runs after typecheck so a broken build reports first. + - name: Check translation catalogs + run: npm run i18n:check -w=frontend From 3e189068c1a1f7609cc6ac7686f3e8bcca9882f7 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 11:15:34 -0700 Subject: [PATCH 138/238] style(chat): light grey panel ground, with the overflow fade and popout to match --- frontend/src/components/Chat/ChatMessages.tsx | 2 +- frontend/src/components/Chat/ChatPanel.tsx | 2 +- frontend/src/components/Chat/ChatWindow.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 9c4fcdf9f..3e866a427 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -37,7 +37,7 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi panel's — otherwise it lands under the composer. */ return ( - + {messages.map((message, index) => ( ))} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index cb8f76aab..dac827ee1 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -80,7 +80,7 @@ export const ChatPanel: React.FC = () => { // indicator on mobile; the bottom menu carries its own inset paddingTop: insets?.topPx, paddingRight: insets?.rightPx, - bgcolor: 'primaryHighlight.main', + bgcolor: 'grayLightest.main', borderLeft: docked || sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', boxShadow: docked || sidebarWidth ? 3 : 0, diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index d18867ac2..84e923562 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -24,7 +24,7 @@ export const ChatWindow: React.FC = () => { flexFlow: 'column', height: '100%', width: '100%', - bgcolor: 'primaryHighlight.main', + bgcolor: 'grayLightest.main', paddingBottom: 1, }} > From e9c0140141dafca0917addf67eda174f3ec23669 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 11:20:28 -0700 Subject: [PATCH 139/238] fix(chat): show the history delete only on hover, not on the focused row --- frontend/src/components/Chat/ChatHeader.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 6a3b3d861..5d0d11e36 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -158,7 +158,11 @@ export const HistoryButton: React.FC = () => { selected={c.id === currentId} sx={{ '& .remove': { opacity: 0, transition: 'opacity 100ms' }, - '&:hover .remove, &:focus-within .remove': { opacity: 1 }, + // Hover, or the button's own KEYBOARD focus. Not :focus-within — MUI + // focuses the selected item when the menu opens, which would pin the X + // on the active row. :focus-visible keeps it reachable by tab without + // matching that programmatic focus. + '&:hover .remove, & .remove:focus-visible': { opacity: 1 }, }} onClick={() => { dispatch.chat.openConversation(c.id) From 5862d528f29a743a65427c2d9e834f5131691b1b Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 11:28:23 -0700 Subject: [PATCH 140/238] feat(chat): three-step onboarding tour for the AI agent --- frontend/src/components/Chat/ChatBody.tsx | 23 ++++++++++++ frontend/src/components/Chat/ChatHeader.tsx | 27 ++++++++++++-- frontend/src/components/Header/Header.tsx | 40 ++++++++++++++++----- frontend/src/i18n/locales/de/app.json | 6 ++++ frontend/src/i18n/locales/en/app.json | 6 ++++ frontend/src/i18n/locales/es/app.json | 6 ++++ frontend/src/i18n/locales/ja/app.json | 6 ++++ 7 files changed, 104 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 867a9e644..69eaa1cd0 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -10,6 +10,7 @@ import { ChatIntro } from './ChatIntro' import { Notice } from '../Notice' import { Body } from '../Body' import { Icon } from '../Icon' +import { GuideBubble } from '../GuideBubble' import { isChatPopout } from '../../services/chatPopout' /* Everything below the chat header — shared by the docked panel and the @@ -83,6 +84,27 @@ export const ChatBody: React.FC = () => { )} )} + {/* Step 2. Hidden in the popout — the tour belongs to the main window, and a + bubble in a second window would fire with no context around it. */} + + + {t('chat.guideComposeTitle', 'Just ask')} + + + {t( + 'chat.guideComposeBody', + 'Type a question, or say what you want changed. Anything that alters your account pauses for your approval first.' + )} + + + } + > { onSend={text => dispatch.chat.send(text)} onStop={() => dispatch.chat.stop()} /> + ) } diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 5d0d11e36..618eef29b 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -1,11 +1,13 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import { Box, Menu, MenuItem, ListItemText, ListSubheader, IconButton as MuiIconButton } from '@mui/material' +import { Box, Menu, MenuItem, ListItemText, ListSubheader, Typography, IconButton as MuiIconButton } from '@mui/material' import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' import { fontSizes, spacing } from '../../styling' +import { GuideBubble } from '../GuideBubble' +import { isChatPopout } from '../../services/chatPopout' /* Control row shared by the docked panel and the popout window — `leading` takes the panel-chrome control (expand/collapse) at the far left, then the conversation's name, @@ -18,11 +20,32 @@ export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React. leading, children, }) => { + const { t } = useTranslation() return ( {leading} - + {/* Step 3. The wrapper sx keeps the shrink chain intact — without minWidth: 0 the + inserted div would refuse to shrink and the name would stop truncating. */} + + + {t('chat.guideHistoryTitle', 'Your conversations')} + + + {t('chat.guideHistoryBody', 'Chats are saved. Switch between them, or start a new one, from here.')} + + + } + > + + {children} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 8aab1683b..5fc53a29a 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,5 +1,6 @@ import { REGEX_FIRST_PATH, MOBILE_WIDTH } from '../../constants' import { useChatEnabled, useHideSidebar } from '../../hooks/useChatEnabled' +import { GuideBubble } from '../GuideBubble' import React, { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import useNavigationUp from '../../hooks/useNavigationUp' @@ -98,15 +99,38 @@ export const Header: React.FC = ({ panels = 1 }) => { color="grayDarker" /> )} + {/* Step 1 of the chat tour. Deliberately no startDate — this is new to everyone, + including long-standing accounts, so the usual "only for recent signups" cohort + gate would hide it from the people who most need it. The delay lets the app + settle before it speaks up. */} {chatEnabled && !chatPoppedOut && ( - dispatch.chat.set({ open: !chatOpen })} - /> + + + {t('chat.guideAgentTitle', 'Meet Remote.It AI')} + + + {t( + 'chat.guideAgentBody', + 'Ask about your devices, connections and services — or tell it to make changes. Open and close it here any time.' + )} + + + } + > + dispatch.chat.set({ open: !chatOpen })} + /> + )} {!showSearch && } {sidebarHidden && ( diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 388d60da2..5e7766d5a 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -192,6 +192,12 @@ "deleteConversation": "Löschen", "deny": "Ablehnen", "expand": "Vergrößern", + "guideAgentBody": "Fragen Sie nach Ihren Geräten, Verbindungen und Diensten — oder lassen Sie Änderungen vornehmen. Hier können Sie den Assistenten jederzeit öffnen und schließen.", + "guideAgentTitle": "Das ist Remote.It AI", + "guideComposeBody": "Stellen Sie eine Frage oder sagen Sie, was geändert werden soll. Alles, was Ihr Konto verändert, wartet zuerst auf Ihre Zustimmung.", + "guideComposeTitle": "Fragen Sie einfach", + "guideHistoryBody": "Chats werden gespeichert. Wechseln Sie hier zwischen ihnen oder beginnen Sie eine neue.", + "guideHistoryTitle": "Ihre Unterhaltungen", "history": "Verlauf", "historyEmpty": "Keine früheren Unterhaltungen", "inputPlaceholder": "Mit Remote.It chatten", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index dd652fd66..1669e2879 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -191,6 +191,12 @@ "collapse": "Collapse", "deny": "Deny", "expand": "Expand", + "guideAgentBody": "Ask about your devices, connections and services — or tell it to make changes. Open and close it here any time.", + "guideAgentTitle": "Meet Remote.It AI", + "guideComposeBody": "Type a question, or say what you want changed. Anything that alters your account pauses for your approval first.", + "guideComposeTitle": "Just ask", + "guideHistoryBody": "Chats are saved. Switch between them, or start a new one, from here.", + "guideHistoryTitle": "Your conversations", "interrupted": "Interrupted", "newChat": "New Chat", "history": "History", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index d48a9b81b..f2dd6408f 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -195,6 +195,12 @@ "deleteConversation": "Eliminar", "deny": "Denegar", "expand": "Expandir", + "guideAgentBody": "Pregunte por sus dispositivos, conexiones y servicios — o pídale que haga cambios. Puede abrirlo y cerrarlo aquí en cualquier momento.", + "guideAgentTitle": "Le presentamos Remote.It AI", + "guideComposeBody": "Escriba una pregunta o indique qué quiere cambiar. Todo lo que modifique su cuenta espera primero su aprobación.", + "guideComposeTitle": "Solo pregunte", + "guideHistoryBody": "Los chats se guardan. Cambie entre ellos, o inicie uno nuevo, desde aquí.", + "guideHistoryTitle": "Sus conversaciones", "history": "Historial", "historyEmpty": "No hay conversaciones anteriores", "inputPlaceholder": "Chatea con Remote.It", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index a55d3d63a..81b5b46e2 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -189,6 +189,12 @@ "deleteConversation": "削除", "deny": "拒否", "expand": "拡大", + "guideAgentBody": "デバイス、接続、サービスについて質問したり、変更を指示したりできます。ここからいつでも開閉できます。", + "guideAgentTitle": "Remote.It AI のご紹介", + "guideComposeBody": "質問を入力するか、変更したい内容を伝えてください。アカウントに変更を加える操作は、必ず承認を求めてから実行されます。", + "guideComposeTitle": "話しかけるだけ", + "guideHistoryBody": "チャットは保存されます。ここから会話を切り替えたり、新しく開始したりできます。", + "guideHistoryTitle": "会話の履歴", "history": "履歴", "historyEmpty": "過去の会話はありません", "inputPlaceholder": "Remote.It とチャット", From fc78c4838e586820237326e1d6dadd419a6aedd6 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 11:53:33 -0700 Subject: [PATCH 141/238] fix(guides): date the dismiss-all so later guides still appear --- frontend/src/components/Chat/ChatBody.tsx | 2 ++ frontend/src/components/Chat/ChatHeader.tsx | 2 ++ frontend/src/components/GuideBubble.tsx | 25 ++++++++++++++++++--- frontend/src/components/Header/Header.tsx | 3 ++- frontend/src/constants.ts | 4 ++++ frontend/src/models/ui.ts | 10 +++++++-- 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 69eaa1cd0..c6ff49ed5 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -12,6 +12,7 @@ import { Body } from '../Body' import { Icon } from '../Icon' import { GuideBubble } from '../GuideBubble' import { isChatPopout } from '../../services/chatPopout' +import { CHAT_GUIDE_DATE } from '../../constants' /* Everything below the chat header — shared by the docked panel and the popout window */ @@ -88,6 +89,7 @@ export const ChatBody: React.FC = () => { bubble in a second window would fire with no context around it. */} + value === true ? DISMISSAL_DATED_FROM : typeof value === 'number' ? value : undefined + type Props = { guide: string placement?: TooltipProps['placement'] instructions: React.ReactNode component?: BoxProps['component'] - startDate?: Date // Show to users created before this date + startDate?: Date // Cohort gate: hidden from users who signed up before this date + /** When this bubble shipped. A "dismiss all" older than this does not hide it. + * Defaults to startDate, so existing bubbles keep their current behaviour. */ + added?: Date highlight?: boolean hideArrow?: boolean hide?: boolean @@ -27,6 +42,7 @@ export const GuideBubble: React.FC = ({ placement, instructions, startDate = new Date(0), + added, component = 'div', highlight, hideArrow, @@ -38,9 +54,12 @@ export const GuideBubble: React.FC = ({ children, }) => { const { ui } = useDispatch() - const expired = useSelector( - (state: State) => (startDate > state.user.created && !state.ui.testUI) || state.ui.expireBubbles + const cohortExpired = useSelector( + (state: State) => startDate > state.user.created && !state.ui.testUI ) + const dismissed = useSelector((state: State) => dismissedAt(state.ui.expireBubbles)) + // Dismissed only counts against bubbles that already existed when it happened + const expired = cohortExpired || (dismissed !== undefined && (added ?? startDate).getTime() <= dismissed) const poppedBubbles = useSelector((state: State) => state.ui.poppedBubbles) const sidebarOpen = useSelector((state: State) => state.ui.sidebarMenu) const [waiting, setWaiting] = React.useState(true) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 5fc53a29a..43158e244 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,4 +1,4 @@ -import { REGEX_FIRST_PATH, MOBILE_WIDTH } from '../../constants' +import { REGEX_FIRST_PATH, MOBILE_WIDTH, CHAT_GUIDE_DATE } from '../../constants' import { useChatEnabled, useHideSidebar } from '../../hooks/useChatEnabled' import { GuideBubble } from '../GuideBubble' import React, { useState, useRef } from 'react' @@ -106,6 +106,7 @@ export const Header: React.FC = ({ panels = 1 }) => { {chatEnabled && !chatPoppedOut && ( guides: ILookup poppedBubbles: string[] - expireBubbles: boolean + /** WHEN the user last chose "dismiss all", as a timestamp — not whether they did. + * A bubble introduced after that moment still gets shown. `true` is the legacy + * value from when this was a permanent flag; GuideBubble reads it as a dismissal + * dated to the release that changed this. */ + expireBubbles: number | boolean confirm?: { id: string; callback: () => void } accordion: ILookup autoConnect: boolean @@ -300,7 +304,9 @@ export default createModel()({ dispatch.ui.setPersistent({ poppedBubbles }) }, async popAll(_: void) { - dispatch.ui.setPersistent({ expireBubbles: true }) + // Dated, so this only opts out of the guides that exist TODAY — a single + // click should not silently cancel every future feature's onboarding. + dispatch.ui.setPersistent({ expireBubbles: Date.now() }) }, async resetHelp(_: void) { dispatch.ui.setPersistent({ From e16070587d99dde7d5d99fb9b8259fd7ab237e1a Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 13:03:28 -0700 Subject: [PATCH 142/238] feat(chat): confirm before deleting a conversation; name stages by environment domain --- frontend/src/components/Chat/ChatHeader.tsx | 31 ++++++++++++++++++++- frontend/src/i18n/locales/de/app.json | 2 ++ frontend/src/i18n/locales/en/app.json | 2 ++ frontend/src/i18n/locales/es/app.json | 2 ++ frontend/src/i18n/locales/ja/app.json | 2 ++ frontend/src/pages/TestPage.tsx | 6 +++- 6 files changed, 43 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 7746f763b..c73766bfd 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -7,7 +7,10 @@ import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' import { fontSizes, spacing } from '../../styling' import { GuideBubble } from '../GuideBubble' +import { Confirm } from '../Confirm' +import { Notice } from '../Notice' import { isChatPopout } from '../../services/chatPopout' +import { ConversationSummary } from '../../services/agent' import { CHAT_GUIDE_DATE } from '../../constants' /* Control row shared by the docked panel and the popout window — `leading` takes the @@ -84,6 +87,11 @@ export const HistoryButton: React.FC = () => { ? [{ id: currentId, title: currentTitle, createdAt: '', updatedAt: '' }, ...conversations] : conversations const [anchorEl, setAnchorEl] = React.useState(null) + /* The row to delete, held while the dialog is up. The Confirm lives OUTSIDE the + Menu: a dialog rendered inside it would unmount the moment the menu closed, and + its backdrop reads as an outside click to the menu. So the X closes the menu and + hands the conversation over here. */ + const [deleting, setDeleting] = React.useState(null) const open = (e: React.MouseEvent) => { dispatch.chat.loadConversations() // freshen on open @@ -198,6 +206,8 @@ export const HistoryButton: React.FC = () => { primary={c.title || t('chat.untitled', 'New conversation')} primaryTypographyProps={{ noWrap: true, sx: { maxWidth: 240 } }} /> + {/* stopPropagation so the row's own onClick does not load the very + conversation we are about to delete. */} { title={t('chat.deleteConversation', 'Delete')} onClick={e => { e.stopPropagation() - dispatch.chat.removeConversation(c.id) + setDeleting(c) + close() }} > @@ -214,6 +225,24 @@ export const HistoryButton: React.FC = () => { ))} + {/* The server cascades this — messages, turns and the journal all go with it + (D9) — so it asks first. */} + { + if (deleting) dispatch.chat.removeConversation(deleting.id) + setDeleting(null) + }} + onDeny={() => setDeleting(null)} + > + + {t('common.cannotBeUndone', 'This action cannot be undone.')} + + {deleting?.title || t('chat.untitled', 'New conversation')} + ) } diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 5e7766d5a..fb78d9584 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -189,6 +189,8 @@ "approve": "Genehmigen", "close": "Schließen", "collapse": "Verkleinern", + "deleteConfirmAction": "Löschen", + "deleteConfirmTitle": "Diese Unterhaltung löschen?", "deleteConversation": "Löschen", "deny": "Ablehnen", "expand": "Vergrößern", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 1669e2879..47451204f 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -189,6 +189,8 @@ "approve": "Approve", "close": "Close", "collapse": "Collapse", + "deleteConfirmAction": "Delete", + "deleteConfirmTitle": "Delete this conversation?", "deny": "Deny", "expand": "Expand", "guideAgentBody": "Ask about your devices, connections and services — or tell it to make changes. Open and close it here any time.", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index f2dd6408f..38f677667 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -192,6 +192,8 @@ "approve": "Aprobar", "close": "Cerrar", "collapse": "Contraer", + "deleteConfirmAction": "Eliminar", + "deleteConfirmTitle": "¿Eliminar esta conversación?", "deleteConversation": "Eliminar", "deny": "Denegar", "expand": "Expandir", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 81b5b46e2..cca12ac58 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -186,6 +186,8 @@ "approve": "承認", "close": "閉じる", "collapse": "縮小", + "deleteConfirmAction": "削除", + "deleteConfirmTitle": "この会話を削除しますか?", "deleteConversation": "削除", "deny": "拒否", "expand": "拡大", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 112b197bd..c33497fe2 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -110,6 +110,10 @@ export const TestPage: React.FC = () => { // stage switches GraphQL, WebSocket and the agent together — so the radio leads with // the ENVIRONMENT and keeps the registry name underneath as the detail. const stageLabel = (stage: string) => stage.charAt(0).toUpperCase() + stage.slice(1) + // The registry name describes the GraphQL resource alone ("remote.it GraphQL (dev)"), + // which misreads as "this only changes GraphQL". Name the environment's domain instead + // — that is what every one of the three APIs actually moves to. + const stageDomain = (stage: string) => (stage === 'prod' ? 'remote.it' : `${stage}.remote.it`) type StagePair = { stage: string; name: string; graphql?: string; ws?: string } const stagePairs: StagePair[] = useMemo(() => { @@ -251,7 +255,7 @@ export const TestPage: React.FC = () => { Date: Thu, 27 Aug 2026 14:59:47 -0700 Subject: [PATCH 143/238] feat(chat): getting-started prompts when the scoped org has no devices --- frontend/src/components/Chat/ChatIntro.tsx | 36 +++++++++++++++++----- frontend/src/i18n/locales/de/app.json | 3 ++ frontend/src/i18n/locales/en/app.json | 3 ++ frontend/src/i18n/locales/es/app.json | 3 ++ frontend/src/i18n/locales/ja/app.json | 3 ++ 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index 1dfb046ef..251a89200 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -1,8 +1,10 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Box, Chip, Typography } from '@mui/material' -import { Dispatch } from '../../store' +import { Dispatch, State } from '../../store' +import { resolveChatOrg } from '../../models/chat' +import { getDeviceModelFn } from '../../selectors/devices' import { Icon } from '../Icon' /* Empty-state introduction: shown before the first message so the panel reads as a chat, @@ -13,11 +15,31 @@ export const ChatIntro: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() - const prompts = [ - t('chat.prompt1', 'Which of my devices are offline?'), - t('chat.prompt2', 'Show my recent connections'), - t('chat.prompt3', 'Restart a service on one of my devices'), - ] + /* An account with nothing in it makes every stock prompt a dead end — "which of my + devices are offline?" answers "none", which is a poor first impression of a feature + someone is trying for the first time. Swap in prompts the agent can answer from + knowledge rather than from data they do not have yet. + + Scoped to the org the CHAT is pointed at, not the active account: those differ, and + an account whose own device list is empty may still see plenty through a membership. + Gated on `initialized` so an unloaded list never masquerades as an empty one. */ + const chatOrg = useSelector(resolveChatOrg, (a, b) => a?.id === b?.id) + const userId = useSelector((state: State) => state.user.id) + const accountId = chatOrg?.id || userId + const deviceModel = useSelector((state: State) => getDeviceModelFn(state.devices, accountId, accountId)) + const gettingStarted = deviceModel.initialized && !deviceModel.total + + const prompts = gettingStarted + ? [ + t('chat.newPrompt1', 'How do I add my first device?'), + t('chat.newPrompt2', 'Try it out with the demo device'), + t('chat.newPrompt3', 'How do I reach a Raspberry Pi without port forwarding?'), + ] + : [ + t('chat.prompt1', 'Which of my devices are offline?'), + t('chat.prompt2', 'Show my recent connections'), + t('chat.prompt3', 'Restart a service on one of my devices'), + ] return ( diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index fb78d9584..e2a10150f 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -207,6 +207,9 @@ "introBody": "Verwalten Sie Ihre Geräte, Verbindungen und Dienste — fragen Sie einfach.", "introTitle": "Remote.It AI", "newChat": "Neuer Chat", + "newPrompt1": "Wie füge ich mein erstes Gerät hinzu?", + "newPrompt2": "Mit dem Demo-Gerät ausprobieren", + "newPrompt3": "Wie erreiche ich einen Raspberry Pi ohne Portfreigabe?", "newSession": "Neuer Chat", "popIn": "Wieder andocken", "popOut": "In eigenem Fenster öffnen", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 47451204f..92f432fb3 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -202,6 +202,9 @@ "interrupted": "Interrupted", "newChat": "New Chat", "history": "History", + "newPrompt1": "How do I add my first device?", + "newPrompt2": "Try it out with the demo device", + "newPrompt3": "How do I reach a Raspberry Pi without port forwarding?", "usage": "Usage", "usageTitle": "Usage", "usageSession": "5-hour session", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 38f677667..cfa6b5163 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -210,6 +210,9 @@ "introBody": "Gestione sus dispositivos, conexiones y servicios — solo pregunte.", "introTitle": "Remote.It AI", "newChat": "Nuevo chat", + "newPrompt1": "¿Cómo añado mi primer dispositivo?", + "newPrompt2": "Probarlo con el dispositivo de demostración", + "newPrompt3": "¿Cómo accedo a una Raspberry Pi sin abrir puertos?", "newSession": "Nuevo chat", "popIn": "Volver a acoplar", "popOut": "Abrir en ventana propia", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index cca12ac58..1cf9bfc2a 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -204,6 +204,9 @@ "introBody": "デバイス、接続、サービスの管理を、話しかけるだけで。", "introTitle": "Remote.It AI", "newChat": "新しいチャット", + "newPrompt1": "最初のデバイスを追加するには?", + "newPrompt2": "デモデバイスで試してみる", + "newPrompt3": "ポート開放なしで Raspberry Pi に接続するには?", "newSession": "新しいチャット", "popIn": "元に戻す", "popOut": "別ウィンドウで開く", From e7738bfca2af6f35101e010ec03f9fa072e4dfb5 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 15:23:52 -0700 Subject: [PATCH 144/238] fix(devices): stuck spinner on an empty list after switching accounts --- frontend/src/pages/DevicesPage.tsx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index ec007858b..bb4afa1b1 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -3,6 +3,7 @@ import { State } from '../store' import { useHistory } from 'react-router-dom' import { useSelector } from 'react-redux' import { selectDeviceListAttributes, selectDeviceModelAttributes, selectVisibleDevices } from '../selectors/devices' +import { selectActiveAccountId } from '../selectors/accounts' import { getConnectionsLookup } from '../selectors/connections' import { selectPermissions } from '../selectors/organizations' import { restoreAttributes } from '../components/Attributes' @@ -18,24 +19,31 @@ type Props = { restore?: boolean; select?: boolean } export const DevicesPage: React.FC = ({ restore, select }) => { const history = useHistory() - const [initLoad, setInitLoad] = useState(false) + /* WHICH account's list we watched load, not merely THAT one did. As a bare boolean + this armed on the first load and never disarmed, so it outlived the account it was + set for: switching back to an already-loaded empty account left `shouldRedirect` + true, and since it also draws the spinner, a finished empty list spun forever. */ + const [loadingAccount, setLoadingAccount] = useState() const { attributes, required } = useSelector(selectDeviceListAttributes) const { fetching: deviceFetching, initialized, applicationTypes } = useSelector(selectDeviceModelAttributes) const devices = useSelector(selectVisibleDevices) + const accountId = useSelector(selectActiveAccountId) const permissions = useSelector(selectPermissions) const connections = useSelector(getConnectionsLookup) const columnWidths = useSelector((state: State) => state.ui.columnWidths) const selected = useSelector((state: State) => state.ui.selected) const fetching = useSelector((state: State) => state.ui.fetching) || deviceFetching - const shouldRedirect = initLoad && permissions.includes('MANAGE') + const shouldRedirect = loadingAccount === accountId && permissions.includes('MANAGE') + /* An empty list means "add your first device" only once it has actually loaded — so + arm on the way down and redirect on the way up, never both in one pass. Keyed to + the account and re-run when the list empties, so every switch re-decides instead + of inheriting the last account's answer. */ useEffect(() => { - if (!initialized) setInitLoad(true) - if (shouldRedirect && !devices.length) { - history.push('/add') - } - }, [initialized, history]) + if (!initialized) setLoadingAccount(accountId) + else if (shouldRedirect && !devices.length) history.push('/add') + }, [accountId, initialized, shouldRedirect, devices.length, history]) return ( From d9bcb9ad1a4c4c7aef421d18c734101f81252208 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 15:50:42 -0700 Subject: [PATCH 145/238] style(chat): clone icons for pop out and pop in --- frontend/src/assets/CloneDashed.tsx | 30 +++++++++++++++++++++ frontend/src/components/Chat/ChatPanel.tsx | 2 +- frontend/src/components/Chat/ChatWindow.tsx | 2 +- frontend/src/components/Icon.tsx | 2 ++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 frontend/src/assets/CloneDashed.tsx diff --git a/frontend/src/assets/CloneDashed.tsx b/frontend/src/assets/CloneDashed.tsx new file mode 100644 index 000000000..682306922 --- /dev/null +++ b/frontend/src/assets/CloneDashed.tsx @@ -0,0 +1,30 @@ +import React from 'react' + +/* FontAwesome's `clone` with its BACK square dashed — the pop-out icon's counterpart, + so the pair reads as one gesture and its reverse rather than two unrelated glyphs. + FA has no dashed clone, but the two halves are separable: the front square is FA's + own regular-weight path verbatim (already notched where the back square crosses it), + and the back square is redrawn as a stroked centerline rect at the same weight — + 48 units of 512, the thickness FA's regular style uses. + + `pathLength` declares the perimeter as a round 1200 so the dash pattern divides it + into exactly 16 even dash-and-gap segments; measuring the real rounded-rect perimeter would leave + a ragged partial dash at the closing corner. */ +export const CloneDashed = props => { + return ( + + + + + ) +} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index dac827ee1..38e47bced 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -104,7 +104,7 @@ export const ChatPanel: React.FC = () => { > {!browser.isMobile && !layout.mobile && ( dispatch.chat.popOut()} /> diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 84e923562..3bd8528c7 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -31,7 +31,7 @@ export const ChatWindow: React.FC = () => { dispatch.chat.popIn()} /> diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 480b64f37..44bff9e5c 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -10,6 +10,7 @@ import { far } from '@fortawesome/pro-regular-svg-icons' import { fas } from '@fortawesome/pro-solid-svg-icons' import { R3gray } from '../assets/R3gray' import { RemoteAI } from '../assets/RemoteAI' +import { CloneDashed } from '../assets/CloneDashed' library.add(fal, fab, far, fas) @@ -107,6 +108,7 @@ export const Icon = React.forwardRef( // Handle special icon cases if (name === 'r3') return if (name === 'remote-ai') return + if (name === 'clone-dashed') return let fontType: IconPrefix = 'far' From 41293eba6f5136670a864a762fba4af6429d7a19 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 16:24:01 -0700 Subject: [PATCH 146/238] style(chat): use object-subtract flipped for pop in --- frontend/src/assets/CloneDashed.tsx | 30 --------------------- frontend/src/components/Chat/ChatWindow.tsx | 3 ++- frontend/src/components/Icon.tsx | 6 ++--- 3 files changed, 5 insertions(+), 34 deletions(-) delete mode 100644 frontend/src/assets/CloneDashed.tsx diff --git a/frontend/src/assets/CloneDashed.tsx b/frontend/src/assets/CloneDashed.tsx deleted file mode 100644 index 682306922..000000000 --- a/frontend/src/assets/CloneDashed.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' - -/* FontAwesome's `clone` with its BACK square dashed — the pop-out icon's counterpart, - so the pair reads as one gesture and its reverse rather than two unrelated glyphs. - FA has no dashed clone, but the two halves are separable: the front square is FA's - own regular-weight path verbatim (already notched where the back square crosses it), - and the back square is redrawn as a stroked centerline rect at the same weight — - 48 units of 512, the thickness FA's regular style uses. - - `pathLength` declares the perimeter as a round 1200 so the dash pattern divides it - into exactly 16 even dash-and-gap segments; measuring the real rounded-rect perimeter would leave - a ragged partial dash at the closing corner. */ -export const CloneDashed = props => { - return ( - - - - - ) -} diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 3bd8528c7..1c59a3b0e 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -31,7 +31,8 @@ export const ChatWindow: React.FC = () => { dispatch.chat.popIn()} /> diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 44bff9e5c..33bb408c1 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -3,14 +3,13 @@ import { useTheme, Badge } from '@mui/material' import { PlatformIcon } from './PlatformIcon' import { fontSizes, spacing, Sizes } from '../styling' import { FontAwesomeIcon, FontAwesomeIconProps } from '@fortawesome/react-fontawesome' -import { library, IconName, IconPrefix } from '@fortawesome/fontawesome-svg-core' +import { library, IconName, IconPrefix, FlipProp } from '@fortawesome/fontawesome-svg-core' import { fab } from '@fortawesome/free-brands-svg-icons' import { fal } from '@fortawesome/pro-light-svg-icons' import { far } from '@fortawesome/pro-regular-svg-icons' import { fas } from '@fortawesome/pro-solid-svg-icons' import { R3gray } from '../assets/R3gray' import { RemoteAI } from '../assets/RemoteAI' -import { CloneDashed } from '../assets/CloneDashed' library.add(fal, fab, far, fas) @@ -25,6 +24,8 @@ export interface IconProps { fontSize?: number onClick?: (event: React.MouseEvent) => void size?: Sizes + /** Mirror the glyph. Passed straight through to FontAwesome. */ + flip?: FlipProp styles?: React.CSSProperties rotate?: number spin?: boolean @@ -108,7 +109,6 @@ export const Icon = React.forwardRef( // Handle special icon cases if (name === 'r3') return if (name === 'remote-ai') return - if (name === 'clone-dashed') return let fontType: IconPrefix = 'far' From edb387f36181fee89272c3e3b93f45d9768500cd Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 16:28:37 -0700 Subject: [PATCH 147/238] style(chat): invert table fill and grid --- .../src/components/Chat/ChatMessageItem.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 5ddda0068..aeb9b4e5f 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -38,15 +38,19 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ ({ - '& pre, & table': scrollbarStyles(theme, { background: 'grayLighter', width: SCROLLBAR_WIDTH_NARROW }), + // Each surface names the color BEHIND its scrollbar, which is what makes + // the track invisible until hover: `pre` is a gray block, while a table's + // strip sits on the card itself. + '& pre': scrollbarStyles(theme, { background: 'grayLight', width: SCROLLBAR_WIDTH_NARROW }), + '& table': scrollbarStyles(theme, { background: 'white', width: SCROLLBAR_WIDTH_NARROW }), }), { bgcolor: 'white.main', borderRadius: `${radius.lg}px`, - paddingX: 2, - paddingY: 1.5, + paddingX: 3, + paddingY: 2, fontSize: fontSizes.base, - lineHeight: 1.5, + lineHeight: 1.7, wordBreak: 'break-word', // Theme tokens, and the app's own emphasis convention (see theme.ts // body1/caption): bold is a COLOR step plus weight 500 — never 700. @@ -66,14 +70,14 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ '& code': { fontFamily: "'Roboto Mono', monospace", fontSize: fontSizes.sm, - bgcolor: 'grayLighter.main', + bgcolor: 'grayLight.main', borderRadius: `${radius.sm}px`, paddingX: 0.5, paddingY: 0.25, }, '& pre': { overflowX: 'auto', - bgcolor: 'grayLighter.main', + bgcolor: 'grayLight.main', borderRadius: `${radius.lg}px`, padding: 1.5, '& code': { padding: 0, bgcolor: 'transparent' }, @@ -85,9 +89,12 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ fontSize: fontSizes.sm, marginY: 1, }, + // Inverted against the card: the cells carry the fill and the grid is drawn + // in the card's own white, so the rules read as gaps rather than lines. '& th, & td': { + bgcolor: 'grayLightest.main', border: '1px solid', - borderColor: 'grayLighter.main', + borderColor: 'white.main', paddingX: 1, paddingY: 0.5, textAlign: 'left', From 8cea876d083b6cbe566e92465859ea400295416b Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 16:41:48 -0700 Subject: [PATCH 148/238] style(chat): message spacing, width caps and tinted code blocks --- .../src/components/Chat/ChatMessageItem.tsx | 33 ++++++++++++++----- frontend/src/components/Chat/ChatMessages.tsx | 8 ++++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index aeb9b4e5f..ff023a88a 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -22,8 +22,17 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ const { t } = useTranslation() if (message.role === 'user') return ( - - + + {message.text} @@ -39,9 +48,10 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ sx={[ theme => ({ // Each surface names the color BEHIND its scrollbar, which is what makes - // the track invisible until hover: `pre` is a gray block, while a table's - // strip sits on the card itself. - '& pre': scrollbarStyles(theme, { background: 'grayLight', width: SCROLLBAR_WIDTH_NARROW }), + // the track invisible until hover: `pre` is a tinted block, while a table's + // strip sits on the card itself. Keep these in step with the `& pre` / + // `& th, & td` backgrounds below — a stale color shows as a stray bar. + '& pre': scrollbarStyles(theme, { background: 'primaryLighter', width: SCROLLBAR_WIDTH_NARROW }), '& table': scrollbarStyles(theme, { background: 'white', width: SCROLLBAR_WIDTH_NARROW }), }), { @@ -51,6 +61,7 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ paddingY: 2, fontSize: fontSizes.base, lineHeight: 1.7, + maxWidth: 800, wordBreak: 'break-word', // Theme tokens, and the app's own emphasis convention (see theme.ts // body1/caption): bold is a COLOR step plus weight 500 — never 700. @@ -70,14 +81,14 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ '& code': { fontFamily: "'Roboto Mono', monospace", fontSize: fontSizes.sm, - bgcolor: 'grayLight.main', + bgcolor: 'grayLighter.main', borderRadius: `${radius.sm}px`, paddingX: 0.5, paddingY: 0.25, }, '& pre': { overflowX: 'auto', - bgcolor: 'grayLight.main', + bgcolor: 'grayLighter.main', borderRadius: `${radius.lg}px`, padding: 1.5, '& code': { padding: 0, bgcolor: 'transparent' }, @@ -87,7 +98,8 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ overflowX: 'auto', borderCollapse: 'collapse', fontSize: fontSizes.sm, - marginY: 1, + marginY: 2, + borderRadius: `${radius.sm}px`, }, // Inverted against the card: the cells carry the fill and the grid is drawn // in the card's own white, so the rules read as gaps rather than lines. @@ -95,10 +107,13 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ bgcolor: 'grayLightest.main', border: '1px solid', borderColor: 'white.main', - paddingX: 1, + paddingX: 1.5, paddingY: 0.5, textAlign: 'left', whiteSpace: 'nowrap', + '& code': { + bgcolor: 'white.main', + }, }, '& blockquote': { borderLeft: '3px solid', diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 3e866a427..34f1dec94 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -37,7 +37,13 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi panel's — otherwise it lands under the composer. */ return ( - + {messages.map((message, index) => ( ))} From bb780afde58cb49c17cbc156fd0d32e46bb9cb53 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 16:44:12 -0700 Subject: [PATCH 149/238] fix(chat): match code block scrollbar to its background --- frontend/src/components/Chat/ChatMessageItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index ff023a88a..92f562f4f 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -48,10 +48,10 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ sx={[ theme => ({ // Each surface names the color BEHIND its scrollbar, which is what makes - // the track invisible until hover: `pre` is a tinted block, while a table's + // the track invisible until hover: `pre` is a gray block, while a table's // strip sits on the card itself. Keep these in step with the `& pre` / // `& th, & td` backgrounds below — a stale color shows as a stray bar. - '& pre': scrollbarStyles(theme, { background: 'primaryLighter', width: SCROLLBAR_WIDTH_NARROW }), + '& pre': scrollbarStyles(theme, { background: 'grayLighter', width: SCROLLBAR_WIDTH_NARROW }), '& table': scrollbarStyles(theme, { background: 'white', width: SCROLLBAR_WIDTH_NARROW }), }), { From 9f9ff7ccad9ceba7b1a45fd7045ab7ecc54f68a5 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 16:52:04 -0700 Subject: [PATCH 150/238] feat(chat): sign finished answers with the AI mark and pulse its signal while thinking --- frontend/src/assets/RemoteAI.tsx | 25 ++++++- .../src/components/Chat/ChatMessageItem.tsx | 17 ++++- frontend/src/components/Chat/ChatMessages.tsx | 8 ++- frontend/src/components/Chat/ChatTyping.tsx | 67 +++++++++---------- 4 files changed, 78 insertions(+), 39 deletions(-) diff --git a/frontend/src/assets/RemoteAI.tsx b/frontend/src/assets/RemoteAI.tsx index 759d10c6f..bea3c00b0 100644 --- a/frontend/src/assets/RemoteAI.tsx +++ b/frontend/src/assets/RemoteAI.tsx @@ -2,7 +2,12 @@ import React from 'react' /* The Remote.It AI mark — a bot head under signal arcs. Stroked in currentColor so it takes the Icon component's color and size like any - other icon (see Icon.tsx's `remote-ai` case). */ + other icon (see Icon.tsx's `remote-ai` case). + + The two arcs are separate paths, and the antenna dot is separate again, so a + caller can animate the signal without touching the head: target `.signal-inner` + and `.signal-outer` from a parent's sx (see ChatTyping). Kept as one visual + group here — nothing about the resting mark depends on the split. */ export const RemoteAI = props => { return ( @@ -14,7 +19,23 @@ export const RemoteAI = props => { strokeLinejoin="round" /> + + , } +type Props = { + message: ChatTranscriptMessage + /** Show the sign-off mark. Withheld by the caller while the message is still streaming. */ + signed?: boolean +} + // Memoized: immer keeps unchanged message refs stable, so during streaming // only the tail message re-renders instead of re-parsing every message's // markdown on each delta -export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ message }) => { +export const ChatMessageItem = React.memo(({ message, signed }) => { const { t } = useTranslation() if (message.role === 'user') return ( @@ -135,6 +142,14 @@ export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ {t('chat.interrupted', 'Interrupted')} )} + {/* Signs off a finished answer — the caller withholds it from a message that is + still streaming, so the mark landing is what tells you the turn is done. + Indented to the card's text, not its edge, so it hangs under the writing. */} + {signed && ( + + + + )} ) }) diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 34f1dec94..c3a011115 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -45,7 +45,13 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi sx={{ paddingX: 2.5, paddingY: 1 }} > {messages.map((message, index) => ( - + ))} {typing && } {children} diff --git a/frontend/src/components/Chat/ChatTyping.tsx b/frontend/src/components/Chat/ChatTyping.tsx index c475264d2..c57008565 100644 --- a/frontend/src/components/Chat/ChatTyping.tsx +++ b/frontend/src/components/Chat/ChatTyping.tsx @@ -1,41 +1,38 @@ import React from 'react' import { Box } from '@mui/material' -import { radius } from '../../styling' +import { Icon } from '../Icon' -/* "The agent is working" — three pulsing dots in an assistant-aligned bubble, shown while a - turn is in flight but nothing else is visibly moving (before the first token, and between - a tool finishing and the next output). Running tool calls show their own spinner, and - streaming text is its own motion, so this never doubles up with either. */ +/* "The agent is working" — the same mark that signs a finished answer, with its signal + arcs broadcasting. Shown while a turn is in flight but nothing else is visibly moving + (before the first token, and between a tool finishing and the next output). Running + tool calls show their own spinner, and streaming text is its own motion, so this never + doubles up with either. + + It sits at the sign-off's exact offset, so across a turn the mark reads as one object: + it pulses here, the answer streams in above it, and it settles at the end of the card + (see ChatMessageItem's `signed`). The arcs light outward — inner, then outer — which is + why the delays differ rather than the durations. */ export const ChatTyping: React.FC = () => ( - - - {[0, 1, 2].map(i => ( - - ))} - + + ) From 0b8fd4c565f7c58e96c5db6945fdb4de28ac7ae5 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 17:26:32 -0700 Subject: [PATCH 151/238] fix(chat): defer connectivity to the global network detector --- frontend/src/components/Chat/ChatBody.tsx | 6 ++++-- frontend/src/hooks/useChatSync.ts | 19 +++++++++++++++++++ frontend/src/i18n/locales/de/app.json | 2 +- frontend/src/i18n/locales/en/app.json | 2 +- frontend/src/i18n/locales/es/app.json | 2 +- frontend/src/i18n/locales/ja/app.json | 2 +- frontend/src/models/chat.ts | 8 +++++++- 7 files changed, 34 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index c6ff49ed5..652fc6eeb 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -26,10 +26,12 @@ export const ChatBody: React.FC = () => { const dispatch = useDispatch() const signedOut = health === 'unauthorized' const unreachable = health === 'unreachable' - // Literal default: the i18next parser can't extract a value passed as a variable + // Literal default: the i18next parser can't extract a value passed as a variable. + // Says only what a failed agent probe proves — diagnosing the connection is + // services/Network's job, and it raises its own notice. const unavailableMessage = t( 'chat.unavailable', - 'Remote.It AI is temporarily unavailable. Check your internet connection or try again in a few minutes.' + 'Remote.It AI is temporarily unavailable. Try again in a few minutes.' ) // "Working" indicator: a turn is in flight but nothing else is moving — before the first diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index 9c2c1433b..dd8d48152 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -4,6 +4,20 @@ import { useSelector, useDispatch } from 'react-redux' import { store, State, Dispatch } from '../store' import { toChatHandoff } from '../models/chat' import { initChatPopoutMain, initChatPopoutWindow, checkPopoutPresence, PopoutMainHandlers } from '../services/chatPopout' +import network from '../services/Network' + +/* Re-probe the agent when the app's own detector says connectivity is back — the same + 'connect' event Heartbeat, CloudSync and Controller reconnect on. Without it an + outage sticks until the panel is reopened, and the panel is left implying the user + should go check their own connection. */ +const useAgentHealthOnReconnect = (check: () => void): void => { + useEffect(() => { + network.on('connect', check) + return () => { + network.off('connect', check) + } + }, []) +} const currentHandoff = () => toChatHandoff(store.getState().chat) @@ -57,6 +71,8 @@ export const useChatMainSync = (): void => { if (open) dispatch.chat.checkHealth() }, [open]) + useAgentHealthOnReconnect(() => dispatch.chat.checkHealth()) + // The chat follows the app's active org from the sidebar selector useEffect(() => { dispatch.chat.syncOrg() @@ -93,4 +109,7 @@ export const useChatPopoutSync = (): void => { }) return unsubscribe }, []) + + // The popout is its own app instance, so it has its own Network to listen to + useAgentHealthOnReconnect(() => dispatch.chat.checkHealth()) } diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index e2a10150f..d3409ce5f 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -224,7 +224,7 @@ "toolRequest": "Der Agent möchte {{tool}} ausführen", "toolsUsed_one": "{{count}} Tool verwendet", "toolsUsed_other": "{{count}} Tools verwendet", - "unavailable": "Mycal ist vorübergehend nicht verfügbar. Überprüfen Sie Ihre Internetverbindung oder versuchen Sie es in einigen Minuten erneut.", + "unavailable": "Remote.It AI ist vorübergehend nicht verfügbar. Versuchen Sie es in einigen Minuten erneut.", "untitled": "Neue Unterhaltung", "usage": "Nutzung", "usageResets": "Zurücksetzung {{when}}", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 92f432fb3..811e64256 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -231,7 +231,7 @@ "toolRequest": "The agent wants to run {{tool}}", "toolsUsed_one": "Used {{count}} tool", "toolsUsed_other": "Used {{count}} tools", - "unavailable": "Remote.It AI is temporarily unavailable. Check your internet connection or try again in a few minutes.", + "unavailable": "Remote.It AI is temporarily unavailable. Try again in a few minutes.", "waitingApproval": "Waiting for approval…", "windowTitle": "remote.it chat" }, diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index cfa6b5163..8e75ec806 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -228,7 +228,7 @@ "toolsUsed_one": "{{count}} herramienta utilizada", "toolsUsed_many": "{{count}} de herramientas utilizadas", "toolsUsed_other": "{{count}} herramientas utilizadas", - "unavailable": "Mycal no está disponible temporalmente. Compruebe su conexión a internet o inténtelo de nuevo en unos minutos.", + "unavailable": "Remote.It AI no está disponible temporalmente. Inténtelo de nuevo en unos minutos.", "untitled": "Nueva conversación", "usage": "Uso", "usageResets": "Se restablece {{when}}", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 1cf9bfc2a..a330b9485 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -220,7 +220,7 @@ "stop": "停止", "toolRequest": "エージェントが {{tool}} の実行を求めています", "toolsUsed_other": "{{count}} 個のツールを使用", - "unavailable": "Mycal は一時的に利用できません。インターネット接続を確認するか、しばらくしてからもう一度お試しください。", + "unavailable": "Remote.It AI は一時的に利用できません。しばらくしてからもう一度お試しください。", "untitled": "新しい会話", "usage": "使用量", "usageResets": "{{when}}にリセット", diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 6f7dd6d23..1b69b7ccc 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -294,7 +294,13 @@ export default createModel()({ await dispatch.chat.stop() closePopoutWithHandback(toChatHandoff(store.getState().chat)) }, - async checkHealth() { + async checkHealth(_: void, state) { + /* Connectivity is the app's to detect and report — services/Network owns the + online/offline events and raises the global notice. The same guard get.ts and + post.ts use: probing while the app knows it is offline would relabel a network + outage as an agent outage, and the panel would say so on top of the global + message. Network's `connect` event re-runs this (see useChatSync). */ + if (state.ui.offline) return dispatch.chat.set({ health: await agentHealth() }) }, /* The server owns the transcript now (D11) — adopt its copy when it knows more than From e4e6ded1102caa40f6157d078f058a03160922ed Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 17:28:10 -0700 Subject: [PATCH 152/238] feat(chat): single AI mark at the foot, grey idle and blue while working --- frontend/src/assets/RemoteAI.tsx | 2 +- frontend/src/components/Chat/ChatIntro.tsx | 2 +- frontend/src/components/Chat/ChatMark.tsx | 37 ++++++++++++++++++ .../src/components/Chat/ChatMessageItem.tsx | 13 +------ frontend/src/components/Chat/ChatMessages.tsx | 13 ++----- frontend/src/components/Chat/ChatTyping.tsx | 38 ------------------- 6 files changed, 44 insertions(+), 61 deletions(-) create mode 100644 frontend/src/components/Chat/ChatMark.tsx delete mode 100644 frontend/src/components/Chat/ChatTyping.tsx diff --git a/frontend/src/assets/RemoteAI.tsx b/frontend/src/assets/RemoteAI.tsx index bea3c00b0..6974f0c28 100644 --- a/frontend/src/assets/RemoteAI.tsx +++ b/frontend/src/assets/RemoteAI.tsx @@ -6,7 +6,7 @@ import React from 'react' The two arcs are separate paths, and the antenna dot is separate again, so a caller can animate the signal without touching the head: target `.signal-inner` - and `.signal-outer` from a parent's sx (see ChatTyping). Kept as one visual + and `.signal-outer` from a parent's sx (see ChatMark). Kept as one visual group here — nothing about the resting mark depends on the split. */ export const RemoteAI = props => { return ( diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index 251a89200..a7caf222e 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -54,7 +54,7 @@ export const ChatIntro: React.FC = () => { paddingY: 4, }} > - + {t('chat.introTitle', 'Remote.It AI')} diff --git a/frontend/src/components/Chat/ChatMark.tsx b/frontend/src/components/Chat/ChatMark.tsx new file mode 100644 index 000000000..a15d378b6 --- /dev/null +++ b/frontend/src/components/Chat/ChatMark.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { Box } from '@mui/material' +import { Icon } from '../Icon' + +/* The transcript's one and only AI mark. It lives at the bottom of the conversation + permanently rather than per message: a mark under every answer reads as a repeated + avatar, and the thing it actually reports — whether the agent is working right now — + is a property of the conversation, not of any one message. + + Idle it sits grey and still; while a turn is in flight it goes brand blue and the + signal arcs broadcast outward — inner first, then outer, which is why the delays + differ rather than the durations. */ +export const ChatMark: React.FC<{ active?: boolean }> = ({ active }) => ( + + + +) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 4a9d9f2b5..1ea8acb4f 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -6,7 +6,6 @@ import { Box, Typography } from '@mui/material' import { fontSizes, radius, scrollbarStyles, SCROLLBAR_WIDTH_NARROW } from '../../styling' import { ChatTranscriptMessage } from '../../models/chat' import { ChatToolCalls } from './ChatToolCalls' -import { Icon } from '../Icon' // Links open in a new tab: a bare anchor is a top-level navigation, which in // Electron replaces the app window with the external site (will-navigate only @@ -18,14 +17,12 @@ const markdownComponents = { type Props = { message: ChatTranscriptMessage - /** Show the sign-off mark. Withheld by the caller while the message is still streaming. */ - signed?: boolean } // Memoized: immer keeps unchanged message refs stable, so during streaming // only the tail message re-renders instead of re-parsing every message's // markdown on each delta -export const ChatMessageItem = React.memo(({ message, signed }) => { +export const ChatMessageItem = React.memo(({ message }) => { const { t } = useTranslation() if (message.role === 'user') return ( @@ -142,14 +139,6 @@ export const ChatMessageItem = React.memo(({ message, signed }) => { {t('chat.interrupted', 'Interrupted')} )} - {/* Signs off a finished answer — the caller withholds it from a message that is - still streaming, so the mark landing is what tells you the turn is done. - Indented to the card's text, not its edge, so it hangs under the writing. */} - {signed && ( - - - - )} ) }) diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index c3a011115..3388e0537 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import { Box } from '@mui/material' import { ChatTranscriptMessage } from '../../models/chat' import { ChatMessageItem } from './ChatMessageItem' -import { ChatTyping } from './ChatTyping' +import { ChatMark } from './ChatMark' import { Body } from '../Body' type Props = { @@ -45,16 +45,11 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi sx={{ paddingX: 2.5, paddingY: 1 }} > {messages.map((message, index) => ( - + ))} - {typing && } {children} + {/* Last, and always: one mark anchored to the foot of the conversation. */} + ) diff --git a/frontend/src/components/Chat/ChatTyping.tsx b/frontend/src/components/Chat/ChatTyping.tsx deleted file mode 100644 index c57008565..000000000 --- a/frontend/src/components/Chat/ChatTyping.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react' -import { Box } from '@mui/material' -import { Icon } from '../Icon' - -/* "The agent is working" — the same mark that signs a finished answer, with its signal - arcs broadcasting. Shown while a turn is in flight but nothing else is visibly moving - (before the first token, and between a tool finishing and the next output). Running - tool calls show their own spinner, and streaming text is its own motion, so this never - doubles up with either. - - It sits at the sign-off's exact offset, so across a turn the mark reads as one object: - it pulses here, the answer streams in above it, and it settles at the end of the card - (see ChatMessageItem's `signed`). The arcs light outward — inner, then outer — which is - why the delays differ rather than the durations. */ -export const ChatTyping: React.FC = () => ( - - - -) From d03660b87b7abd6b3e321af39b600ae096d9ad4f Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Thu, 27 Aug 2026 17:38:04 -0700 Subject: [PATCH 153/238] style(chat): tighten composer padding and space the mark --- frontend/src/components/Chat/ChatInput.tsx | 2 +- frontend/src/components/Chat/ChatMark.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx index b56a1868d..61561e68e 100644 --- a/frontend/src/components/Chat/ChatInput.tsx +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -23,7 +23,7 @@ export const ChatInput: React.FC = ({ disabled, placeholder, streaming, o setText('') } return ( - + = ({ active }) => ( Date: Fri, 28 Aug 2026 08:23:07 -0700 Subject: [PATCH 154/238] style(chat): inset the docked panel from the theme spacing scale --- frontend/src/components/Chat/ChatPanel.tsx | 31 +++++++++++++++------- frontend/src/components/PanelHandle.tsx | 10 +++++-- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 38e47bced..279c6a176 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useSelector, useDispatch } from 'react-redux' -import { Box } from '@mui/material' +import { Box, Theme } from '@mui/material' import { State, Dispatch } from '../../store' import { CHAT_PANEL_WIDTH_MIN } from '../../constants' +import { radius } from '../../styling' import { useChatDocked, useChatWidth, useChatMaxWidth, useSidebarWidth } from '../../hooks/useChatEnabled' import { useChatMainSync } from '../../hooks/useChatSync' import { usePanelDrag } from '../../hooks/usePanelDrag' @@ -13,6 +14,11 @@ import { ChatHeader, NewChatButton } from './ChatHeader' import { ChatBody } from './ChatBody' import browser from '../../services/browser' +/* How far the docked column floats off the window edges, in theme spacing units. + One knob: the margins and the size subtractions below both derive from it, so a + change here can't leave the box and its margins disagreeing. */ +const INSET = 1 + /* Display-only: lifecycle, popout protocol, and org mirroring live in useChatMainSync; user actions dispatch chat model effects */ export const ChatPanel: React.FC = () => { @@ -63,8 +69,17 @@ export const ChatPanel: React.FC = () => { ...(docked ? { position: 'relative', - height: '100%', - width: drag.width, + /* Floating inset column. The margins come OUT of the width App already + reserves for the chat (chatWidth), rather than being added to it — so + the footprint still measures drag.width and the content area's math, + the drag clamp and the effective-width breakpoints all stay honest. + Hence subtracting one inset horizontally (right margin only) and two + vertically (top and bottom). */ + marginY: INSET, + marginRight: INSET, + height: (theme: Theme) => `calc(100% - ${theme.spacing(INSET * 2)})`, + width: (theme: Theme) => `calc(${drag.width}px - ${theme.spacing(INSET)})`, + borderRadius: `${radius.lg}px`, } : { position: 'absolute', @@ -81,7 +96,9 @@ export const ChatPanel: React.FC = () => { paddingTop: insets?.topPx, paddingRight: insets?.rightPx, bgcolor: 'grayLightest.main', - borderLeft: docked || sidebarWidth ? 1 : 0, + // Only the overlay needs a drawn edge — the floating column is separated by + // its shadow, and a left-only border would run out mid-way around the radius + borderLeft: !docked && sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', boxShadow: docked || sidebarWidth ? 3 : 0, paddingBottom: showBottomMenu ? 1.5 : insets?.bottomPx || 1.5, @@ -103,11 +120,7 @@ export const ChatPanel: React.FC = () => { } > {!browser.isMobile && !layout.mobile && ( - dispatch.chat.popOut()} - /> + dispatch.chat.popOut()} /> )} dispatch.chat.set({ open: false })} /> diff --git a/frontend/src/components/PanelHandle.tsx b/frontend/src/components/PanelHandle.tsx index 32719bbda..8275ccd08 100644 --- a/frontend/src/components/PanelHandle.tsx +++ b/frontend/src/components/PanelHandle.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Box, Theme } from '@mui/material' +import { radius } from '../styling' /* The app's drag divider: a hairline that thickens and takes the primary color on hover or while grabbed. Extracted from the panel components so @@ -34,12 +35,17 @@ type Props = { onMouseDown: (event: React.MouseEvent) => void grab: boolean /** Position it against the left edge of the panel it resizes — for a - * right-docked column that has no divider slot of its own in the flow */ + * right-docked column that has no divider slot of its own in the flow. + * That column is a floating rounded panel, so the line stops where its + * corners start curving instead of running the full height past them. */ inset?: boolean } export const PanelHandle: React.FC = ({ onMouseDown, grab, inset }) => ( - +
) From 7bc793c4d689f611afa5141a8bafc6b511102ed7 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 08:59:04 -0700 Subject: [PATCH 155/238] feat(chat): tooltip the usage ring, refine its meter and popout icons --- frontend/src/components/Chat/ChatPanel.tsx | 6 ++- frontend/src/components/Chat/ChatUsage.tsx | 60 +++++++++++++++------ frontend/src/components/Chat/ChatWindow.tsx | 4 +- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 279c6a176..d843db1dd 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -120,7 +120,11 @@ export const ChatPanel: React.FC = () => { } > {!browser.isMobile && !layout.mobile && ( - dispatch.chat.popOut()} /> + dispatch.chat.popOut()} + /> )} dispatch.chat.set({ open: false })} /> diff --git a/frontend/src/components/Chat/ChatUsage.tsx b/frontend/src/components/Chat/ChatUsage.tsx index bb9d69979..7c59660d8 100644 --- a/frontend/src/components/Chat/ChatUsage.tsx +++ b/frontend/src/components/Chat/ChatUsage.tsx @@ -1,21 +1,34 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { Box, Popover, Typography, LinearProgress, CircularProgress, IconButton as MuiIconButton } from '@mui/material' +import { + Box, + Popover, + Tooltip, + Typography, + LinearProgress, + CircularProgress, + IconButton as MuiIconButton, +} from '@mui/material' import { State } from '../../store' import { formatReset } from '../../models/chat' import { UsageWindow } from '../../services/agent' import { radius } from '../../styling' -const pct = (w: UsageWindow) => (w.unlimited || w.limitUsd <= 0 ? 0 : Math.min(100, Math.round((w.spentUsd / w.limitUsd) * 100))) +const pct = (w: UsageWindow) => + w.unlimited || w.limitUsd <= 0 ? 0 : Math.min(100, Math.round((w.spentUsd / w.limitUsd) * 100)) /* One window's row in the popover: a labeled bar + reset time. */ -const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, window }) => { +const WindowRow: React.FC<{ label: string; window: UsageWindow; gutterBottom?: boolean }> = ({ + label, + window, + gutterBottom, +}) => { const { t } = useTranslation() const used = pct(window) const color = used >= 90 ? 'error' : used >= 70 ? 'warning' : 'primary' return ( - + {label} @@ -26,7 +39,12 @@ const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, wi {!window.unlimited && ( <> - + {window.resetsAt && ( {t('chat.usageResets', 'Resets {{when}}', { when: formatReset(window.resetsAt) })} @@ -42,12 +60,12 @@ const WindowRow: React.FC<{ label: string; window: UsageWindow }> = ({ label, wi progress circles is the MUI idiom for a donut — there is no dedicated gauge. */ const UsageRing: React.FC<{ value: number; color: 'primary' | 'warning' | 'error' }> = ({ value, color }) => ( - + @@ -63,6 +81,7 @@ export const ChatUsage: React.FC = () => { const { t } = useTranslation() const usage = useSelector((state: State) => state.chat.usage) const [anchorEl, setAnchorEl] = React.useState(null) + const [hovered, setHovered] = React.useState(false) if (!usage || (usage.session.unlimited && usage.weekly.unlimited)) return null @@ -71,14 +90,25 @@ export const ChatUsage: React.FC = () => { return ( <> - setAnchorEl(e.currentTarget)} - sx={{ padding: 0.5 }} + placement="top" + arrow + open={hovered && !anchorEl} + onOpen={() => setHovered(true)} + onClose={() => setHovered(false)} > - - + setAnchorEl(e.currentTarget)} sx={{ padding: 1 }}> + + + { {t('chat.usageTitle', 'Usage')} - + diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 1c59a3b0e..79236eff7 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -31,8 +31,8 @@ export const ChatWindow: React.FC = () => { dispatch.chat.popIn()} /> From 3caa5da568d87a7de5fd653134516b8a8f0ad567 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 09:55:09 -0700 Subject: [PATCH 156/238] fix(layout): keep the bottom menu on the app side of the chat column --- frontend/src/components/App.tsx | 39 ++++++++++++++++------ frontend/src/components/Chat/ChatPanel.tsx | 6 ++-- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 96ca061f4..1857adf09 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -9,12 +9,7 @@ import { useLocation } from 'react-router-dom' import { PersistGate } from 'redux-persist/integration/react' import { selectResellerRef } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' -import { - HIDE_TWO_PANEL_WIDTH, - MOBILE_WIDTH, - REGEX_FIRST_PATH, - SHOW_TRIPLE_PANEL_WIDTH, -} from '../constants' +import { HIDE_TWO_PANEL_WIDTH, MOBILE_WIDTH, REGEX_FIRST_PATH, SHOW_TRIPLE_PANEL_WIDTH } from '../constants' import { State, Dispatch } from '../store' import { Box } from '@mui/material' import { InstallationNotice } from './InstallationNotice' @@ -157,19 +152,41 @@ export const App: React.FC = () => { display: 'flex', overflow: 'hidden', flexDirection: 'row', - alignItems: 'start', - justifyContent: 'start', }} > - {hideSidebar ? : } - + {/* The app side owns its own chrome. The sidebar, the pages AND the bottom + menu stack in this column, so the docked chat is a full-height column + BESIDE all three rather than a panel the menu runs underneath. */} + + + {hideSidebar ? : } + + + {showBottomMenu && } + {chatEnabled && ( )} - {showBottomMenu && } )} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index d843db1dd..a75685ee6 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -26,7 +26,6 @@ export const ChatPanel: React.FC = () => { const open = useSelector((state: State) => state.chat.open) const expanded = useSelector((state: State) => state.chat.expanded) const insets = useSelector((state: State) => state.ui.layout.insets) - const showBottomMenu = useSelector((state: State) => state.ui.layout.showBottomMenu) const layout = useSelector((state: State) => state.ui.layout) const docked = useChatDocked() const chatWidth = useChatWidth() @@ -101,7 +100,10 @@ export const ChatPanel: React.FC = () => { borderLeft: !docked && sidebarWidth ? 1 : 0, borderColor: 'grayLighter.main', boxShadow: docked || sidebarWidth ? 3 : 0, - paddingBottom: showBottomMenu ? 1.5 : insets?.bottomPx || 1.5, + // The chat reaches the window's bottom edge in both modes now — a full-height + // column beside the bottom menu when docked, over it when it overlays — so it + // owns its safe-area inset rather than leaving it to the menu below it + paddingBottom: insets?.bottomPx || 1.5, }} ref={panelRef} > From 5aca91c08175c2002bead66f3d86a5cb79c54f1b Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 10:09:46 -0700 Subject: [PATCH 157/238] fix(body): mask the overflow fades instead of overlaying gradients --- frontend/src/components/Body.tsx | 168 +++++++++--------- frontend/src/components/Chat/ChatMessages.tsx | 9 +- 2 files changed, 84 insertions(+), 93 deletions(-) diff --git a/frontend/src/components/Body.tsx b/frontend/src/components/Body.tsx index 13c2a49e0..63f62e057 100644 --- a/frontend/src/components/Body.tsx +++ b/frontend/src/components/Body.tsx @@ -3,6 +3,24 @@ import browser from '../services/browser' import { Box, SxProps, Theme } from '@mui/material' import { spacing, toSxArray, scrollbarStyles, SCROLLBAR_WIDTH } from '../styling' +const FADE_SIZE = 30 + +/* Alpha stops for one edge of the scroll surface. `black` and `transparent` are mask + alpha values here, not palette colours — which is the point: a mask dissolves the + CONTENT, so unlike a painted gradient it needs to know nothing about what it is + sitting on, and it lines up with the scroll box by construction. + + `gutter` is the scrollbar strip at the far end, left opaque so the bar itself does + not dissolve along with the content. Nothing else is painted there — the content box + already excludes the strip — so holding it solid costs nothing. */ +const fadeMask = (direction: 'to bottom' | 'to right', fadeStart: boolean, gutter: number) => { + const start = fadeStart ? `transparent, black ${FADE_SIZE}px` : 'black' + const end = gutter + ? `black calc(100% - ${FADE_SIZE + gutter}px), transparent calc(100% - ${gutter}px), black calc(100% - ${gutter}px)` + : `black calc(100% - ${FADE_SIZE}px), transparent` + return `linear-gradient(${direction}, ${start}, ${end})` +} + export type BodyProps = { inset?: boolean center?: boolean @@ -41,94 +59,68 @@ export const Body: React.FC = ({ const scrollbarWidth = browser.isMobile ? 0 : SCROLLBAR_WIDTH const bg: Color = scrollbarBackground || 'white' + /* Both bars take space when the surface scrolls in both directions (overflow: + scroll below), so that is when the fades hold back off the scrollbar strips. */ + const gutter = horizontalOverflow ? scrollbarWidth : 0 + const masks = [ + verticalOverflow && fadeMask('to bottom', !!fadeTop, gutter), + horizontalOverflow && fadeMask('to right', false, gutter), + ].filter(Boolean) + const mask = masks.join(', ') + return ( - <> - {verticalOverflow && ( - ({ - position: 'absolute', - height: 30, - zIndex: 7, - width: '100%', - right: horizontalOverflow ? `${scrollbarWidth}px` : undefined, - bottom: horizontalOverflow ? `${scrollbarWidth}px` : 0, - backgroundImage: `linear-gradient(transparent, ${theme.palette[bg].main})`, - pointerEvents: 'none', - })} - /> - )} - {verticalOverflow && fadeTop && ( - ({ - position: 'absolute', - height: 30, - zIndex: 7, - width: '100%', - right: horizontalOverflow ? `${scrollbarWidth}px` : undefined, - top: 0, - backgroundImage: `linear-gradient(${theme.palette[bg].main}, transparent)`, - pointerEvents: 'none', - })} - /> - )} - {horizontalOverflow && ( - ({ - position: 'absolute', - width: 30, - top: 0, - bottom: `${scrollbarWidth}px`, - zIndex: 7, - right: `${scrollbarWidth}px`, - backgroundImage: `linear-gradient(90deg, transparent, ${theme.palette[bg].main})`, - pointerEvents: 'none', - })} - /> - )} - scrollbarStyles(theme, { background: bg, width: scrollbarWidth }), - theme => ({ - flexGrow: 1, - height: '100%', - overflow: verticalOverflow && horizontalOverflow ? 'scroll' : 'auto', - overscrollBehaviorX: 'none', - position: 'relative', - WebkitOverflowScrolling: 'touch', - [theme.breakpoints.down('sm')]: { - overflowX: 'hidden', - }, - // forces right scrollbar to appear (overflow: scroll causes extra padding) - ...(horizontalOverflow ? { '& > *:first-of-type': { minHeight: '100.1%' } } : {}), - }), - flex - ? { - display: 'flex', - alignContent: 'flex-start', - flexWrap: 'wrap', - justifyContent: 'space-evenly', - } - : {}, - center - ? { - display: 'flex', - alignItems: 'center', - justifyContent: verticalOverflow && browser.isAndroid ? undefined : 'center', - flexDirection: 'column', - padding: `${spacing.md}px ${spacing.md}px ${spacing.xl}px`, - } - : {}, - inset ? { padding: `${spacing.sm}px ${spacing.xl}px` } : {}, - gutterBottom ? { paddingBottom: `${spacing.xxl}px` } : {}, - gutterTop ? { paddingTop: `${spacing.sm}px` } : {}, - ...toSxArray(sx), - ]} - > - {children} - - + scrollbarStyles(theme, { background: bg, width: scrollbarWidth }), + mask + ? { + // -webkit- first so the standard property wins where both are understood + WebkitMaskImage: mask, + maskImage: mask, + WebkitMaskComposite: 'source-in', + maskComposite: 'intersect', + } + : {}, + theme => ({ + flexGrow: 1, + height: '100%', + overflow: verticalOverflow && horizontalOverflow ? 'scroll' : 'auto', + overscrollBehaviorX: 'none', + position: 'relative', + WebkitOverflowScrolling: 'touch', + [theme.breakpoints.down('sm')]: { + overflowX: 'hidden', + }, + // forces right scrollbar to appear (overflow: scroll causes extra padding) + ...(horizontalOverflow ? { '& > *:first-of-type': { minHeight: '100.1%' } } : {}), + }), + flex + ? { + display: 'flex', + alignContent: 'flex-start', + flexWrap: 'wrap', + justifyContent: 'space-evenly', + } + : {}, + center + ? { + display: 'flex', + alignItems: 'center', + justifyContent: verticalOverflow && browser.isAndroid ? undefined : 'center', + flexDirection: 'column', + padding: `${spacing.md}px ${spacing.md}px ${spacing.xl}px`, + } + : {}, + inset ? { padding: `${spacing.sm}px ${spacing.xl}px` } : {}, + gutterBottom ? { paddingBottom: `${spacing.xxl}px` } : {}, + gutterTop ? { paddingTop: `${spacing.sm}px` } : {}, + ...toSxArray(sx), + ]} + > + {children} + ) } diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 3388e0537..98798f37c 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -31,12 +31,11 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi return () => element.removeEventListener('scroll', onScroll) }, []) - /* The app's standard scroll surface: Body draws the bottom overflow fade the - settings and device pages use. Its fade is absolutely positioned, so it needs - this relative wrapper to sit at the transcript's bottom edge rather than the - panel's — otherwise it lands under the composer. */ + /* The app's standard scroll surface: Body draws the same top and bottom overflow + fades the settings and device pages use. They are masked onto the scroll box + itself, so this wrapper is only here to give the column its flex bounds. */ return ( - + Date: Fri, 28 Aug 2026 10:18:35 -0700 Subject: [PATCH 158/238] fix(body): measure the scrollbar gutter so the fade reaches the edge --- frontend/src/components/Body.tsx | 41 +++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Body.tsx b/frontend/src/components/Body.tsx index 63f62e057..2f0e256e2 100644 --- a/frontend/src/components/Body.tsx +++ b/frontend/src/components/Body.tsx @@ -11,8 +11,11 @@ const FADE_SIZE = 30 sitting on, and it lines up with the scroll box by construction. `gutter` is the scrollbar strip at the far end, left opaque so the bar itself does - not dissolve along with the content. Nothing else is painted there — the content box - already excludes the strip — so holding it solid costs nothing. */ + not dissolve along with the content. It is MEASURED rather than assumed: an overlay + scrollbar (the macOS default) floats over the content and reserves nothing, so + holding a strip back there leaves a hard band of un-faded content where no bar is — + which is what the old painted gradients did. A classic bar does reserve the strip, + and nothing else is painted under it, so holding it solid costs nothing there. */ const fadeMask = (direction: 'to bottom' | 'to right', fadeStart: boolean, gutter: number) => { const start = fadeStart ? `transparent, black ${FADE_SIZE}px` : 'black' const end = gutter @@ -21,6 +24,24 @@ const fadeMask = (direction: 'to bottom' | 'to right', fadeStart: boolean, gutte return `linear-gradient(${direction}, ${start}, ${end})` } +/* How wide the scrollbars actually are on this surface — 0 for overlay bars. Watched + rather than read once: with overflow:auto the bar comes and goes with the content, + and that changes the content box, which is what ResizeObserver reports by default. */ +const useScrollbarGutter = (ref: React.RefObject): number => { + const [gutter, setGutter] = React.useState(0) + React.useLayoutEffect(() => { + const element = ref.current + if (!element) return + const measure = () => + setGutter(Math.max(element.offsetWidth - element.clientWidth, element.offsetHeight - element.clientHeight)) + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + return () => observer.disconnect() + }, []) + return gutter +} + export type BodyProps = { inset?: boolean center?: boolean @@ -58,10 +79,18 @@ export const Body: React.FC = ({ }) => { const scrollbarWidth = browser.isMobile ? 0 : SCROLLBAR_WIDTH const bg: Color = scrollbarBackground || 'white' + const scrollRef = React.useRef(null) + const gutter = useScrollbarGutter(scrollRef) - /* Both bars take space when the surface scrolls in both directions (overflow: - scroll below), so that is when the fades hold back off the scrollbar strips. */ - const gutter = horizontalOverflow ? scrollbarWidth : 0 + // Callers pass a ref to drive the scroll position (see ChatMessages); the measurement + // needs the same node, so hand it to both + const setScrollRef = React.useCallback( + (node: HTMLDivElement | null) => { + scrollRef.current = node + if (bodyRef) (bodyRef as React.MutableRefObject).current = node + }, + [bodyRef] + ) const masks = [ verticalOverflow && fadeMask('to bottom', !!fadeTop, gutter), horizontalOverflow && fadeMask('to right', false, gutter), @@ -70,7 +99,7 @@ export const Body: React.FC = ({ return ( Date: Fri, 28 Aug 2026 11:09:42 -0700 Subject: [PATCH 159/238] feat(chat): centre the transcript column and widen the app frame for it --- .../src/components/Chat/ChatMessageItem.tsx | 2 -- frontend/src/components/Chat/ChatMessages.tsx | 20 +++++++++++++------ frontend/src/components/RemoteHeader.tsx | 15 +++++++++++--- frontend/src/constants.ts | 4 ++++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 1ea8acb4f..f32cbeda1 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -34,7 +34,6 @@ export const ChatMessageItem = React.memo(({ message }) => { marginLeft: 8, padding: 2, paddingY: 1.25, - maxWidth: 800, }} > @@ -65,7 +64,6 @@ export const ChatMessageItem = React.memo(({ message }) => { paddingY: 2, fontSize: fontSizes.base, lineHeight: 1.7, - maxWidth: 800, wordBreak: 'break-word', // Theme tokens, and the app's own emphasis convention (see theme.ts // body1/caption): bold is a COLOR step plus weight 500 — never 700. diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx index 98798f37c..e8ee50665 100644 --- a/frontend/src/components/Chat/ChatMessages.tsx +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -3,6 +3,7 @@ import { Box } from '@mui/material' import { ChatTranscriptMessage } from '../../models/chat' import { ChatMessageItem } from './ChatMessageItem' import { ChatMark } from './ChatMark' +import { CHAT_MAX_MESSAGE_WIDTH } from '../../constants' import { Body } from '../Body' type Props = { @@ -43,12 +44,19 @@ export const ChatMessages: React.FC = ({ messages, streaming, typing, chi bodyRef={ref} sx={{ paddingX: 2.5, paddingY: 1 }} > - {messages.map((message, index) => ( - - ))} - {children} - {/* Last, and always: one mark anchored to the foot of the conversation. */} - + {/* The reading measure belongs to the COLUMN, not to each message. Capped per + message, a wide panel did not widen the text — it pushed the speakers to + opposite edges and the thread read as two columns. Centred here, the + conversation holds together at any panel width, and left/right alignment + stays relative to the column instead of the window. */} + + {messages.map((message, index) => ( + + ))} + {children} + {/* Last, and always: one mark anchored to the foot of the conversation. */} + + ) diff --git a/frontend/src/components/RemoteHeader.tsx b/frontend/src/components/RemoteHeader.tsx index 84cfde85c..fc400500d 100644 --- a/frontend/src/components/RemoteHeader.tsx +++ b/frontend/src/components/RemoteHeader.tsx @@ -5,11 +5,20 @@ import { TargetPlatform } from './TargetPlatform' import { Icon } from './Icon' import screenfull from 'screenfull' import browser from '../services/browser' +import { useChatDocked, useChatWidth } from '../hooks/useChatEnabled' type Props = { device?: IDevice; children: React.ReactNode } export const RemoteHeader: React.FC = ({ device, children }) => { - const maxWidth = !browser.isElectron && useMediaQuery(`(min-width:${APP_MAX_WIDTH}px)`) + /* APP_MAX_WIDTH is how wide the APP's content should ever get. The docked chat is a + column beside that content rather than part of it, so the frame grows by exactly + what the chat takes — otherwise opening the chat quietly spends the app's own width + on it. Expanded doesn't count: it overlays the content instead of sitting beside it. + The media query uses the same figure, or the framed look would start before the + frame could actually reach its width. (Web only — Electron always fills its window.) */ + const chatWidth = useChatWidth() + const appMaxWidth = APP_MAX_WIDTH + (useChatDocked() ? chatWidth : 0) + const maxWidth = !browser.isElectron && useMediaQuery(`(min-width:${appMaxWidth}px)`) const showFrame = browser.isRemote const [fullscreen, setFullscreen] = useState(false) const fullscreenEnabled = screenfull.isEnabled @@ -54,7 +63,7 @@ export const RemoteHeader: React.FC = ({ device, children }) => { marginTop: maxWidth || showFrame ? 3 / 2 : 0, height: `calc(100% - ${showFrame ? spacing(6) : maxWidth ? spacing(3) : '0px'})`, width: `calc(100% - ${showFrame ? spacing(6) : '0px'})`, - maxWidth: maxWidth ? APP_MAX_WIDTH : undefined, + maxWidth: maxWidth ? appMaxWidth : undefined, backgroundColor: 'white.main', borderRadius: maxWidth || showFrame ? 5 : undefined, boxShadow: maxWidth || showFrame ? 3 : undefined, @@ -64,4 +73,4 @@ export const RemoteHeader: React.FC = ({ device, children }) => { ) -} \ No newline at end of file +} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 18285e81c..e64710184 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -120,6 +120,10 @@ export const CHAT_GUIDE_DATE = new Date('2026-08-27') preserve it, so the chat keeps its column on small desktop windows instead of taking the screen — that only happens at phone size (MOBILE_WIDTH). */ export const CHAT_MIN_CONTENT_WIDTH = 500 +// Reading measure for the transcript column — text much wider than this is hard to +// track back to the start of the next line. Applied to the COLUMN, so the panel itself +// may be any width without the conversation spreading across it. +export const CHAT_MAX_MESSAGE_WIDTH = 800 export const ORGANIZATION_BAR_WIDTH = 70 export const HIDE_SIDEBAR_WIDTH = 1150 export const HIDE_TWO_PANEL_WIDTH = 750 From ae49296b010acd444bf7f23bae5d545309969c6a Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 11:23:07 -0700 Subject: [PATCH 160/238] fix(layout): stop double-counting the chat width, drop chat expand, share one resize source --- frontend/src/components/App.tsx | 20 +++------ frontend/src/components/Chat/ChatHeader.tsx | 23 +++++----- frontend/src/components/Chat/ChatPanel.tsx | 23 +++------- frontend/src/components/DoublePanel.tsx | 26 +++++------- frontend/src/hooks/useChatEnabled.ts | 21 ++------- frontend/src/hooks/usePanelDrag.ts | 13 ++++-- frontend/src/hooks/useViewportWidth.ts | 47 +++++++++++++++++++++ frontend/src/i18n/locales/de/app.json | 2 - frontend/src/i18n/locales/en/app.json | 2 - frontend/src/i18n/locales/es/app.json | 2 - frontend/src/i18n/locales/ja/app.json | 2 - frontend/src/models/chat.ts | 20 +++++---- frontend/src/store.ts | 1 - 13 files changed, 107 insertions(+), 95 deletions(-) create mode 100644 frontend/src/hooks/useViewportWidth.ts diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 1857adf09..6ce8d2596 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -19,14 +19,7 @@ import { SidebarMenu } from './SidebarMenu' import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' -import { - useChatEnabled, - useChatDocked, - useChatWidth, - useSidebarWidth, - useEffectiveWidth, - useHideSidebar, -} from '../hooks/useChatEnabled' +import { useChatEnabled, useSidebarWidth, useEffectiveWidth, useHideSidebar } from '../hooks/useChatEnabled' import { Router } from '../routers/Router' import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' @@ -56,8 +49,6 @@ export const App: React.FC = () => { const waitMessage = useSelector((state: State) => state.ui.waitMessage) const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) const chatEnabled = useChatEnabled() - const chatDocked = useChatDocked() - const chatWidth = useChatWidth() const sidebarWidth = useSidebarWidth() const reseller = useSelector(selectResellerRef) const dispatch = useDispatch() @@ -69,10 +60,11 @@ export const App: React.FC = () => { const singlePanel = effectiveWidth <= HIDE_TWO_PANEL_WIDTH const triplePanel = effectiveWidth >= SHOW_TRIPLE_PANEL_WIDTH const mobile = effectiveWidth <= MOBILE_WIDTH - // The docked chat column reserves layout space the same way the sidebar - // does; useChatDocked only docks when the panels still fit beside it — - // otherwise the chat renders as an overlay and reserves nothing - const sidePanelWidth = sidebarWidth + (chatDocked ? chatWidth : 0) + /* Chrome the content panels have to share their row with. The chat is NOT in that + row — it is a column beside the whole app side — so it must not be counted here: + the panels' own parent already excludes it, and adding it back subtracted the chat + twice, which drove their max width below their minimum and froze the drag. */ + const sidePanelWidth = sidebarWidth const isRootMenu = location.pathname.match(REGEX_FIRST_PATH)?.[0] === location.pathname const showBottomMenu = (mobile || browser.isMobile) && isRootMenu && hideSidebar const needsUserHydration = authenticated && !user diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index c73766bfd..75c29d6fe 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -1,7 +1,15 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import { Box, Menu, MenuItem, ListItemText, ListSubheader, Typography, IconButton as MuiIconButton } from '@mui/material' +import { + Box, + Menu, + MenuItem, + ListItemText, + ListSubheader, + Typography, + IconButton as MuiIconButton, +} from '@mui/material' import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' @@ -13,21 +21,16 @@ import { isChatPopout } from '../../services/chatPopout' import { ConversationSummary } from '../../services/agent' import { CHAT_GUIDE_DATE } from '../../constants' -/* Control row shared by the docked panel and the popout window — `leading` takes the - panel-chrome control (expand/collapse) at the far left, then the conversation's name, - which doubles as the history picker; window-specific actions render as children on the - right in each caller's order. The row mirrors the app Header's box exactly — same +/* Control row shared by the docked panel and the popout window — the conversation's + name sits at the left and doubles as the history picker; window-specific actions + render as children on the right in each caller's order. The row mirrors the app Header's box exactly — same height, same top margin, centered — so the two icon rows share a baseline across the divider. The name is the only thing allowed to shrink: it takes the slack and yields it back, so the action icons never compress. */ -export const ChatHeader: React.FC<{ leading?: React.ReactNode; children?: React.ReactNode }> = ({ - leading, - children, -}) => { +export const ChatHeader: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const { t } = useTranslation() return ( - {leading} {/* Step 3. The wrapper sx keeps the shrink chain intact — without minWidth: 0 the inserted div would refuse to shrink and the name would stop truncating. */} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index a75685ee6..605e16e43 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -24,7 +24,6 @@ const INSET = 1 export const ChatPanel: React.FC = () => { const { t } = useTranslation() const open = useSelector((state: State) => state.chat.open) - const expanded = useSelector((state: State) => state.chat.expanded) const insets = useSelector((state: State) => state.ui.layout.insets) const layout = useSelector((state: State) => state.ui.layout) const docked = useChatDocked() @@ -61,10 +60,10 @@ export const ChatPanel: React.FC = () => { display: 'flex', flexFlow: 'column', flexShrink: 0, - // Docked column beside the panels when it fits; otherwise an overlay - // over the CONTENT area — expanded (maximized) or on a small window. - // The overlay's left edge stops at the sidebar chrome so it never - // covers the left nav; when the sidebar is hidden it spans the window + // Docked column beside the panels when it fits, otherwise an overlay over the + // CONTENT area — a phone, or a window too narrow to hold both. The overlay's + // left edge stops at the sidebar chrome so it never covers the left nav; when + // the sidebar is hidden it spans the window ...(docked ? { position: 'relative', @@ -108,19 +107,7 @@ export const ChatPanel: React.FC = () => { ref={panelRef} > {docked && } - dispatch.chat.set({ expanded: !expanded })} - /> - ) - } - > + {!browser.isMobile && !layout.mobile && ( = ({ left, right, layout, header = tru const secondaryMinWidth = getPanelWidthDefault(routeKey, undefined, MIN_WIDTH) const primaryRef = useRef(null) const [parentWidth, setParentWidth] = useState() + const viewportWidth = useViewportWidth() const sidePanelWidth = layout.sidePanelWidth + PADDING - const getMaxWidth = useCallback( - () => { - const fullWidth = primaryRef.current?.parentElement?.offsetWidth || 1000 - // Never below the minimum: a max < min makes usePanelDrag oscillate and - // emit negative widths when reserved chrome exceeds the window - return Math.max(MIN_WIDTH, fullWidth - secondaryMinWidth - sidePanelWidth) - }, - [secondaryMinWidth, sidePanelWidth] - ) + const getMaxWidth = useCallback(() => { + const fullWidth = primaryRef.current?.parentElement?.offsetWidth || 1000 + // Never below the minimum: a max < min makes usePanelDrag oscillate and + // emit negative widths when reserved chrome exceeds the window + return Math.max(MIN_WIDTH, fullWidth - secondaryMinWidth - sidePanelWidth) + }, [secondaryMinWidth, sidePanelWidth]) const drag = usePanelDrag(panelWidth, { panelRef: primaryRef, @@ -49,14 +48,11 @@ export const DoublePanel: React.FC = ({ left, right, layout, header = tru setParentWidth(parent) }, [sidePanelWidth]) + // The shared viewport width stands in for a resize listener: it only changes when the + // window actually did, and at most once a frame useEffect(() => { measureParent() - }, [layout, drag.width, measureParent]) - - useEffect(() => { - window.addEventListener('resize', measureParent) - return () => window.removeEventListener('resize', measureParent) - }, [measureParent]) + }, [layout, drag.width, viewportWidth, measureParent]) const panelSx = { height: '100%', diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index 2aa3385ce..888e4b8f2 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { State } from '../store' import browser from '../services/browser' +import { useViewportWidth } from './useViewportWidth' import { MODE, CHAT_ALWAYS_ON, @@ -21,18 +21,6 @@ export const useChatEnabled = (): boolean => { return MODE === 'development' || CHAT_ALWAYS_ON || !!testUI } -/* Viewport width, tracked for the chat column's fit math — the content - panels measure on the same event (see DoublePanel's resize listener) */ -const useViewportWidth = (): number => { - const [width, setWidth] = useState(() => window.innerWidth) - useEffect(() => { - const onResize = () => setWidth(window.innerWidth) - window.addEventListener('resize', onResize) - return () => window.removeEventListener('resize', onResize) - }, []) - return width -} - /* The widest the chat column may be dragged: whatever the window holds once the content keeps CHAT_MIN_CONTENT_WIDTH. The sidebar does not cap the drag — the layout breakpoints below measure the REMAINING width, so a chat dragged wide collapses the @@ -58,8 +46,8 @@ export const useChatWidth = (): number => { /* Whether the open chat reserves layout width (docked) or covers the app as an overlay. Taking the whole screen is a PHONE behaviour, not a small-window one: a narrow desktop window keeps the column and lets the chat and the content share - what there is. Maximizing is the one way to get the overlay on a real screen. - The threshold is DERIVED — the window must hold the column at its minimum and the + what there is. On a real screen the chat is always a column — it is resized by + dragging its edge, not by a maximize toggle. The threshold is DERIVED — the window must hold the column at its minimum and the content at its minimum — rather than borrowed from the two-panel breakpoint, which is what used to un-dock the chat on windows as wide as 1150px. Deliberately independent of the chat's CURRENT width: gating docking on the width that docking @@ -67,10 +55,9 @@ export const useChatWidth = (): number => { export const useChatDocked = (): boolean => { const enabled = useChatEnabled() const open = useSelector((state: State) => state.chat.open) - const expanded = useSelector((state: State) => state.chat.expanded) const viewport = useViewportWidth() const fits = viewport >= CHAT_PANEL_WIDTH_MIN + CHAT_MIN_CONTENT_WIDTH - return enabled && open && !expanded && !browser.isMobile && fits + return enabled && open && !browser.isMobile && fits } /* The width the app layout actually has left: the window minus the docked chat diff --git a/frontend/src/hooks/usePanelDrag.ts b/frontend/src/hooks/usePanelDrag.ts index 77113c945..7ec5c7204 100644 --- a/frontend/src/hooks/usePanelDrag.ts +++ b/frontend/src/hooks/usePanelDrag.ts @@ -1,4 +1,5 @@ import React, { useRef, useState, useEffect, useCallback } from 'react' +import { useViewportWidth } from './useViewportWidth' interface UsePanelDragOptions { panelRef: React.RefObject @@ -35,6 +36,7 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) const handleRef = useRef(initialWidth) const moveRef = useRef(0) const [width, setWidth] = useState(initialWidth) + const viewportWidth = useViewportWidth() const [grab, setGrab] = useState(false) const measure = useCallback(() => { @@ -51,7 +53,10 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) // CLAMP the accumulator rather than ignoring out-of-range values: letting // it run past the limit meant a drag beyond the edge had to retrace the // whole overshoot before the panel moved again, which reads as sticking. - handleRef.current = Math.min(Math.max(handleRef.current + (anchor === 'right' ? -delta : delta), minWidth), maxWidth) + handleRef.current = Math.min( + Math.max(handleRef.current + (anchor === 'right' ? -delta : delta), minWidth), + maxWidth + ) setWidth(handleRef.current) onChange?.(handleRef.current) }, @@ -83,11 +88,11 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) setWidth(initialWidth) }, [initialWidth]) + // Re-clamp when the layout shifts or the window resizes — the shared viewport width + // is the resize signal, already coalesced to a frame and silent when nothing moved useEffect(() => { measure() - window.addEventListener('resize', measure) - return () => window.removeEventListener('resize', measure) - }, [layoutDep]) + }, [layoutDep, viewportWidth]) return { width, grab, onDown } } diff --git a/frontend/src/hooks/useViewportWidth.ts b/frontend/src/hooks/useViewportWidth.ts new file mode 100644 index 000000000..6e2789147 --- /dev/null +++ b/frontend/src/hooks/useViewportWidth.ts @@ -0,0 +1,47 @@ +import { useSyncExternalStore } from 'react' + +/* The window's width, shared by every layout hook and resizable panel. + + One listener for the whole app, coalesced to a frame. Each consumer used to mount its + OWN resize listener and its own state — and the layout hooks fan out, so a handful of + components meant a dozen-plus listeners each calling setState on every resize event. + That cost about a frame's worth of work per event even when the width had not + changed at all, which is most of what a drag or a maximise actually fires. + + Three things keep it cheap: one listener however many components read it, a burst of + events collapsed into a single measurement per frame, and silence when the width is + unchanged — so a no-op resize notifies nobody and renders nothing. */ +let width = window.innerWidth +const listeners = new Set<() => void>() +let queued = 0 + +const measure = () => { + queued = 0 + const next = window.innerWidth + if (next === width) return + width = next + listeners.forEach(listener => listener()) +} + +const onResize = () => { + if (queued) return + queued = requestAnimationFrame(measure) +} + +const subscribe = (listener: () => void) => { + if (!listeners.size) window.addEventListener('resize', onResize) + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size) return + window.removeEventListener('resize', onResize) + if (queued) { + cancelAnimationFrame(queued) + queued = 0 + } + } +} + +const getSnapshot = () => width + +export const useViewportWidth = (): number => useSyncExternalStore(subscribe, getSnapshot) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 9d42dff06..75389ca6c 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -289,12 +289,10 @@ "chat": { "approve": "Genehmigen", "close": "Schließen", - "collapse": "Verkleinern", "deleteConfirmAction": "Löschen", "deleteConfirmTitle": "Diese Unterhaltung löschen?", "deleteConversation": "Löschen", "deny": "Ablehnen", - "expand": "Vergrößern", "guideAgentBody": "Fragen Sie nach Ihren Geräten, Verbindungen und Diensten — oder lassen Sie Änderungen vornehmen. Hier können Sie den Assistenten jederzeit öffnen und schließen.", "guideAgentTitle": "Das ist Remote.It AI", "guideComposeBody": "Stellen Sie eine Frage oder sagen Sie, was geändert werden soll. Alles, was Ihr Konto verändert, wartet zuerst auf Ihre Zustimmung.", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index e779c4ab5..76fbbac15 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -289,11 +289,9 @@ "chat": { "approve": "Approve", "close": "Close", - "collapse": "Collapse", "deleteConfirmAction": "Delete", "deleteConfirmTitle": "Delete this conversation?", "deny": "Deny", - "expand": "Expand", "guideAgentBody": "Ask about your devices, connections and services — or tell it to make changes. Open and close it here any time.", "guideAgentTitle": "Meet Remote.It AI", "guideComposeBody": "Type a question, or say what you want changed. Anything that alters your account pauses for your approval first.", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 2f15dd49c..b5c7c28f4 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -292,12 +292,10 @@ "chat": { "approve": "Aprobar", "close": "Cerrar", - "collapse": "Contraer", "deleteConfirmAction": "Eliminar", "deleteConfirmTitle": "¿Eliminar esta conversación?", "deleteConversation": "Eliminar", "deny": "Denegar", - "expand": "Expandir", "guideAgentBody": "Pregunte por sus dispositivos, conexiones y servicios — o pídale que haga cambios. Puede abrirlo y cerrarlo aquí en cualquier momento.", "guideAgentTitle": "Le presentamos Remote.It AI", "guideComposeBody": "Escriba una pregunta o indique qué quiere cambiar. Todo lo que modifique su cuenta espera primero su aprobación.", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index a0010ea5e..f16ae1fa0 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -286,12 +286,10 @@ "chat": { "approve": "承認", "close": "閉じる", - "collapse": "縮小", "deleteConfirmAction": "削除", "deleteConfirmTitle": "この会話を削除しますか?", "deleteConversation": "削除", "deny": "拒否", - "expand": "拡大", "guideAgentBody": "デバイス、接続、サービスについて質問したり、変更を指示したりできます。ここからいつでも開閉できます。", "guideAgentTitle": "Remote.It AI のご紹介", "guideComposeBody": "質問を入力するか、変更したい内容を伝えてください。アカウントに変更を加える操作は、必ず承認を求めてから実行されます。", diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 1b69b7ccc..65dc0c1c5 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -44,7 +44,6 @@ export type ChatTranscriptMessage = export type IChatState = { open: boolean /** Maximized over the content area (the left nav stays); toggled by the header expand button */ - expanded: boolean /** Docked column width in px — drag-resized, persisted */ width: number messages: ChatTranscriptMessage[] @@ -67,7 +66,6 @@ export type IChatState = { export const defaultChatState: IChatState = { open: true, - expanded: false, width: CHAT_PANEL_WIDTH, messages: [], conversationId: '', @@ -123,7 +121,8 @@ function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { // pointless until the token is refreshed (e.g. it expired mid-turn). if (event.message.startsWith('reauth_required')) { state.error = i18n.t('notices:chat.sessionExpired', { - defaultValue: 'The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.', + defaultValue: + 'The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.', }) state.health = 'unauthorized' } else { @@ -162,7 +161,9 @@ export const toChatHandoff = (chat: IChatState): ChatHandoff => ({ }) const authRequiredError = () => - i18n.t('notices:chat.authRequired', { defaultValue: 'The agent refused this session\u2019s credentials — refresh permissions to continue.' }) + i18n.t('notices:chat.authRequired', { + defaultValue: 'The agent refused this session\u2019s credentials — refresh permissions to continue.', + }) /* A short, human reset time: a time-of-day within a day, else weekday + time. */ export const formatReset = (iso: string | null): string => { @@ -176,7 +177,9 @@ export const formatReset = (iso: string | null): string => { const usageLimitMessage = (e: UsageLimitError): string => { const when = formatReset(e.resetsAt) - return when ? i18n.t('notices:chat.usageReset', { defaultValue: '{{msg}} Resets {{when}}.', msg: e.message, when }) : e.message + return when + ? i18n.t('notices:chat.usageReset', { defaultValue: '{{msg}} Resets {{when}}.', msg: e.message, when }) + : e.message } let abortController: AbortController | null = null @@ -235,7 +238,8 @@ export default createModel()({ } catch (error) { flushDeltas() if (error instanceof AgentAuthError) dispatch.chat.set({ error: authRequiredError(), health: 'unauthorized' }) - else if (error instanceof UsageLimitError) dispatch.chat.applyEvent({ type: 'error', message: usageLimitMessage(error) }) + else if (error instanceof UsageLimitError) + dispatch.chat.applyEvent({ type: 'error', message: usageLimitMessage(error) }) else if ((error as Error).name !== 'AbortError') dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message }) } finally { @@ -315,7 +319,7 @@ export default createModel()({ messages: remote.messages.map(m => m.role === 'assistant' ? { role: 'assistant' as const, text: m.content, toolCalls: [] } - : { role: 'user' as const, text: m.content }, + : { role: 'user' as const, text: m.content } ), }) } @@ -377,7 +381,7 @@ export default createModel()({ messages: remote.messages.map(m => m.role === 'assistant' ? { role: 'assistant' as const, text: m.content, toolCalls: [] } - : { role: 'user' as const, text: m.content }, + : { role: 'user' as const, text: m.content } ), }) }, diff --git a/frontend/src/store.ts b/frontend/src/store.ts index bc53bac45..9961ba53e 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -22,7 +22,6 @@ const chatTransform = createTransform( conversationId: inbound.conversationId, orgId: inbound.orgId, open: inbound.open, - expanded: inbound.expanded, width: inbound.width, poppedOut: inbound.poppedOut, }), From cf944a3152722ada697ae56ddf1a82b380ec9890 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 11:34:45 -0700 Subject: [PATCH 161/238] perf(chat): publish the drag width on breakpoint crossings, not every frame --- frontend/src/components/Chat/ChatPanel.tsx | 34 ++++++++++++++++++++-- frontend/src/components/RemoteHeader.tsx | 6 +++- frontend/src/hooks/useChatEnabled.ts | 13 +++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 605e16e43..a089916d6 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -5,7 +5,14 @@ import { Box, Theme } from '@mui/material' import { State, Dispatch } from '../../store' import { CHAT_PANEL_WIDTH_MIN } from '../../constants' import { radius } from '../../styling' -import { useChatDocked, useChatWidth, useChatMaxWidth, useSidebarWidth } from '../../hooks/useChatEnabled' +import { + useChatDocked, + useChatWidth, + useChatMaxWidth, + useSidebarWidth, + layoutBreakpoints, +} from '../../hooks/useChatEnabled' +import { useViewportWidth } from '../../hooks/useViewportWidth' import { useChatMainSync } from '../../hooks/useChatSync' import { usePanelDrag } from '../../hooks/usePanelDrag' import { PanelHandle } from '../PanelHandle' @@ -30,6 +37,7 @@ export const ChatPanel: React.FC = () => { const chatWidth = useChatWidth() const maxWidth = useChatMaxWidth() const sidebarWidth = useSidebarWidth() + const viewport = useViewportWidth() const panelRef = useRef(null) const dispatch = useDispatch() @@ -42,12 +50,32 @@ export const ChatPanel: React.FC = () => { // width held back until mouseup leaves the content on a stale minWidth that // will not shrink — the column then overflows the window until it snaps. const getMaxWidth = useCallback(() => maxWidth, [maxWidth]) - const setWidth = useCallback((width: number) => dispatch.chat.set({ width }), [dispatch]) + + /* Publishing every pixel put a redux write — and with it a re-render of the whole app + — on every frame of the drag, which measured ~36ms a frame against ~8ms for the + content divider. The column itself is drawn from the drag's own local state, and + the only thing the app wants this width for is its breakpoints, so publish when one + is actually crossed and once more on release. */ + const published = useRef(chatWidth) + const setWidth = useCallback( + (width: number) => { + published.current = width + dispatch.chat.set({ width }) + }, + [dispatch] + ) + const publishIfLayoutChanges = useCallback( + (width: number) => { + if (layoutBreakpoints(viewport - width) === layoutBreakpoints(viewport - published.current)) return + setWidth(width) + }, + [setWidth, viewport] + ) const drag = usePanelDrag(chatWidth, { panelRef, minWidth: CHAT_PANEL_WIDTH_MIN, getMaxWidth, - onChange: setWidth, + onChange: publishIfLayoutChanges, onPersist: setWidth, layoutDep: layout, anchor: 'right', diff --git a/frontend/src/components/RemoteHeader.tsx b/frontend/src/components/RemoteHeader.tsx index fc400500d..2e9b1731c 100644 --- a/frontend/src/components/RemoteHeader.tsx +++ b/frontend/src/components/RemoteHeader.tsx @@ -18,7 +18,11 @@ export const RemoteHeader: React.FC = ({ device, children }) => { frame could actually reach its width. (Web only — Electron always fills its window.) */ const chatWidth = useChatWidth() const appMaxWidth = APP_MAX_WIDTH + (useChatDocked() ? chatWidth : 0) - const maxWidth = !browser.isElectron && useMediaQuery(`(min-width:${appMaxWidth}px)`) + /* The QUERY is the constant, not the widened cap: it only asks "is the window wider + than the app wants to be", which the chat does not change. Interpolating the live + cap rebuilt the MediaQueryList on every frame of a chat drag — a fresh matchMedia + and re-subscribe per frame — for an answer that flips at one threshold. */ + const maxWidth = !browser.isElectron && useMediaQuery(`(min-width:${APP_MAX_WIDTH}px)`) const showFrame = browser.isRemote const [fullscreen, setFullscreen] = useState(false) const fullscreenEnabled = screenfull.isEnabled diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index 888e4b8f2..f9a3ec3cf 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -9,6 +9,9 @@ import { CHAT_PANEL_WIDTH_MIN, CHAT_MIN_CONTENT_WIDTH, HIDE_SIDEBAR_WIDTH, + HIDE_TWO_PANEL_WIDTH, + SHOW_TRIPLE_PANEL_WIDTH, + MOBILE_WIDTH, SIDEBAR_WIDTH, ORGANIZATION_BAR_WIDTH, } from '../constants' @@ -84,3 +87,13 @@ export const useSidebarWidth = (): number => { const hideSidebar = useHideSidebar() return hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0) } + +/* The app reads the chat's width ONLY through these thresholds — the sidebar collapsing, + one panel or two or three, phone size. Nothing downstream wants the pixel value, so a + drag has to publish once per threshold it crosses rather than once per frame. Packed + into one number purely so two widths can be compared for "same layout" in a line. */ +export const layoutBreakpoints = (effectiveWidth: number): number => + (effectiveWidth <= HIDE_SIDEBAR_WIDTH ? 1 : 0) + + (effectiveWidth <= HIDE_TWO_PANEL_WIDTH ? 2 : 0) + + (effectiveWidth >= SHOW_TRIPLE_PANEL_WIDTH ? 4 : 0) + + (effectiveWidth <= MOBILE_WIDTH ? 8 : 0) From f8f3f8e40b44c38c12193bb217c9dfd77528ee05 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 11:44:45 -0700 Subject: [PATCH 162/238] feat(chat): cap the column at the width its transcript can use --- frontend/src/constants.ts | 7 +++++++ frontend/src/hooks/useChatEnabled.ts | 5 ++++- frontend/src/hooks/usePanelDrag.ts | 9 +++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index e64710184..8bb1ecb4b 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -124,6 +124,13 @@ export const CHAT_MIN_CONTENT_WIDTH = 500 // track back to the start of the next line. Applied to the COLUMN, so the panel itself // may be any width without the conversation spreading across it. export const CHAT_MAX_MESSAGE_WIDTH = 800 +/* Widest the column is worth dragging — the point at which the transcript stops growing, + so past it the drag only buys empty panel around a column already at full width. Built + from what actually stands between the two: the 20px gutter either side of the + transcript (ChatMessages' paddingX) and the 8px the docked column insets itself by + (ChatPanel's INSET). Derived rather than written down, because a round number here + lands just short and the transcript never quite reaches its own measure. */ +export const CHAT_PANEL_WIDTH_MAX = CHAT_MAX_MESSAGE_WIDTH + 20 * 2 + 8 export const ORGANIZATION_BAR_WIDTH = 70 export const HIDE_SIDEBAR_WIDTH = 1150 export const HIDE_TWO_PANEL_WIDTH = 750 diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index f9a3ec3cf..dd8422755 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -7,6 +7,7 @@ import { CHAT_ALWAYS_ON, CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN, + CHAT_PANEL_WIDTH_MAX, CHAT_MIN_CONTENT_WIDTH, HIDE_SIDEBAR_WIDTH, HIDE_TWO_PANEL_WIDTH, @@ -32,7 +33,9 @@ export const useChatEnabled = (): boolean => { down to CHAT_PANEL_WIDTH_MIN, rather than the app flipping to a full-screen chat. */ export const useChatMaxWidth = (): number => { const viewport = useViewportWidth() - return Math.max(CHAT_PANEL_WIDTH_MIN, viewport - CHAT_MIN_CONTENT_WIDTH) + // Two ceilings: what the window can spare, and what the transcript can actually use. + // The floor wins over both — a window too small for either still gets a usable column. + return Math.max(CHAT_PANEL_WIDTH_MIN, Math.min(CHAT_PANEL_WIDTH_MAX, viewport - CHAT_MIN_CONTENT_WIDTH)) } /* Width the docked chat column occupies — single source for the fit-check diff --git a/frontend/src/hooks/usePanelDrag.ts b/frontend/src/hooks/usePanelDrag.ts index 7ec5c7204..597c69fcd 100644 --- a/frontend/src/hooks/usePanelDrag.ts +++ b/frontend/src/hooks/usePanelDrag.ts @@ -69,7 +69,7 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) event.preventDefault() window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) - onPersist?.(panelRef.current?.offsetWidth || width) + onPersist?.(handleRef.current) }, [onMove, onPersist, panelRef, width] ) @@ -78,7 +78,12 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) setGrab(true) measure() moveRef.current = event.clientX - handleRef.current = panelRef.current?.offsetWidth || width + /* The accumulator tracks the panel's LOGICAL width, not what it renders as. The + chat column draws itself an inset narrower than the width it is given, so seeding + (and persisting) from offsetWidth quietly shaved that inset off every drag — the + column settled a margin short of its own ceiling and never reached the reading + measure the ceiling exists to provide. */ + handleRef.current = width event.preventDefault() window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) From 1d4d0286d874e4967c8010bacd581c1e5f3e0cef Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 11:49:07 -0700 Subject: [PATCH 163/238] fix(chat): keep the history delete visible where there is no hover --- frontend/src/components/Chat/ChatHeader.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 75c29d6fe..d70187f5a 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -193,7 +193,15 @@ export const HistoryButton: React.FC = () => { dense selected={c.id === currentId} sx={{ - '& .remove': { opacity: 0, transition: 'opacity 100ms' }, + /* Hide-until-hover only where something can do the hovering. A touch + screen has no hover state to enter, so the row would offer no way to + delete at all — there the X just stays visible. Asked as a capability + rather than off browser.isMobile, which only knows the native builds: + an iPad on the web or a touch laptop needs the same treatment and is + not "mobile" by that measure. */ + '@media (hover: hover)': { + '& .remove': { opacity: 0, transition: 'opacity 100ms' }, + }, // Hover, or the button's own KEYBOARD focus. Not :focus-within — MUI // focuses the selected item when the menu opens, which would pin the X // on the active row. :focus-visible keeps it reachable by tab without From 2375180a9d8d3e6e090891b75a9f37ab5110dd93 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 12:27:03 -0700 Subject: [PATCH 164/238] fix(layout): measure MobileUI against the app's breakpoint, not the raw window --- frontend/src/components/MobileUI.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/MobileUI.tsx b/frontend/src/components/MobileUI.tsx index 29847e027..a664a817f 100644 --- a/frontend/src/components/MobileUI.tsx +++ b/frontend/src/components/MobileUI.tsx @@ -1,7 +1,7 @@ import React from 'react' import browser from '../services/browser' -import { useMediaQuery } from '@mui/material' -import { MOBILE_WIDTH } from '../constants' +import { useSelector } from 'react-redux' +import { State } from '../store' type Props = { ios?: boolean @@ -11,7 +11,17 @@ type Props = { } export const MobileUI: React.FC = ({ ios, android, hide, children }) => { - let mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + /* The app's own mobile breakpoint rather than a fresh media query on the window. + Every other breakpoint measures the width left AFTER the docked chat column, so + asking the raw window made this the one place that could disagree: with the chat + docked wide the layout goes mobile — bottom menu, single panel, sidebar collapsed — + while this still served the desktop arrangement. Reading the published value also + drops a matchMedia subscription per instance, and re-renders only when the boolean + actually flips. + + `ios`/`android` stay platform questions (which native build am I?), which is a + different thing from how much room there is. */ + let mobile = useSelector((state: State) => state.ui.layout.mobile) if (android) mobile = mobile && browser.isAndroid if (ios) mobile = mobile && browser.isIOS From 5572603b1ef4d62f2d71b160b31df872f225f60a Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 12:53:41 -0700 Subject: [PATCH 165/238] fix(layout): measure the mobile breakpoint against the app, not the window --- frontend/src/components/CustomerList.tsx | 6 +++--- frontend/src/components/DeviceList.tsx | 7 ++++--- frontend/src/components/DevicesSelectBar.tsx | 6 +++--- frontend/src/components/FileList.tsx | 5 ++--- frontend/src/components/Header/Header.tsx | 7 ++++--- frontend/src/components/JobList.tsx | 5 ++--- frontend/src/components/ListHorizontal.tsx | 6 +++--- frontend/src/components/MobileUI.tsx | 5 ++--- frontend/src/components/OrganizationMember.tsx | 6 +++--- frontend/src/components/OrganizationSelect.tsx | 7 ++++--- frontend/src/components/PlanCard.tsx | 7 +++---- frontend/src/components/ProductList.tsx | 6 +++--- frontend/src/components/ProductsActionBar.tsx | 6 +++--- frontend/src/components/ScriptingActionBar.tsx | 6 +++--- frontend/src/components/ScriptingHeader.tsx | 6 +++--- frontend/src/components/ServiceList.tsx | 6 +++--- frontend/src/components/SidebarNav.tsx | 15 +++------------ frontend/src/hooks/useMobile.ts | 16 ++++++++++++++++ 18 files changed, 67 insertions(+), 61 deletions(-) create mode 100644 frontend/src/hooks/useMobile.ts diff --git a/frontend/src/components/CustomerList.tsx b/frontend/src/components/CustomerList.tsx index 63d0a45c2..35c51cf9c 100644 --- a/frontend/src/components/CustomerList.tsx +++ b/frontend/src/components/CustomerList.tsx @@ -1,10 +1,10 @@ import React from 'react' +import { useMobile } from '../hooks/useMobile' import { State } from '../store' import { useSelector } from 'react-redux' import { customerAttributes } from './CustomerAttributes' -import { MOBILE_WIDTH } from '../constants' import { removeObject } from '../helpers/utilHelper' -import { useMediaQuery, Stack, Typography } from '@mui/material' +import { Stack, Typography } from '@mui/material' import { CustomerListItem } from './CustomerListItem' import { GridList } from './GridList' import { Avatar } from './Avatar' @@ -16,7 +16,7 @@ export interface CustomerListProps { } export const CustomerList: React.FC = ({ customers = [], disabled }) => { - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const columnWidths = useSelector((state: State) => state.ui.columnWidths) const [required, attributes] = removeObject(customerAttributes, a => a.required === true) diff --git a/frontend/src/components/DeviceList.tsx b/frontend/src/components/DeviceList.tsx index a06202e31..49f7ccf87 100644 --- a/frontend/src/components/DeviceList.tsx +++ b/frontend/src/components/DeviceList.tsx @@ -1,13 +1,14 @@ import React, { useCallback, useMemo } from 'react' +import { useMobile } from '../hooks/useMobile' import { useTranslation } from 'react-i18next' import browser from '../services/browser' import { useLocation } from 'react-router-dom' -import { MOBILE_WIDTH, GUIDE_START_DATE } from '../constants' +import { GUIDE_START_DATE } from '../constants' import { DeviceListContext } from '../services/Context' import { Dispatch } from '../store' import { useDispatch } from 'react-redux' import { DeviceListHeaderCheckbox } from './DeviceListHeaderCheckbox' -import { Typography, useMediaQuery } from '@mui/material' +import { Typography } from '@mui/material' import { DeviceListItem } from './DeviceListItem' import { Attribute } from './Attributes' import { isOffline } from '../models/devices' @@ -117,7 +118,7 @@ export const DeviceList: React.FC = ({ select, }) => { const location = useLocation() - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const dispatch = useDispatch() const onFirstClick = useCallback(() => dispatch.ui.pop('deviceList'), [dispatch]) const isScriptsPath = location.pathname.includes('scripts') diff --git a/frontend/src/components/DevicesSelectBar.tsx b/frontend/src/components/DevicesSelectBar.tsx index 756bb820a..f1744fe68 100644 --- a/frontend/src/components/DevicesSelectBar.tsx +++ b/frontend/src/components/DevicesSelectBar.tsx @@ -1,7 +1,7 @@ import React from 'react' +import { useMobile } from '../hooks/useMobile' import { State } from '../store' -import { MOBILE_WIDTH } from '../constants' -import { useMediaQuery, Stack, Typography, Button } from '@mui/material' +import { Stack, Typography, Button } from '@mui/material' import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { radius } from '../styling' @@ -9,7 +9,7 @@ import { Icon } from './Icon' export const DevicesSelectBar: React.FC = () => { const selected = useSelector((state: State) => state.ui.selected) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const history = useHistory() return ( diff --git a/frontend/src/components/FileList.tsx b/frontend/src/components/FileList.tsx index db138989b..e8e47ddf6 100644 --- a/frontend/src/components/FileList.tsx +++ b/frontend/src/components/FileList.tsx @@ -1,9 +1,8 @@ import React from 'react' +import { useMobile } from '../hooks/useMobile' import { State } from '../store' import { useParams } from 'react-router-dom' import { useSelector } from 'react-redux' -import { MOBILE_WIDTH } from '../constants' -import { useMediaQuery } from '@mui/material' import { FileListItem } from './FileListItem' import { Attribute } from './Attributes' import { GridList } from './GridList' @@ -20,7 +19,7 @@ interface FileListProps { export const FileList: React.FC = ({ attributes, required, scripts = [], columnWidths, fetching, isScriptList = true }) => { const { fileID } = useParams<{ fileID?: string }>() const selectedIds = useSelector((state: State) => state.ui.selected) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() return ( {scripts?.map((script, index) => ( diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 43158e244..c58eabab3 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,4 +1,5 @@ -import { REGEX_FIRST_PATH, MOBILE_WIDTH, CHAT_GUIDE_DATE } from '../../constants' +import { REGEX_FIRST_PATH, CHAT_GUIDE_DATE } from '../../constants' +import { useMobile } from '../../hooks/useMobile' import { useChatEnabled, useHideSidebar } from '../../hooks/useChatEnabled' import { GuideBubble } from '../GuideBubble' import React, { useState, useRef } from 'react' @@ -7,7 +8,7 @@ import useNavigationUp from '../../hooks/useNavigationUp' import browser from '../../services/browser' import { State } from '../../store' import { Dispatch } from '../../store' -import { useMediaQuery, Typography } from '@mui/material' +import { Typography } from '@mui/material' import { selectDeviceModelAttributes } from '../../selectors/devices' import { selectPermissions } from '../../selectors/organizations' import { useLocation, Switch, Route } from 'react-router-dom' @@ -40,7 +41,7 @@ export const Header: React.FC = ({ panels = 1 }) => { const navigateUp = useNavigationUp(panels) const [showSearch, setShowSearch] = useState(false) const sidebarHidden = useHideSidebar() - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const inputRef = useRef(null) const dispatch = useDispatch() const location = useLocation() diff --git a/frontend/src/components/JobList.tsx b/frontend/src/components/JobList.tsx index 3fd651c92..e0fe37381 100644 --- a/frontend/src/components/JobList.tsx +++ b/frontend/src/components/JobList.tsx @@ -1,6 +1,5 @@ import React from 'react' -import { MOBILE_WIDTH } from '../constants' -import { useMediaQuery } from '@mui/material' +import { useMobile } from '../hooks/useMobile' import { JobListItem } from './JobListItem' import { JobLoadMore } from './LoadMore' import { Attribute } from './Attributes' @@ -29,7 +28,7 @@ export const JobList: React.FC = ({ loadMore, jobOnlyRoute, }) => { - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() return ( {jobs?.map((job, index) => ( diff --git a/frontend/src/components/ListHorizontal.tsx b/frontend/src/components/ListHorizontal.tsx index b3ecf05ae..7d0bab335 100644 --- a/frontend/src/components/ListHorizontal.tsx +++ b/frontend/src/components/ListHorizontal.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { MOBILE_WIDTH } from '../constants' -import { List, ListProps, useMediaQuery } from '@mui/material' +import { useMobile } from '../hooks/useMobile' +import { List, ListProps } from '@mui/material' import { spacing, toSxArray } from '../styling' type Props = ListProps & { @@ -9,7 +9,7 @@ type Props = ListProps & { } export const ListHorizontal: React.FC = ({ size = 'large', hideIcons, children, sx, ...props }) => { - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const small = size === 'small' return ( = ({ ios, android, hide, children }) => { `ios`/`android` stay platform questions (which native build am I?), which is a different thing from how much room there is. */ - let mobile = useSelector((state: State) => state.ui.layout.mobile) + let mobile = useMobile() if (android) mobile = mobile && browser.isAndroid if (ios) mobile = mobile && browser.isIOS diff --git a/frontend/src/components/OrganizationMember.tsx b/frontend/src/components/OrganizationMember.tsx index 28f3a3a1b..de246be1d 100644 --- a/frontend/src/components/OrganizationMember.tsx +++ b/frontend/src/components/OrganizationMember.tsx @@ -1,8 +1,8 @@ import React from 'react' +import { useMobile } from '../hooks/useMobile' import { Dispatch } from '../store' import { useDispatch } from 'react-redux' -import { MOBILE_WIDTH } from '../constants' -import { Box, useMediaQuery, ListItemSecondaryAction } from '@mui/material' +import { Box, ListItemSecondaryAction } from '@mui/material' import { ListItemLocation } from './ListItemLocation' import { LicenseSelect } from './LicenseSelect' import { RoleSelect } from './RoleSelect' @@ -19,7 +19,7 @@ type Props = { } export const OrganizationMember: React.FC = ({ member, roles = [], disabled, enterprise, link = true }) => { - const hideActions = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const hideActions = useMobile() const dispatch = useDispatch() return ( { const { t } = useTranslation() const history = useHistory() const location = useLocation() - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const { accounts, devices, files, tags, networks, logs, products, partnerStats } = useDispatch() let activeOrg = useSelector(selectOrganization) diff --git a/frontend/src/components/PlanCard.tsx b/frontend/src/components/PlanCard.tsx index e0967dc44..c9607d11a 100644 --- a/frontend/src/components/PlanCard.tsx +++ b/frontend/src/components/PlanCard.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { MOBILE_WIDTH } from '../constants' -import { useMediaQuery, Typography, List, ListItem, ListItemIcon, Divider, Button, Box } from '@mui/material' +import { useMobile } from '../hooks/useMobile' +import { Typography, List, ListItem, ListItemIcon, Divider, Button, Box } from '@mui/material' import { spacing, fontSizes, radius } from '../styling' import { Icon } from './Icon' @@ -20,7 +20,6 @@ type Props = { onSelect?: () => void } - export const PlanCard: React.FC = ({ name, description, @@ -36,7 +35,7 @@ export const PlanCard: React.FC = ({ wide, onSelect, }) => { - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() wide = wide && !mobile return ( diff --git a/frontend/src/components/ProductList.tsx b/frontend/src/components/ProductList.tsx index f62cf596a..8b5efa7a0 100644 --- a/frontend/src/components/ProductList.tsx +++ b/frontend/src/components/ProductList.tsx @@ -1,6 +1,6 @@ -import { Checkbox,useMediaQuery } from '@mui/material' +import { Checkbox } from '@mui/material' +import { useMobile } from '../hooks/useMobile' import React from 'react' -import { MOBILE_WIDTH } from '../constants' import { IDeviceProduct } from '../models/products' import { GridList } from './GridList' import { Icon } from './Icon' @@ -32,7 +32,7 @@ export const ProductList: React.FC = ({ onSelect, onSelectAll, }) => { - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const allSelected = products.length > 0 && selected.length === products.length const someSelected = selected.length > 0 && selected.length < products.length diff --git a/frontend/src/components/ProductsActionBar.tsx b/frontend/src/components/ProductsActionBar.tsx index a416948eb..6eb57e0eb 100644 --- a/frontend/src/components/ProductsActionBar.tsx +++ b/frontend/src/components/ProductsActionBar.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' -import { MOBILE_WIDTH } from '../constants' -import { useMediaQuery, Box, Typography, Collapse } from '@mui/material' +import { useMobile } from '../hooks/useMobile' +import { Box, Typography, Collapse } from '@mui/material' import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -22,7 +22,7 @@ export const ProductsActionBar: React.FC = ({ select }) => { const selected = useSelector(getProductsSelected) const admin = useSelector(selectPermissions).includes('ADMIN') const [deleting, setDeleting] = useState(false) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const history = useHistory() const { t } = useTranslation() diff --git a/frontend/src/components/ScriptingActionBar.tsx b/frontend/src/components/ScriptingActionBar.tsx index d86b91c2b..6b018c9d3 100644 --- a/frontend/src/components/ScriptingActionBar.tsx +++ b/frontend/src/components/ScriptingActionBar.tsx @@ -1,10 +1,10 @@ -import { Stack,Typography,useMediaQuery } from '@mui/material' +import { Stack, Typography } from '@mui/material' +import { useMobile } from '../hooks/useMobile' import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch,useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { IconButton } from '../buttons/IconButton' -import { MOBILE_WIDTH } from '../constants' import { Dispatch,State } from '../store' import { radius } from '../styling' import { Icon } from './Icon' @@ -13,7 +13,7 @@ export const ScriptingActionBar: React.FC = () => { const { t } = useTranslation() const selected = useSelector((state: State) => state.ui.selected) const scriptForm = useSelector((state: State) => state.ui.scriptForm) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const dispatch = useDispatch() const history = useHistory() diff --git a/frontend/src/components/ScriptingHeader.tsx b/frontend/src/components/ScriptingHeader.tsx index 1d99e0643..375e2dcda 100644 --- a/frontend/src/components/ScriptingHeader.tsx +++ b/frontend/src/components/ScriptingHeader.tsx @@ -1,10 +1,10 @@ -import { Button, Stack, Tooltip, useMediaQuery } from '@mui/material' +import { Button, Stack, Tooltip } from '@mui/material' +import { useMobile } from '../hooks/useMobile' import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { Route, Link as RouteLink, useHistory, useLocation } from 'react-router-dom' import { IconButton } from '../buttons/IconButton' -import { MOBILE_WIDTH } from '../constants' import { selectPermissions } from '../selectors/organizations' import { ScriptingActionBar } from './ScriptingActionBar' import { ScriptingTabBar } from './ScriptingTabBar' @@ -25,7 +25,7 @@ export const ScriptingHeader: React.FC = ({ children }) => { const location = useLocation() const permissions = useSelector(selectPermissions) const selectedIds = useSelector((state: State) => state.ui.selected) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() return ( = ({ const { sortService } = getSortOptions(useSelector(selectDeviceModelAttributes).sortServiceOption) const location = useLocation() const previousName = useRef('') - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const rows = devices.reduce((row, device) => { const hasFilter = applicationTypes?.length diff --git a/frontend/src/components/SidebarNav.tsx b/frontend/src/components/SidebarNav.tsx index 6d746549a..5470fb4ab 100644 --- a/frontend/src/components/SidebarNav.tsx +++ b/frontend/src/components/SidebarNav.tsx @@ -1,21 +1,12 @@ import React from 'react' +import { useMobile } from '../hooks/useMobile' import browser from '../services/browser' import { useTranslation } from 'react-i18next' -import { MOBILE_WIDTH } from '../constants' import { selectLimitsLookup } from '../selectors/organizations' import { selectDefaultSelectedPage } from '../selectors/ui' import { useSelector, useDispatch } from 'react-redux' import { State, Dispatch } from '../store' -import { - Box, - Badge, - List, - Divider, - Tooltip, - Chip, - Theme, - useMediaQuery, -} from '@mui/material' +import { Box, Badge, List, Divider, Tooltip, Chip, Theme } from '@mui/material' import { ListItemLocation } from './ListItemLocation' import { UpgradeBanner } from './UpgradeBanner' import { ResellerLogo } from './ResellerLogo' @@ -49,7 +40,7 @@ export const SidebarNav: React.FC = () => { const limits = useSelector(selectLimitsLookup) const insets = useSelector((state: State) => state.ui.layout.insets) const rootPaths = useSelector((state: State) => !browser.isElectron && state.ui.layout.hideSidebar) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const mobile = useMobile() const dispatch = useDispatch() const pathname = path => (rootPaths ? path : defaultSelectedPage[path] || path) const { t } = useTranslation() diff --git a/frontend/src/hooks/useMobile.ts b/frontend/src/hooks/useMobile.ts new file mode 100644 index 000000000..59ef9f2d3 --- /dev/null +++ b/frontend/src/hooks/useMobile.ts @@ -0,0 +1,16 @@ +import { useSelector } from 'react-redux' +import { State } from '../store' + +/* Is the app in its mobile layout? + + The width the APP has — the window minus the docked chat column — not the window + itself. Asking the window directly is what let a component serve desktop density into + a mobile-width area whenever the chat was open: the layout had already switched, and + the component had not heard about it. Reading the published value keeps every + consumer on the one answer, and re-renders only when the boolean flips. + + For anything INSIDE a panel, useContainerWidth is truer still — a list squeezed into + a narrow panel is cramped even when the app overall is not mobile (see + DevicesActionBar). Use this for chrome measured against the app area, and that when + the element's own width is what actually matters. */ +export const useMobile = (): boolean => useSelector((state: State) => state.ui.layout.mobile) From d1c061cd11af60f4dd139e5e6c078863bfb27304 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 13:00:58 -0700 Subject: [PATCH 166/238] fix(lists): size the device and service lists from their panel, not the app --- frontend/src/components/DeviceList.tsx | 75 ++++++++++++++----------- frontend/src/components/ServiceList.tsx | 63 ++++++++++++--------- 2 files changed, 80 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/DeviceList.tsx b/frontend/src/components/DeviceList.tsx index 49f7ccf87..f314c2e48 100644 --- a/frontend/src/components/DeviceList.tsx +++ b/frontend/src/components/DeviceList.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo } from 'react' -import { useMobile } from '../hooks/useMobile' +import { useContainerWidth } from '../hooks/useContainerWidth' +import { MOBILE_WIDTH } from '../constants' import { useTranslation } from 'react-i18next' import browser from '../services/browser' import { useLocation } from 'react-router-dom' @@ -8,7 +9,7 @@ import { DeviceListContext } from '../services/Context' import { Dispatch } from '../store' import { useDispatch } from 'react-redux' import { DeviceListHeaderCheckbox } from './DeviceListHeaderCheckbox' -import { Typography } from '@mui/material' +import { Box, Typography } from '@mui/material' import { DeviceListItem } from './DeviceListItem' import { Attribute } from './Attributes' import { isOffline } from '../models/devices' @@ -118,40 +119,50 @@ export const DeviceList: React.FC = ({ select, }) => { const location = useLocation() - const mobile = useMobile() + const { containerRef, containerWidth } = useContainerWidth() + // The panel this list sits in, not the whole app: a list squeezed into a narrow panel + // is cramped even when the app overall is nowhere near mobile. + const mobile = containerWidth < MOBILE_WIDTH const dispatch = useDispatch() const onFirstClick = useCallback(() => dispatch.ui.pop('deviceList'), [dispatch]) const isScriptsPath = location.pathname.includes('scripts') + /* Measured wrapper: `width: 100%` resolves against the scroll container, so it reports + the space this list ACTUALLY has. The GridList inside is deliberately wider than the + container when columns overflow — that IS the horizontal scroll — so measuring the + list itself would report content width instead. Overflow still reaches the scroll + container, and Body's first-child rule now applies to this box just the same. */ return ( - } - headerContextData={{ device: devices[0] }} - headerContextProvider={DeviceListContext.Provider} - > - {devices?.map((device, index) => { - const canRestore = isOffline(device) && !device.shared - if (restore && !canRestore) return null - const disabled = select && !device.scriptable && isScriptsPath - return ( - - ) - })} - - + + } + headerContextData={{ device: devices[0] }} + headerContextProvider={DeviceListContext.Provider} + > + {devices?.map((device, index) => { + const canRestore = isOffline(device) && !device.shared + if (restore && !canRestore) return null + const disabled = select && !device.scriptable && isScriptsPath + return ( + + ) + })} + + + ) } diff --git a/frontend/src/components/ServiceList.tsx b/frontend/src/components/ServiceList.tsx index 4b9769aea..4e792bcbb 100644 --- a/frontend/src/components/ServiceList.tsx +++ b/frontend/src/components/ServiceList.tsx @@ -1,11 +1,12 @@ import React, { useRef } from 'react' -import { useMobile } from '../hooks/useMobile' +import { useContainerWidth } from '../hooks/useContainerWidth' +import { MOBILE_WIDTH } from '../constants' import { useLocation } from 'react-router-dom' import { useSelector } from 'react-redux' import { getSortOptions } from './SortServices' import { selectDeviceModelAttributes } from '../selectors/devices' import { DeviceListHeaderCheckbox } from './DeviceListHeaderCheckbox' -import { Divider } from '@mui/material' +import { Box, Divider } from '@mui/material' import { DeviceListContext } from '../services/Context' import { DeviceListItem } from './DeviceListItem' import { Attribute } from './Attributes' @@ -39,7 +40,10 @@ export const ServiceList: React.FC = ({ const { sortService } = getSortOptions(useSelector(selectDeviceModelAttributes).sortServiceOption) const location = useLocation() const previousName = useRef('') - const mobile = useMobile() + const { containerRef, containerWidth } = useContainerWidth() + // The panel this list sits in, not the whole app: a list squeezed into a narrow panel + // is cramped even when the app overall is nowhere near mobile. + const mobile = containerWidth < MOBILE_WIDTH const rows = devices.reduce((row, device) => { const hasFilter = applicationTypes?.length @@ -54,29 +58,36 @@ export const ServiceList: React.FC = ({ // Reset previous name when the list changes previousName.current = '' + /* Measured wrapper: `width: 100%` resolves against the scroll container, so it reports + the space this list ACTUALLY has. The GridList inside is deliberately wider than the + container when columns overflow — that IS the horizontal scroll — so measuring the + list itself would report content width instead. Overflow still reaches the scroll + container, and Body's first-child rule now applies to this box just the same. */ return ( - } - headerContextData={{ device: devices[0], service: devices[0].services[0] }} - headerContextProvider={DeviceListContext.Provider} - > - {rows.map(([service, device]) => { - const disabled = select && !device.scriptable && location.pathname.includes('scripts') - const duplicateName = device.id === previousName.current - const divider = !duplicateName && !!previousName.current - previousName.current = device.id - return ( - - {divider && } - - - ) - })} - - + + } + headerContextData={{ device: devices[0], service: devices[0].services[0] }} + headerContextProvider={DeviceListContext.Provider} + > + {rows.map(([service, device]) => { + const disabled = select && !device.scriptable && location.pathname.includes('scripts') + const duplicateName = device.id === previousName.current + const divider = !duplicateName && !!previousName.current + previousName.current = device.id + return ( + + {divider && } + + + ) + })} + + + ) } From 62eef014012aff3ae6ce6b64caab4879e86b16aa Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 13:09:13 -0700 Subject: [PATCH 167/238] copy(chat): swap the restart prompt for adding a device --- frontend/src/components/Chat/ChatIntro.tsx | 2 +- frontend/src/i18n/locales/de/app.json | 2 +- frontend/src/i18n/locales/en/app.json | 2 +- frontend/src/i18n/locales/es/app.json | 2 +- frontend/src/i18n/locales/ja/app.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx index a7caf222e..43942b666 100644 --- a/frontend/src/components/Chat/ChatIntro.tsx +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -38,7 +38,7 @@ export const ChatIntro: React.FC = () => { : [ t('chat.prompt1', 'Which of my devices are offline?'), t('chat.prompt2', 'Show my recent connections'), - t('chat.prompt3', 'Restart a service on one of my devices'), + t('chat.prompt3', 'Help me add a new device'), ] return ( diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 75389ca6c..48f43cced 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -314,7 +314,7 @@ "popOut": "In eigenem Fenster öffnen", "prompt1": "Welche meiner Geräte sind offline?", "prompt2": "Meine letzten Verbindungen anzeigen", - "prompt3": "Einen Dienst auf einem meiner Geräte neu starten", + "prompt3": "Hilfe beim Hinzufügen eines neuen Geräts", "send": "Senden", "signIn": "Mit remote.it anmelden", "signInFromMain": "Melden Sie sich im Hauptfenster der App an.", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 76fbbac15..1c0c0cb4e 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -319,7 +319,7 @@ "introBody": "Manage your devices, connections, and services — just ask.", "prompt1": "Which of my devices are offline?", "prompt2": "Show my recent connections", - "prompt3": "Restart a service on one of my devices", + "prompt3": "Help me add a new device", "popIn": "Pop back in", "popOut": "Pop out", "send": "Send", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index b5c7c28f4..b3f1023f8 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -317,7 +317,7 @@ "popOut": "Abrir en ventana propia", "prompt1": "¿Qué dispositivos míos están desconectados?", "prompt2": "Mostrar mis conexiones recientes", - "prompt3": "Reiniciar un servicio en uno de mis dispositivos", + "prompt3": "Ayuda para añadir un dispositivo nuevo", "send": "Enviar", "signIn": "Iniciar sesión con remote.it", "signInFromMain": "Inicie sesión desde la ventana principal de la aplicación.", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index f16ae1fa0..56debfa3a 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -311,7 +311,7 @@ "popOut": "別ウィンドウで開く", "prompt1": "オフラインのデバイスは?", "prompt2": "最近の接続を表示", - "prompt3": "デバイスのサービスを再起動", + "prompt3": "新しいデバイスの追加を手伝って", "send": "送信", "signIn": "remote.it でサインイン", "signInFromMain": "メインのアプリウィンドウからサインインしてください。", From dc50f89cdc6663184a4d0f2c102f09b87d0ecde6 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 14:27:59 -0700 Subject: [PATCH 168/238] feat(chat): gate the AI chat on an 'ai-agent' license feature --- frontend/src/constants.ts | 24 ++++++++++- frontend/src/hooks/useChatEnabled.ts | 21 ++++++--- frontend/src/i18n/locales/de/app.json | 3 ++ frontend/src/i18n/locales/en/app.json | 3 ++ frontend/src/i18n/locales/es/app.json | 3 ++ frontend/src/i18n/locales/ja/app.json | 3 ++ frontend/src/pages/TestPage.tsx | 57 ++++++++++++++----------- frontend/src/selectors/organizations.ts | 34 +++++++++++++-- 8 files changed, 111 insertions(+), 37 deletions(-) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 8bb1ecb4b..1230b7454 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -3,9 +3,29 @@ const env = import.meta.env export const MODE = env.MODE || 'development' // The AI portal (app.ai.remote.it) shows the Remote.It AI chat unconditionally: it IS the -// AI surface. Set per-deployment via the Amplify branch env so the general app keeps the -// soft-launch (Test UI) even if this branch's code merges elsewhere. +// AI surface. Set per-deployment via the Amplify branch env so the general app stays on +// the licensed gate below even if this branch's code merges elsewhere. export const CHAT_ALWAYS_ON = env.VITE_CHAT_ALWAYS_ON === 'true' + +/* The license limit that gates the Remote.It AI chat. The whole surface hangs off this + one name — the header button, the docked column and everything the panel loads — so + switching the feature on for an account is a licensing change rather than a release. */ +export const CHAT_FEATURE = 'ai-agent' + +/* Boolean license features this build gates on that an account's license may not carry + yet. Naming one here gives it a row on the Test page AND puts it in the limits lookup, + which is what makes it testable at all: the lookup is built FROM the limits the API + returned, so a flag the API has never mentioned has nothing for an override to attach + to. The API's value always wins once it starts arriving, so an entry whose limit has + gone live everywhere is dead weight and can be deleted. */ +export const PENDING_FEATURES: string[] = [CHAT_FEATURE] + +/* What a forward-declared flag is worth before any license mentions it: ON in local dev, + because you are building the feature, and OFF in every deployed build. Kept as the + flag's VALUE rather than as a bypass around the gate, so the Test page switch always + reads what is actually in effect and one click still turns the feature off in dev. */ +export const PENDING_FEATURE_DEFAULT = MODE === 'development' + // Renderer-owned OIDC (permitteer docs/remoteit-desktop-login.md, D8) — identical on // web and desktop; the backend never touches auth. export const OAUTH_ISSUER = env.VITE_OAUTH_ISSUER || '' diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index dd8422755..11ce373e2 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -1,10 +1,11 @@ import { useSelector } from 'react-redux' import { State } from '../store' +import { selectLimitsLookup } from '../selectors/organizations' import browser from '../services/browser' import { useViewportWidth } from './useViewportWidth' import { - MODE, CHAT_ALWAYS_ON, + CHAT_FEATURE, CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN, CHAT_PANEL_WIDTH_MAX, @@ -17,12 +18,20 @@ import { ORGANIZATION_BAR_WIDTH, } from '../constants' -/* The Remote.It AI chat is always on in local dev builds and on the dedicated AI portal - (CHAT_ALWAYS_ON, set for app.ai.remote.it); in every other deployed build it soft-launches - behind the hidden Test UI (shift+option on the avatar menu → Test UI). */ +/* Whether the Remote.It AI chat exists for this user at all — the single gate the header + button, the docked column and everything the panel loads hang off. Nothing chat-related + mounts without it, so a user without the feature makes no agent requests. + + It is a LICENSE feature, read exactly the way tagging/saml/roles are, which means it + follows the ACCOUNT you are viewing: the chat is scoped to the organization in the + sidebar selector, so an org whose license does not carry the agent does not get one. + One build skips the license: app.ai.remote.it (CHAT_ALWAYS_ON) IS the AI surface. Local + dev is NOT an exception — the flag simply defaults on there (PENDING_FEATURE_DEFAULT), + so the gate itself is what you toggle rather than something you have to work around. + Until the API carries the limit, Test Settings → Features is how to turn it on. */ export const useChatEnabled = (): boolean => { - const testUI = useSelector((state: State) => state.ui.testUI) - return MODE === 'development' || CHAT_ALWAYS_ON || !!testUI + const licensed = useSelector((state: State) => !!selectLimitsLookup(state)[CHAT_FEATURE]) + return CHAT_ALWAYS_ON || licensed } /* The widest the chat column may be dragged: whatever the window holds once the diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 48f43cced..572a4f739 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2224,7 +2224,10 @@ "customWebSocketURL": "WebSocket-URL", "disableTestUI": "Test-UI deaktivieren", "disableTestUIHint": "Um die Alpha-UI wieder zu aktivieren, müssen Sie das Avatar-Menü bei gedrückter Alt-Umschalt-Taste auswählen.", + "disabled": "deaktiviert", + "enabled": "aktiviert", "featureLabel": "{{name}} (Standard {{state}})", + "featurePending": "Noch in keiner Lizenz enthalten – nur über diesen Schalter aktivierbar.", "features": "Funktionen", "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 1c0c0cb4e..c9baebc6a 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2224,7 +2224,10 @@ "customWebSocketURL": "WebSocket URL", "disableTestUI": "Disable Test UI", "disableTestUIHint": "To re-enable the alpha UI you will have to select the Avatar menu while holding alt-shift.", + "disabled": "disabled", + "enabled": "enabled", "featureLabel": "{{name}} (default {{state}})", + "featurePending": "Not carried by any license yet — this switch is the only way to turn it on.", "features": "Features", "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index b3f1023f8..0ad918d81 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2260,7 +2260,10 @@ "customWebSocketURL": "URL de WebSocket", "disableTestUI": "Deshabilitar la interfaz de prueba", "disableTestUIHint": "Para volver a habilitar la interfaz alfa, debes seleccionar el menú de avatar mientras mantienes presionado alt-shift.", + "disabled": "desactivado", + "enabled": "activado", "featureLabel": "{{name}} (predeterminado {{state}})", + "featurePending": "Todavía no está incluida en ninguna licencia; este interruptor es la única forma de activarla.", "features": "Funciones", "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 56debfa3a..b84b788d7 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2188,7 +2188,10 @@ "customWebSocketURL": "WebSocket URL", "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", + "disabled": "無効", + "enabled": "有効", "featureLabel": "{{name}}(デフォルト: {{state}})", + "featurePending": "まだどのライセンスにも含まれていません。このスイッチでのみ有効にできます。", "features": "機能", "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index c33497fe2..0cd1dc960 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -6,7 +6,7 @@ import { Dispatch, State } from '../store' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL } from '../helpers/apiHelper' import { oidcAccessToken, oidcMintError } from '../services/oidc' -import { selectLimitsLookup, selectLimits } from '../selectors/organizations' +import { selectLimitsLookup, selectFeatures } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { bindableResources } from '../services/permitteerAccount' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' @@ -26,8 +26,11 @@ export const TestPage: React.FC = () => { const apis = useSelector((state: State) => state.ui.apis) const testUI = useSelector((state: State) => state.ui.testUI) const preferences = useSelector((state: State) => state.backend.preferences) - const limitsOverride = useSelector(selectLimitsLookup) - const limits = useSelector(selectLimits) + // What is in EFFECT (the license, with any override on top) versus the overrides + // themselves: the switch reads the first and writes the second. + const featureValues = useSelector(selectLimitsLookup) + const features = useSelector(selectFeatures) + const overrides = useSelector((state: State) => state.ui.limitsOverride) /* The backend replaces its WHOLE preferences object with what we send, and our copy starts out as `{ version: '', cliVersion: '' }` until the backend pushes its own @@ -336,28 +339,32 @@ export const TestPage: React.FC = () => { {t('testPage.features', 'Features')} - {limits.map(l => { - if (typeof l.value === 'boolean') - return ( - - dispatch.ui.setPersistent({ - limitsOverride: { ...limitsOverride, [l.name]: !limitsOverride[l.name] }, - }) - } - /> - ) - })} + {features.map(f => ( + + dispatch.ui.setPersistent({ limitsOverride: { ...overrides, [f.name]: !featureValues[f.name] } }) + } + /> + ))} limits.find(limit => limit.name === limitName) ) +/* Every feature gate in the app reads this: the account's licensed limits, with the + Test page's overrides applied on top. Overrides are a PERSONAL-account tool — an + organization's real entitlements are never faked, so what you see on an org is what + its license actually grants. */ export const selectLimitsLookup = createSelector( [selectLimits, isUserAccount, getLimitsOverride], (baseLimits, isUserAccount, limitsOverride): ILookup => { - let result: ILookup = {} - baseLimits.forEach(l => { - result[l.name] = limitsOverride[l.name] === undefined || !isUserAccount ? l.value : limitsOverride[l.name] - }) + const result: ILookup = {} + // Flags this build knows about but no license carries yet: worth their dev default + // until the API says otherwise, and — being named — something an override can take a + // position on, which a name the lookup has never seen would not be. + PENDING_FEATURES.forEach(name => (result[name] = PENDING_FEATURE_DEFAULT)) + baseLimits.forEach(l => (result[l.name] = l.value)) + if (isUserAccount) + Object.keys(result).forEach(name => { + if (limitsOverride[name] !== undefined) result[name] = limitsOverride[name] + }) return result } ) +export type IFeature = { name: string; value: boolean; pending?: boolean } + +/* The boolean features the Test page lists: the ones this account's license mentions, + plus the ones this build forward-declares. `pending` is the difference between "the + license said no" and "no license has mentioned it yet" — the second is a flag still + soft-launching, where the Test page switch is the only way to see the feature. */ +export const selectFeatures = createSelector([selectLimits], (limits): IFeature[] => { + const features: IFeature[] = limits + .filter(l => typeof l.value === 'boolean') + .map(l => ({ name: l.name, value: l.value as boolean })) + for (const name of PENDING_FEATURES) + if (!features.some(f => f.name === name)) features.push({ name, value: PENDING_FEATURE_DEFAULT, pending: true }) + return features +}) + export const selectLicensesWithLimits = createSelector([selectLicenses, selectLimits], (licenses, limits) => { return { licenses: licenses.map(license => ({ From 4fe987d31d33f40e54dd1fa6850b2c78c24b2c2d Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 16:31:45 -0700 Subject: [PATCH 169/238] docs(env): describe the chat's license gate in .env.example --- .env.example | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index b05b855f1..bd1b24479 100644 --- a/.env.example +++ b/.env.example @@ -34,9 +34,11 @@ OAUTH_ISSUER="https://login.dev.remote.it" # that disagrees with the OIDC resource 401s with nothing in the UI explaining why. # --- Remote.It AI chat --------------------------------------------------------------- -# On the AI portal (app.ai.remote.it) the chat is always on; elsewhere it soft-launches -# behind the Test UI (shift+option on the avatar menu). Set true locally to skip that. -VITE_CHAT_ALWAYS_ON="true" +# The chat is a license feature ("ai-agent"), so leave this false: local dev turns that +# flag on by default, and Settings → Test Settings → Features toggles it. Set true ONLY +# for the AI portal deployment (app.ai.remote.it), which IS the AI surface and so skips +# the license entirely. +VITE_CHAT_ALWAYS_ON="false" # In dev, agentURL() returns the same-origin "/agent" vite proxy unless Test Settings # overrides it, so AGENT_PROXY_TARGET is the knob here: the deployed dev agent, or # http://localhost:3001 to run the ai-agent service locally. DPoP proofs are signed over From ae75d12f08dcd868eeb423d1705ebabf34b92784 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 28 Aug 2026 16:40:11 -0700 Subject: [PATCH 170/238] fix(chat): make CHAT_ALWAYS_ON a flag default so Test Settings can switch it off --- .env.example | 4 ++-- frontend/src/constants.ts | 26 +++++++++++++------------ frontend/src/hooks/useChatEnabled.ts | 14 +++++-------- frontend/src/selectors/organizations.ts | 14 ++++++------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index bd1b24479..ebed52754 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,8 @@ OAUTH_ISSUER="https://login.dev.remote.it" # --- Remote.It AI chat --------------------------------------------------------------- # The chat is a license feature ("ai-agent"), so leave this false: local dev turns that # flag on by default, and Settings → Test Settings → Features toggles it. Set true ONLY -# for the AI portal deployment (app.ai.remote.it), which IS the AI surface and so skips -# the license entirely. +# for the AI portal deployment (app.ai.remote.it), which IS the AI surface — that only +# makes the flag default ON there, so it can still be switched off for testing. VITE_CHAT_ALWAYS_ON="false" # In dev, agentURL() returns the same-origin "/agent" vite proxy unless Test Settings # overrides it, so AGENT_PROXY_TARGET is the knob here: the deployed dev agent, or diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 1230b7454..c087fed78 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -13,18 +13,20 @@ export const CHAT_ALWAYS_ON = env.VITE_CHAT_ALWAYS_ON === 'true' export const CHAT_FEATURE = 'ai-agent' /* Boolean license features this build gates on that an account's license may not carry - yet. Naming one here gives it a row on the Test page AND puts it in the limits lookup, - which is what makes it testable at all: the lookup is built FROM the limits the API - returned, so a flag the API has never mentioned has nothing for an override to attach - to. The API's value always wins once it starts arriving, so an entry whose limit has - gone live everywhere is dead weight and can be deleted. */ -export const PENDING_FEATURES: string[] = [CHAT_FEATURE] - -/* What a forward-declared flag is worth before any license mentions it: ON in local dev, - because you are building the feature, and OFF in every deployed build. Kept as the - flag's VALUE rather than as a bypass around the gate, so the Test page switch always - reads what is actually in effect and one click still turns the feature off in dev. */ -export const PENDING_FEATURE_DEFAULT = MODE === 'development' + yet, each paired with what it is worth until a license speaks. Naming one here gives it + a row on the Test page AND puts it in the limits lookup, which is what makes it testable + at all: the lookup is built FROM the limits the API returned, so a flag the API has + never mentioned has nothing for an override to attach to. The API's value wins once it + starts arriving, so an entry whose limit has gone live is dead weight and can go. + + These are DEFAULTS, not bypasses — every one of them stays a normal feature flag, so + the Test page switch reads what is actually in effect and can turn the feature back + OFF. That is the point of routing local dev and the AI portal through here rather than + around the gate: on app.ai.remote.it the chat is on because CHAT_ALWAYS_ON makes this + default true, and it is still one switch away from off. */ +export const PENDING_FEATURES: ILookup = { + [CHAT_FEATURE]: MODE === 'development' || CHAT_ALWAYS_ON, +} // Renderer-owned OIDC (permitteer docs/remoteit-desktop-login.md, D8) — identical on // web and desktop; the backend never touches auth. diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index 11ce373e2..fc2f5fb29 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -4,7 +4,6 @@ import { selectLimitsLookup } from '../selectors/organizations' import browser from '../services/browser' import { useViewportWidth } from './useViewportWidth' import { - CHAT_ALWAYS_ON, CHAT_FEATURE, CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN, @@ -25,14 +24,11 @@ import { It is a LICENSE feature, read exactly the way tagging/saml/roles are, which means it follows the ACCOUNT you are viewing: the chat is scoped to the organization in the sidebar selector, so an org whose license does not carry the agent does not get one. - One build skips the license: app.ai.remote.it (CHAT_ALWAYS_ON) IS the AI surface. Local - dev is NOT an exception — the flag simply defaults on there (PENDING_FEATURE_DEFAULT), - so the gate itself is what you toggle rather than something you have to work around. - Until the API carries the limit, Test Settings → Features is how to turn it on. */ -export const useChatEnabled = (): boolean => { - const licensed = useSelector((state: State) => !!selectLimitsLookup(state)[CHAT_FEATURE]) - return CHAT_ALWAYS_ON || licensed -} + Nothing skips this gate. Local dev and the AI portal (CHAT_ALWAYS_ON) only set the + flag's default where no license carries it yet (PENDING_FEATURES), so even there the + chat is a feature flag you can switch back off in Test Settings → Features — which is + also how you turn it on anywhere else until the API starts sending the limit. */ +export const useChatEnabled = (): boolean => useSelector((state: State) => !!selectLimitsLookup(state)[CHAT_FEATURE]) /* The widest the chat column may be dragged: whatever the window holds once the content keeps CHAT_MIN_CONTENT_WIDTH. The sidebar does not cap the drag — the layout diff --git a/frontend/src/selectors/organizations.ts b/frontend/src/selectors/organizations.ts index cd17b306d..8f8d46f28 100644 --- a/frontend/src/selectors/organizations.ts +++ b/frontend/src/selectors/organizations.ts @@ -1,6 +1,6 @@ import { createSelector } from 'reselect' import { REMOTEIT_PRODUCT_ID } from '../models/plans' -import { PENDING_FEATURES, PENDING_FEATURE_DEFAULT } from '../constants' +import { PENDING_FEATURES } from '../constants' import { getUser, getOrganizations, @@ -98,10 +98,10 @@ export const selectLimitsLookup = createSelector( [selectLimits, isUserAccount, getLimitsOverride], (baseLimits, isUserAccount, limitsOverride): ILookup => { const result: ILookup = {} - // Flags this build knows about but no license carries yet: worth their dev default - // until the API says otherwise, and — being named — something an override can take a - // position on, which a name the lookup has never seen would not be. - PENDING_FEATURES.forEach(name => (result[name] = PENDING_FEATURE_DEFAULT)) + // Flags this build knows about but no license carries yet: worth their declared + // default until the API says otherwise, and — being named — something an override can + // take a position on, which a name the lookup has never seen would not be. + Object.entries(PENDING_FEATURES).forEach(([name, value]) => (result[name] = value)) baseLimits.forEach(l => (result[l.name] = l.value)) if (isUserAccount) Object.keys(result).forEach(name => { @@ -121,8 +121,8 @@ export const selectFeatures = createSelector([selectLimits], (limits): IFeature[ const features: IFeature[] = limits .filter(l => typeof l.value === 'boolean') .map(l => ({ name: l.name, value: l.value as boolean })) - for (const name of PENDING_FEATURES) - if (!features.some(f => f.name === name)) features.push({ name, value: PENDING_FEATURE_DEFAULT, pending: true }) + for (const [name, value] of Object.entries(PENDING_FEATURES)) + if (!features.some(f => f.name === name)) features.push({ name, value, pending: true }) return features }) From 7b324f49b85705d57059c126cce961461b0958e6 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 31 Aug 2026 09:45:37 -0700 Subject: [PATCH 171/238] fix(layout): give the app frame side gutters and hold it until they fit --- frontend/src/components/RemoteHeader.tsx | 37 +++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/RemoteHeader.tsx b/frontend/src/components/RemoteHeader.tsx index 2e9b1731c..83d82bcdb 100644 --- a/frontend/src/components/RemoteHeader.tsx +++ b/frontend/src/components/RemoteHeader.tsx @@ -1,28 +1,41 @@ import React, { useState } from 'react' import { APP_MAX_WIDTH } from '../constants' -import { Tooltip, IconButton, Box, Stack, useMediaQuery } from '@mui/material' +import { Tooltip, IconButton, Box, Stack } from '@mui/material' import { TargetPlatform } from './TargetPlatform' import { Icon } from './Icon' import screenfull from 'screenfull' import browser from '../services/browser' +import { spacing as scale } from '../styling' import { useChatDocked, useChatWidth } from '../hooks/useChatEnabled' +import { useViewportWidth } from '../hooks/useViewportWidth' type Props = { device?: IDevice; children: React.ReactNode } +/* The grey surround the app floats on once it stops growing: the same gap on every side, + and — doubled — the width the window must have SPARE before a frame is worth drawing. + One value for both, so the look and the moment it appears cannot disagree. */ +const FRAME_GUTTER = scale.sm + export const RemoteHeader: React.FC = ({ device, children }) => { /* APP_MAX_WIDTH is how wide the APP's content should ever get. The docked chat is a column beside that content rather than part of it, so the frame grows by exactly what the chat takes — otherwise opening the chat quietly spends the app's own width on it. Expanded doesn't count: it overlays the content instead of sitting beside it. - The media query uses the same figure, or the framed look would start before the - frame could actually reach its width. (Web only — Electron always fills its window.) */ + (Web only — Electron always fills its window.) */ const chatWidth = useChatWidth() const appMaxWidth = APP_MAX_WIDTH + (useChatDocked() ? chatWidth : 0) - /* The QUERY is the constant, not the widened cap: it only asks "is the window wider - than the app wants to be", which the chat does not change. Interpolating the live - cap rebuilt the MediaQueryList on every frame of a chat drag — a fresh matchMedia - and re-subscribe per frame — for an answer that flips at one threshold. */ - const maxWidth = !browser.isElectron && useMediaQuery(`(min-width:${APP_MAX_WIDTH}px)`) + /* Framed only once the window can hold the capped app AND a gutter either side. The + test used to be a `min-width: APP_MAX_WIDTH` media query — which is precisely the + width at which the app still fills the window edge to edge, so the top gap and the + rounded corners arrived while the sides had nothing to show. A docked chat stretched + that dead zone by its own width, raising the cap but not the query. + + Compared as numbers rather than through matchMedia BECAUSE the threshold moves with + the chat: interpolating a live value into a media query rebuilt the MediaQueryList + on every frame of a drag. useViewportWidth is one shared, frame-coalesced listener, + so reading the width here costs nothing extra. */ + const viewport = useViewportWidth() + const maxWidth = !browser.isElectron && viewport >= appMaxWidth + FRAME_GUTTER * 2 const showFrame = browser.isRemote const [fullscreen, setFullscreen] = useState(false) const fullscreenEnabled = screenfull.isEnabled @@ -64,9 +77,11 @@ export const RemoteHeader: React.FC = ({ device, children }) => { flexFlow: 'column', margin: 'auto', contain: 'layout', - marginTop: maxWidth || showFrame ? 3 / 2 : 0, - height: `calc(100% - ${showFrame ? spacing(6) : maxWidth ? spacing(3) : '0px'})`, - width: `calc(100% - ${showFrame ? spacing(6) : '0px'})`, + marginTop: maxWidth || showFrame ? `${FRAME_GUTTER}px` : 0, + height: `calc(100% - ${showFrame ? spacing(6) : maxWidth ? `${FRAME_GUTTER * 2}px` : '0px'})`, + // The sides get the same gutter as the top. Insetting only the top read as a + // rendering seam above the app rather than as a window floating on the grey. + width: `calc(100% - ${showFrame ? spacing(6) : maxWidth ? `${FRAME_GUTTER * 2}px` : '0px'})`, maxWidth: maxWidth ? appMaxWidth : undefined, backgroundColor: 'white.main', borderRadius: maxWidth || showFrame ? 5 : undefined, From 570e825077228b3777aa0ff9325d22508c98e1dc Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 31 Aug 2026 09:57:48 -0700 Subject: [PATCH 172/238] fix(auth): explain sign-in failures in the user's language instead of showing the raw error --- frontend/src/components/SignInApp.tsx | 90 +++++++++++++++++++++++---- frontend/src/i18n/locales/de/app.json | 16 +++++ frontend/src/i18n/locales/en/app.json | 16 +++++ frontend/src/i18n/locales/es/app.json | 17 +++++ frontend/src/i18n/locales/ja/app.json | 15 +++++ frontend/src/models/auth.ts | 31 +++++++-- frontend/src/services/oidc.ts | 57 +++++++++++++++-- 7 files changed, 218 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index 89fa12b34..01c5d21db 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -1,7 +1,9 @@ import React, { useEffect } from 'react' import { Box, Button, Typography, CircularProgress } from '@mui/material' +import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { Dispatch, State } from '../store' +import { OidcErrorCode } from '../services/oidc' import browser from '../services/browser' import brand from '@common/brand/config' @@ -11,8 +13,74 @@ import brand from '@common/brand/config' * in the SYSTEM browser (permitteer docs/remoteit-desktop-login.md). The backend owns * the flow; this panel starts it and waits. */ + +/* What a failed sign-in tells the person to DO. Keyed by the reason rather than by the + server's wording, because the two things a stuck user needs — "is this me or them?" + and "do I retry or wait?" — are not in an error_description. The raw detail is shown + underneath, quietly, so a support conversation still has something to go on. */ +const SignInError: React.FC<{ code?: OidcErrorCode; detail?: string; retryAfter?: number }> = ({ + code, + detail, + retryAfter, +}) => { + const { t } = useTranslation() + // Round UP: telling someone to wait 6 minutes when the lock lifts in 6:40 just earns + // a second failure. Below a minute still reads as "a minute". + const minutes = Math.max(1, Math.ceil((retryAfter || 0) / 60)) + + const message = (): string => { + switch (code) { + case 'rateLimited': + return retryAfter + ? t('signIn.errorRateLimitedWait', { + count: minutes, + defaultValue_one: 'Too many sign-in attempts from this network. Please try again in about a minute.', + defaultValue_other: + 'Too many sign-in attempts from this network. Please try again in about {{count}} minutes.', + }) + : t( + 'signIn.errorRateLimited', + 'Too many sign-in attempts from this network. Please wait a few minutes and try again.' + ) + case 'unreachable': + return t( + 'signIn.errorUnreachable', + "We couldn't reach the sign-in service. Check your internet connection, then try again." + ) + case 'unavailable': + return t( + 'signIn.errorUnavailable', + 'The sign-in service is temporarily unavailable. Please try again in a few minutes.' + ) + case 'refused': + return t( + 'signIn.errorRefused', + 'The sign-in service refused this request. Try again, and contact support if it keeps happening.' + ) + case 'expired': + return t('signIn.errorExpired', 'That sign-in attempt expired before it finished. Please try again.') + default: + return t('signIn.errorUnknown', "Sign in didn't complete. Please try again.") + } + } + + return ( + + + {message()} + + {!!detail && ( + + {detail} + + )} + + ) +} + export function SignInApp() { - const { signInError, signingIn } = useSelector((state: State) => state.auth) + const { t } = useTranslation() + const { signInError, signInErrorCode, signInRetryAfter, signingIn } = useSelector((state: State) => state.auth) const { auth } = useDispatch() // On the WEB there is nothing to show a signed-out user — the AS login page IS the @@ -30,7 +98,7 @@ export function SignInApp() { - Taking you to sign in… + {t('signIn.redirecting', 'Taking you to sign in…')} ) @@ -38,31 +106,29 @@ export function SignInApp() { return ( - Sign in to {brand.appName} + {t('signIn.title', 'Sign in to {{app}}', { app: brand.appName })} - We'll open your browser to sign you in with Remote.It Single Sign-On. + {t('signIn.subtitle', "We'll open your browser to sign you in with Remote.It Single Sign-On.")} {signingIn ? ( - Waiting for your browser… finish signing in there. + {t('signIn.waiting', 'Waiting for your browser… finish signing in there.')} ) : ( )} - {signInError && ( - - {signInError} - - )} + {!!signInError && } ) } diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 572a4f739..5f19a4b8f 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2163,6 +2163,22 @@ "chooseServices": "Wählen Sie die Dienste aus, für die Sie Zugriff gewähren möchten.", "services": "Dienste" }, + "signIn": { + "button": "Anmelden", + "cancel": "Abbrechen", + "errorExpired": "Der Anmeldeversuch ist abgelaufen, bevor er abgeschlossen war. Bitte versuchen Sie es erneut.", + "errorRateLimited": "Zu viele Anmeldeversuche aus diesem Netzwerk. Bitte warten Sie einige Minuten und versuchen Sie es erneut.", + "errorRateLimitedWait_one": "Zu viele Anmeldeversuche aus diesem Netzwerk. Bitte versuchen Sie es in etwa einer Minute erneut.", + "errorRateLimitedWait_other": "Zu viele Anmeldeversuche aus diesem Netzwerk. Bitte versuchen Sie es in etwa {{count}} Minuten erneut.", + "errorRefused": "Der Anmeldedienst hat diese Anfrage abgelehnt. Versuchen Sie es erneut und wenden Sie sich an den Support, falls es weiterhin auftritt.", + "errorUnavailable": "Der Anmeldedienst ist vorübergehend nicht verfügbar. Bitte versuchen Sie es in einigen Minuten erneut.", + "errorUnknown": "Die Anmeldung wurde nicht abgeschlossen. Bitte versuchen Sie es erneut.", + "errorUnreachable": "Der Anmeldedienst war nicht erreichbar. Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", + "redirecting": "Sie werden zur Anmeldung weitergeleitet…", + "retry": "Erneut versuchen", + "subtitle": "Wir öffnen Ihren Browser, um Sie mit Remote.It Single Sign-On anzumelden.", + "title": "Bei {{app}} anmelden" + }, "statusChip": { "connected": "Verbunden", "idle": "Inaktiv", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index c9baebc6a..edf603047 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2163,6 +2163,22 @@ "chooseServices": "Choose the services you'd like to provide access to.", "services": "Services" }, + "signIn": { + "button": "Sign In", + "cancel": "Cancel", + "errorExpired": "That sign-in attempt expired before it finished. Please try again.", + "errorRateLimited": "Too many sign-in attempts from this network. Please wait a few minutes and try again.", + "errorRateLimitedWait_one": "Too many sign-in attempts from this network. Please try again in about a minute.", + "errorRateLimitedWait_other": "Too many sign-in attempts from this network. Please try again in about {{count}} minutes.", + "errorRefused": "The sign-in service refused this request. Try again, and contact support if it keeps happening.", + "errorUnavailable": "The sign-in service is temporarily unavailable. Please try again in a few minutes.", + "errorUnknown": "Sign in didn't complete. Please try again.", + "errorUnreachable": "We couldn't reach the sign-in service. Check your internet connection, then try again.", + "redirecting": "Taking you to sign in…", + "retry": "Try again", + "subtitle": "We'll open your browser to sign you in with Remote.It Single Sign-On.", + "title": "Sign in to {{app}}" + }, "statusChip": { "connected": "Connected", "idle": "Idle", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 0ad918d81..7a09708d6 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2197,6 +2197,23 @@ "chooseServices": "Elige los servicios a los que deseas dar acceso.", "services": "Servicios" }, + "signIn": { + "button": "Iniciar sesión", + "cancel": "Cancelar", + "errorExpired": "Ese intento de inicio de sesión caducó antes de completarse. Inténtalo de nuevo.", + "errorRateLimited": "Demasiados intentos de inicio de sesión desde esta red. Espera unos minutos e inténtalo de nuevo.", + "errorRateLimitedWait_many": "Demasiados intentos de inicio de sesión desde esta red. Inténtalo de nuevo en aproximadamente {{count}} minutos.", + "errorRateLimitedWait_one": "Demasiados intentos de inicio de sesión desde esta red. Inténtalo de nuevo en aproximadamente un minuto.", + "errorRateLimitedWait_other": "Demasiados intentos de inicio de sesión desde esta red. Inténtalo de nuevo en aproximadamente {{count}} minutos.", + "errorRefused": "El servicio de inicio de sesión rechazó esta solicitud. Inténtalo de nuevo y contacta con soporte si el problema continúa.", + "errorUnavailable": "El servicio de inicio de sesión no está disponible temporalmente. Inténtalo de nuevo en unos minutos.", + "errorUnknown": "El inicio de sesión no se completó. Inténtalo de nuevo.", + "errorUnreachable": "No pudimos conectar con el servicio de inicio de sesión. Comprueba tu conexión a internet e inténtalo de nuevo.", + "redirecting": "Te llevamos al inicio de sesión…", + "retry": "Reintentar", + "subtitle": "Abriremos tu navegador para iniciar sesión con Remote.It Single Sign-On.", + "title": "Inicia sesión en {{app}}" + }, "statusChip": { "connected": "Conectado", "idle": "Inactivo", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index b84b788d7..d393166bd 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2129,6 +2129,21 @@ "chooseServices": "アクセスを許可するサービスを選択してください。", "services": "サービス" }, + "signIn": { + "button": "サインイン", + "cancel": "キャンセル", + "errorExpired": "サインインの試行が完了する前に期限切れになりました。もう一度お試しください。", + "errorRateLimited": "このネットワークからのサインイン試行が多すぎます。数分待ってからもう一度お試しください。", + "errorRateLimitedWait_other": "このネットワークからのサインイン試行が多すぎます。約{{count}}分後にもう一度お試しください。", + "errorRefused": "サインインサービスがこのリクエストを拒否しました。もう一度お試しいただき、繰り返し発生する場合はサポートにお問い合わせください。", + "errorUnavailable": "サインインサービスが一時的に利用できません。数分後にもう一度お試しください。", + "errorUnknown": "サインインが完了しませんでした。もう一度お試しください。", + "errorUnreachable": "サインインサービスに接続できませんでした。インターネット接続を確認してから、もう一度お試しください。", + "redirecting": "サインインページに移動しています…", + "retry": "再試行", + "subtitle": "Remote.It シングルサインオンでサインインするため、ブラウザーを開きます。", + "title": "{{app}} にサインイン" + }, "statusChip": { "connected": "接続済み", "idle": "アイドル", diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index feb454462..457ed0883 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,7 +9,7 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, oidcGrantStale, OidcClaims } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, oidcGrantStale, OidcClaims, OidcError, OidcErrorCode } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' @@ -32,7 +32,13 @@ export interface AuthState { initialized: boolean authenticated: boolean backendAuthenticated: boolean + /** Technical detail — console, support, bug reports. NEVER rendered on its own: it is + * the server's own wording, so it is untranslated and often meaningless to a person. */ signInError?: string + /** What the failure MEANS, which is what the screen actually translates and acts on. */ + signInErrorCode?: OidcErrorCode + /** Seconds the server asked us to wait, when it said so (429). */ + signInRetryAfter?: number signingIn?: boolean passwordChallenge?: { challenge: string; hint?: string } user?: IUser @@ -45,12 +51,25 @@ const defaultState: AuthState = { authenticated: false, backendAuthenticated: false, signInError: undefined, + signInErrorCode: undefined, + signInRetryAfter: undefined, signingIn: false, user: undefined, mfaMethod: '', AWSUser: { authProvider: '' }, } +/* Every sign-in failure lands here, so the screen has exactly one shape to read and a + new throw site cannot reintroduce a raw server string on the UI. */ +const signInFailure = (error: any): Partial => ({ + signingIn: false, + signInError: error?.message, + signInErrorCode: error instanceof OidcError ? error.code : undefined, + signInRetryAfter: error instanceof OidcError ? error.retryAfter : undefined, +}) + +const signInCleared = { signInError: undefined, signInErrorCode: undefined, signInRetryAfter: undefined } + export default createModel()({ state: defaultState, effects: dispatch => ({ @@ -79,7 +98,7 @@ export default createModel()({ } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') } catch (error: any) { console.error('AUTH INIT: sign-in completion failed', error) - if (!options.silent) dispatch.auth.set({ signInError: error?.message || 'Sign in failed, please try again.' }) + if (!options.silent) dispatch.auth.set(signInFailure(error)) } } dispatch.auth.set({ initialized: true }) @@ -124,11 +143,11 @@ export default createModel()({ try { await oidcStart({ prompt: 'select_account' }) } catch (error) { - dispatch.auth.set({ signInError: error?.message || 'Could not open the account chooser.' }) + dispatch.auth.set(signInFailure(error)) } }, async signIn(_: void) { - dispatch.auth.set({ signingIn: true, signInError: undefined }) + dispatch.auth.set({ signingIn: true, ...signInCleared }) try { // Desktop sign-in always offers the CHOOSER (prompt=select_account): a live chip // in the browser would otherwise silently SSO whoever was last signed in, and a @@ -139,7 +158,7 @@ export default createModel()({ await oidcStart(browser.isElectron ? { prompt: 'select_account' } : {}) } catch (error: any) { console.error('SIGN IN FAILED', error) - dispatch.auth.set({ signingIn: false, signInError: error?.message || 'Sign in failed, please try again.' }) + dispatch.auth.set(signInFailure(error)) } }, async fetchUser(_: void) { @@ -149,7 +168,7 @@ export default createModel()({ const user = response?.data?.data?.login - auth.set({ user, signInError: undefined }) + auth.set({ user, ...signInCleared }) if (user.authhash && user.yoicsId) { Controller.setupConnection({ username: user.yoicsId, authHash: user.authhash, guid: user.id }) auth.signedIn() diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 2c2043121..69d698613 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -76,6 +76,44 @@ let minting: Promise = Promise.resolve() let mintErrors: { [resource: string]: string } = {} let discovery: { authorization_endpoint: string; token_endpoint: string; end_session_endpoint?: string; end_session_api_endpoint?: string } | undefined +/* Sign-in failures the person reading them can DO something different about. The message + stays the technical detail — console, support, bug reports — while `code` is what picks + the sentence they read, so the AS rewording an error_description can never silently + change our copy, and an untranslated server string can never reach the screen. */ +export type OidcErrorCode = 'rateLimited' | 'unreachable' | 'unavailable' | 'refused' | 'expired' + +export class OidcError extends Error { + code: OidcErrorCode + /** Seconds to wait, when the server told us (429). */ + retryAfter?: number + constructor(code: OidcErrorCode, message: string, retryAfter?: number) { + super(message) + this.name = 'OidcError' + this.code = code + this.retryAfter = retryAfter + } +} + +/* Retry-After is allowed to be either delta-seconds or an HTTP date; permitteer's + rate limiter also sends ratelimit-reset, which is always seconds. Take whichever + is present so "try again in N minutes" is the server's number, not a guess. */ +const retryAfterSeconds = (response: Response): number | undefined => { + const header = response.headers.get('retry-after') || response.headers.get('ratelimit-reset') + if (!header) return undefined + const seconds = Number(header) + if (!Number.isNaN(seconds)) return Math.max(0, Math.round(seconds)) + const date = Date.parse(header) + return Number.isNaN(date) ? undefined : Math.max(0, Math.round((date - Date.now()) / 1000)) +} + +/* One place that decides what a non-OK response from the AS MEANS, so the token endpoint + and discovery cannot drift into telling the user different stories about a 429. */ +const responseError = (response: Response, detail: string): OidcError => { + if (response.status === 429) return new OidcError('rateLimited', detail, retryAfterSeconds(response)) + if (response.status >= 500) return new OidcError('unavailable', detail) + return new OidcError('refused', detail) +} + const b64u = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') const randomB64u = (length: number) => b64u(crypto.getRandomValues(new Uint8Array(length))) @@ -106,8 +144,14 @@ export const oidcActor = (): { sub: string } | null => decodeJwt(stored()?.id_to async function discover() { if (discovery) return discovery - const response = await fetch(`${OAUTH_ISSUER}/.well-known/openid-configuration`) - if (!response.ok) throw new Error(`discovery failed: ${response.status}`) + let response: Response + try { + response = await fetch(`${OAUTH_ISSUER}/.well-known/openid-configuration`) + } catch (error: any) { + // fetch only rejects when the request never got an answer: offline, DNS, TLS, CORS. + throw new OidcError('unreachable', `discovery unreachable: ${error?.message || 'network error'}`) + } + if (!response.ok) throw responseError(response, `discovery failed: ${response.status}`) discovery = await response.json() return discovery! } @@ -208,9 +252,9 @@ export async function oidcCompleteFromUrl(): Promise { sessionStorage.removeItem(FLOW_KEY) cleanUrl() const flow: Flow | undefined = raw ? JSON.parse(raw) : undefined - if (!flow || flow.state !== state) throw new Error('Sign-in state mismatch — try again.') + if (!flow || flow.state !== state) throw new OidcError('expired', 'Sign-in state mismatch') const error = query.get('error') - if (error) throw new Error(query.get('error_description') || error) + if (error) throw new OidcError('refused', query.get('error_description') || error) const body = await tokenRequest({ grant_type: 'authorization_code', @@ -220,7 +264,7 @@ export async function oidcCompleteFromUrl(): Promise { resource: OAUTH_GRAPHQL_RESOURCE, }) const claims = decodeJwt(body.id_token) - if (claims?.nonce !== flow.nonce) throw new Error('Sign-in nonce mismatch — try again.') + if (claims?.nonce !== flow.nonce) throw new OidcError('expired', 'Sign-in nonce mismatch') const previous = stored()?.refresh_token if (previous && previous !== body.refresh_token) { fetch(`${OAUTH_ISSUER}/revoke`, { @@ -443,7 +487,8 @@ async function tokenRequest(params: { [key: string]: string }): Promise { }) const body: any = await response.json().catch(() => ({})) if (!response.ok || !body.access_token) { - const error: any = new Error(body.error_description || body.error || `token endpoint ${response.status}`) + const detail = body.error_description || body.error || `token endpoint ${response.status}` + const error: any = responseError(response, detail) error.oauthError = body.error throw error } From a0386f40d2135e2a3877ad0937bebf515a85a532 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 31 Aug 2026 10:24:31 -0700 Subject: [PATCH 173/238] fix(auth): stop the silent authorize loop that rate-limited the login server --- frontend/src/components/SignInApp.tsx | 34 +++++++++++++++++++-------- frontend/src/models/auth.ts | 33 ++++++++++++++++++++------ frontend/src/services/Controller.ts | 7 +++--- frontend/src/services/oidc.ts | 24 +++++++++++++++++++ 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index 01c5d21db..6a24aeabb 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -3,7 +3,7 @@ import { Box, Button, Typography, CircularProgress } from '@mui/material' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { Dispatch, State } from '../store' -import { OidcErrorCode } from '../services/oidc' +import { OidcErrorCode, oidcAutoStartsSpent, oidcCountAutoStart } from '../services/oidc' import browser from '../services/browser' import brand from '@common/brand/config' @@ -78,18 +78,32 @@ const SignInError: React.FC<{ code?: OidcErrorCode; detail?: string; retryAfter? ) } +/* How many authorizes this tab may start with nobody asking. Two, because one legitimate + retry (a token that died mid-session) is normal and a third in one tab never is. */ +const AUTO_START_LIMIT = 2 + export function SignInApp() { const { t } = useTranslation() - const { signInError, signInErrorCode, signInRetryAfter, signingIn } = useSelector((state: State) => state.auth) + const { signInFailed, signInError, signInErrorCode, signInRetryAfter, signingIn } = useSelector( + (state: State) => state.auth + ) const { auth } = useDispatch() - // On the WEB there is nothing to show a signed-out user — the AS login page IS the - // sign-in surface, so leave for it immediately (once per landing; an error return - // stays here so a cancel at the AS can't loop). Desktop keeps the launcher: its - // window must show something while the SYSTEM browser hosts the journey. - const autoStart = !browser.isElectron && !signingIn && !signInError + /* On the WEB there is nothing to show a signed-out user — the AS login page IS the + sign-in surface, so leave for it immediately. Desktop keeps the launcher: its window + must show something while the SYSTEM browser hosts the journey. + + TWO brakes, because this effect redirects the browser and the redirect can come + straight back. signInFailed is the real one: any failed attempt parks us here with an + explanation instead of bouncing. The spend counter is the backstop for the case that + actually bit — a path that returns without recording the failure — since an automatic + authorize renders nothing to a person and the first visible symptom is the AS + rate-limiting the address. A click is never counted against it. */ + const autoStart = !browser.isElectron && !signingIn && !signInFailed && oidcAutoStartsSpent() < AUTO_START_LIMIT useEffect(() => { - if (autoStart) auth.signIn() + if (!autoStart) return + oidcCountAutoStart() + auth.signIn() // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoStart]) @@ -125,10 +139,10 @@ export function SignInApp() { )} - {!!signInError && } + {signInFailed && } ) } diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 457ed0883..c6540cbed 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -32,6 +32,10 @@ export interface AuthState { initialized: boolean authenticated: boolean backendAuthenticated: boolean + /** A sign-in attempt failed. Deliberately SEPARATE from the message: this is what stops + * the web app starting another authorize by itself, and a brake that reads a display + * string is a brake that vanishes the moment the string is empty or suppressed. */ + signInFailed?: boolean /** Technical detail — console, support, bug reports. NEVER rendered on its own: it is * the server's own wording, so it is untranslated and often meaningless to a person. */ signInError?: string @@ -50,6 +54,7 @@ const defaultState: AuthState = { initialized: false, authenticated: false, backendAuthenticated: false, + signInFailed: false, signInError: undefined, signInErrorCode: undefined, signInRetryAfter: undefined, @@ -63,20 +68,34 @@ const defaultState: AuthState = { new throw site cannot reintroduce a raw server string on the UI. */ const signInFailure = (error: any): Partial => ({ signingIn: false, - signInError: error?.message, + signInFailed: true, + // Never empty: an absent message used to leave the auto-start guard looking like success. + signInError: error?.message || 'Sign in failed', signInErrorCode: error instanceof OidcError ? error.code : undefined, signInRetryAfter: error instanceof OidcError ? error.retryAfter : undefined, }) -const signInCleared = { signInError: undefined, signInErrorCode: undefined, signInRetryAfter: undefined } +const signInCleared = { + signInFailed: false, + signInError: undefined, + signInErrorCode: undefined, + signInRetryAfter: undefined, +} export default createModel()({ state: defaultState, effects: dispatch => ({ - // The BACKEND owns the OIDC session (permitteer docs/remoteit-desktop-login.md): - // init just asks it whether one exists. silent suppresses the session-error toast - // for machine-triggered runs (a network reconnect). See Controller.onNetworkConnect. - async init(options: { silent?: boolean } = {}, state) { + /* The BACKEND owns the OIDC session (permitteer docs/remoteit-desktop-login.md): + init just asks it whether one exists. + + This used to take a `silent` flag that skipped RECORDING a failed sign-in, meaning + to spare an unattended window a toast. But signInError is not a toast — it is the + only thing telling SignInApp not to start another authorize. Suppressed, a rejected + authorize returned, left no trace, and was retried immediately: an invisible + redirect loop (skipConsent shows no consent screen) running as fast as the page + could reload, until the AS rate-limited the address for everyone behind it. A + failure is always recorded now; being unattended is not a reason to forget it. */ + async init(_: void, state) { const { user } = state.auth console.log('AUTH INIT START', { user }) if (!user) { @@ -98,7 +117,7 @@ export default createModel()({ } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') } catch (error: any) { console.error('AUTH INIT: sign-in completion failed', error) - if (!options.silent) dispatch.auth.set(signInFailure(error)) + dispatch.auth.set(signInFailure(error)) } } dispatch.auth.set({ initialized: true }) diff --git a/frontend/src/services/Controller.ts b/frontend/src/services/Controller.ts index 5edf55c4f..0fa226597 100644 --- a/frontend/src/services/Controller.ts +++ b/frontend/src/services/Controller.ts @@ -54,9 +54,10 @@ class Controller extends EventEmitter { ui.set({ errorMessage: '' }) // This is the app's only entry into auth.init, so it has to run whether or // not the window has focus - a window launched in the background still has - // to sign in. Unattended, though, nobody asked for this and nobody is - // watching, so a failed session check shouldn't leave a toast waiting. - auth.init({ silent: !network.isActive() }) + // to sign in. It used to pass silent:true when unattended, to spare nobody a + // toast; that suppressed the record of a FAILED sign in, which is what stops + // the app retrying it, so an unattended window looped the authorize instead. + auth.init() } } diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 69d698613..7d603da6e 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -30,6 +30,28 @@ const TOKENS_KEY = 'oidc.tokens' // What the grant behind those tokens was last written from (see oidcGrantStale). const DECLARATION_KEY = 'oidc.declaration' +/* Authorizes this tab has started on its OWN — no click, nobody asked. This client is + first-party skipConsent, so an automatic authorize shows the person NOTHING: a loop + through it is invisible from the app and its only outward symptom is the AS + rate-limiting the whole address, which then locks out everyone behind it. The state + guards in the auth model are the real brakes; this is the backstop that holds when one + of them is missed. Cleared the moment an exchange completes. */ +const AUTO_START_KEY = 'oidc.autoStarts' +export const oidcAutoStartsSpent = (): number => { + try { + return Number(window.sessionStorage.getItem(AUTO_START_KEY)) || 0 + } catch { + return 0 + } +} +export const oidcCountAutoStart = (): void => { + try { + window.sessionStorage.setItem(AUTO_START_KEY, String(oidcAutoStartsSpent() + 1)) + } catch { + /* blocked storage must not stop someone signing in */ + } +} + // A boot on /signoutCallback is the RETURN from an explicit sign-out: the next authorize // must show the LOGIN PAGE (prompt=login), never silently SSO into another account's // live session in the multi-account cookie. @@ -277,6 +299,8 @@ export async function oidcCompleteFromUrl(): Promise { // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant // is merged from exactly that — so the grant now covers this build. Stamp it. try { tokenStore().setItem(DECLARATION_KEY, declarationFingerprint()) } catch { /* non-fatal */ } + // A completed exchange is the proof the automatic path works; let it start fresh. + try { window.sessionStorage.removeItem(AUTO_START_KEY) } catch { /* non-fatal */ } const at = decodeJwt(body.access_token) access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return claims From 89ad2f784c7aca7385c307128cd960698e6b0040 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 31 Aug 2026 12:32:00 -0700 Subject: [PATCH 174/238] fix(auth): clear sign-in failure on sign out, clamp retry hints, subscribe layout to the breakpoint --- frontend/src/components/RemoteHeader.tsx | 15 ++++++++----- frontend/src/hooks/useViewportWidth.ts | 9 ++++++++ frontend/src/models/auth.ts | 14 ++++++++++-- frontend/src/services/oidc.ts | 28 ++++++++++++++++++------ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/RemoteHeader.tsx b/frontend/src/components/RemoteHeader.tsx index 83d82bcdb..e153edb27 100644 --- a/frontend/src/components/RemoteHeader.tsx +++ b/frontend/src/components/RemoteHeader.tsx @@ -7,7 +7,7 @@ import screenfull from 'screenfull' import browser from '../services/browser' import { spacing as scale } from '../styling' import { useChatDocked, useChatWidth } from '../hooks/useChatEnabled' -import { useViewportWidth } from '../hooks/useViewportWidth' +import { useViewportWiderThan } from '../hooks/useViewportWidth' type Props = { device?: IDevice; children: React.ReactNode } @@ -31,11 +31,14 @@ export const RemoteHeader: React.FC = ({ device, children }) => { that dead zone by its own width, raising the cap but not the query. Compared as numbers rather than through matchMedia BECAUSE the threshold moves with - the chat: interpolating a live value into a media query rebuilt the MediaQueryList - on every frame of a drag. useViewportWidth is one shared, frame-coalesced listener, - so reading the width here costs nothing extra. */ - const viewport = useViewportWidth() - const maxWidth = !browser.isElectron && viewport >= appMaxWidth + FRAME_GUTTER * 2 + the chat: interpolating a live value into a media query rebuilt the MediaQueryList on + every frame of a drag. useViewportWiderThan subscribes to the ANSWER, so this + component — which wraps the whole app — re-renders when the framed state flips and + not on every frame of a window resize. The hook is called unconditionally: isElectron + is fixed for the app's lifetime, but a hook behind a && still reads as a conditional + one. */ + const wideEnough = useViewportWiderThan(appMaxWidth + FRAME_GUTTER * 2) + const maxWidth = !browser.isElectron && wideEnough const showFrame = browser.isRemote const [fullscreen, setFullscreen] = useState(false) const fullscreenEnabled = screenfull.isEnabled diff --git a/frontend/src/hooks/useViewportWidth.ts b/frontend/src/hooks/useViewportWidth.ts index 6e2789147..2c1d1e4b8 100644 --- a/frontend/src/hooks/useViewportWidth.ts +++ b/frontend/src/hooks/useViewportWidth.ts @@ -45,3 +45,12 @@ const subscribe = (listener: () => void) => { const getSnapshot = () => width export const useViewportWidth = (): number => useSyncExternalStore(subscribe, getSnapshot) + +/* Is the window at least `threshold` wide? Same single listener, but the subscriber is + the ANSWER rather than the width, so a component that only wants a breakpoint + re-renders when the breakpoint FLIPS instead of on every frame of a resize. Reach for + this over useViewportWidth wherever the pixel value is not itself rendered — reading + the raw width to compute a boolean re-renders (and re-serializes every sx object) 60 + times a second while someone drags the window edge. */ +export const useViewportWiderThan = (threshold: number): boolean => + useSyncExternalStore(subscribe, () => width >= threshold) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index c6540cbed..6d7722714 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,7 +9,7 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, oidcGrantStale, OidcClaims, OidcError, OidcErrorCode } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, invalidateOidcToken, oidcGrantStale, oidcClearAutoStarts, OidcClaims, OidcError, OidcErrorCode } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' @@ -275,6 +275,11 @@ export default createModel()({ } }, async handleSignInSuccess(claims: OidcClaims): Promise { + // A session — freshly exchanged OR restored from stored tokens — is proof the + // automatic path works, so it clears the auto-start budget. Doing it only at the + // code exchange left a tab that had spent its budget unable to auto sign-in again + // after a perfectly healthy restore. + oidcClearAutoStarts() await dispatch.auth.set({ authenticated: true, AWSUser: { @@ -391,7 +396,12 @@ export default createModel()({ // signing out of the app must not sign the user out of login.* (their browser // session is theirs; an explicit "sign out everywhere" action can come later). oidcClearLocal() - await dispatch.auth.set({ user: undefined }) + /* signInCleared as well as the user: a failure recorded while SIGNED IN — a refused + account switch, say — would otherwise survive into the signed-out screen, where + signInFailed is the brake on auto sign-in. The next person to land here would get + a stale error and no redirect, for something that happened in someone else's + session. */ + await dispatch.auth.set({ user: undefined, ...signInCleared }) dispatch.chat.reset() dispatch.user.reset() dispatch.organization.reset() diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 7d603da6e..cbd5c2398 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -51,6 +51,13 @@ export const oidcCountAutoStart = (): void => { /* blocked storage must not stop someone signing in */ } } +export const oidcClearAutoStarts = (): void => { + try { + window.sessionStorage.removeItem(AUTO_START_KEY) + } catch { + /* non-fatal */ + } +} // A boot on /signoutCallback is the RETURN from an explicit sign-out: the next authorize // must show the LOGIN PAGE (prompt=login), never silently SSO into another account's @@ -116,16 +123,25 @@ export class OidcError extends Error { } } -/* Retry-After is allowed to be either delta-seconds or an HTTP date; permitteer's - rate limiter also sends ratelimit-reset, which is always seconds. Take whichever - is present so "try again in N minutes" is the server's number, not a guess. */ +/* A day. Nothing that gates a sign-in retry waits longer, so a "wait" bigger than this + is not a countdown at all — it is an epoch timestamp, which some rate limiters send in + ratelimit-reset despite the draft specifying delta-seconds. Taken literally that + renders as "try again in about 29566667 minutes", so treat it as the unusable number + it is and let the caller fall back to wording with no figure in it. */ +const MAX_RETRY_AFTER = 24 * 60 * 60 + +/* Retry-After is allowed to be either delta-seconds or an HTTP date; permitteer's rate + limiter also sends ratelimit-reset. Take whichever is present so "try again in N + minutes" is the server's number rather than a guess — but only when the number is + one a person could actually act on. */ const retryAfterSeconds = (response: Response): number | undefined => { const header = response.headers.get('retry-after') || response.headers.get('ratelimit-reset') if (!header) return undefined + const plausible = (seconds: number) => (seconds >= 0 && seconds <= MAX_RETRY_AFTER ? Math.round(seconds) : undefined) const seconds = Number(header) - if (!Number.isNaN(seconds)) return Math.max(0, Math.round(seconds)) + if (!Number.isNaN(seconds)) return plausible(seconds) const date = Date.parse(header) - return Number.isNaN(date) ? undefined : Math.max(0, Math.round((date - Date.now()) / 1000)) + return Number.isNaN(date) ? undefined : plausible((date - Date.now()) / 1000) } /* One place that decides what a non-OK response from the AS MEANS, so the token endpoint @@ -299,8 +315,6 @@ export async function oidcCompleteFromUrl(): Promise { // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant // is merged from exactly that — so the grant now covers this build. Stamp it. try { tokenStore().setItem(DECLARATION_KEY, declarationFingerprint()) } catch { /* non-fatal */ } - // A completed exchange is the proof the automatic path works; let it start fresh. - try { window.sessionStorage.removeItem(AUTO_START_KEY) } catch { /* non-fatal */ } const at = decodeJwt(body.access_token) access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return claims From 0a7a210b500f7af32542c356849a9f8f933ccf6b Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 31 Aug 2026 12:43:46 -0700 Subject: [PATCH 175/238] copy(test): say feature overrides are personal-account only --- frontend/src/i18n/locales/de/app.json | 2 +- frontend/src/i18n/locales/en/app.json | 2 +- frontend/src/i18n/locales/es/app.json | 2 +- frontend/src/i18n/locales/ja/app.json | 2 +- frontend/src/pages/TestPage.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 5f19a4b8f..9dd922cb8 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2243,7 +2243,7 @@ "disabled": "deaktiviert", "enabled": "aktiviert", "featureLabel": "{{name}} (Standard {{state}})", - "featurePending": "Noch in keiner Lizenz enthalten – nur über diesen Schalter aktivierbar.", + "featurePending": "Noch in keiner Lizenz enthalten. Schalter hier gelten nur für Ihr persönliches Konto.", "features": "Funktionen", "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index edf603047..068490832 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2243,7 +2243,7 @@ "disabled": "disabled", "enabled": "enabled", "featureLabel": "{{name}} (default {{state}})", - "featurePending": "Not carried by any license yet — this switch is the only way to turn it on.", + "featurePending": "Not in any license yet. Switches here apply to your personal account only.", "features": "Features", "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 7a09708d6..1a79c1d24 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2280,7 +2280,7 @@ "disabled": "desactivado", "enabled": "activado", "featureLabel": "{{name}} (predeterminado {{state}})", - "featurePending": "Todavía no está incluida en ninguna licencia; este interruptor es la única forma de activarla.", + "featurePending": "Todavía no está en ninguna licencia. Los interruptores aquí solo afectan a tu cuenta personal.", "features": "Funciones", "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index d393166bd..4cb693c05 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2206,7 +2206,7 @@ "disabled": "無効", "enabled": "有効", "featureLabel": "{{name}}(デフォルト: {{state}})", - "featurePending": "まだどのライセンスにも含まれていません。このスイッチでのみ有効にできます。", + "featurePending": "まだどのライセンスにも含まれていません。ここのスイッチは個人アカウントにのみ適用されます。", "features": "機能", "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 0cd1dc960..0312da20c 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -352,7 +352,7 @@ export const TestPage: React.FC = () => { f.pending ? t( 'testPage.featurePending', - 'Not carried by any license yet \u2014 this switch is the only way to turn it on.' + 'Not in any license yet. Switches here apply to your personal account only.' ) : undefined } From 787ee4a4c1051f7d7ecc4004e674247bd32fd531 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 31 Aug 2026 12:50:47 -0700 Subject: [PATCH 176/238] fix(auth): announce halted auto sign-in and keep server detail to developers --- frontend/src/components/SignInApp.tsx | 29 ++++++++++++++++++++++++--- frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index 6a24aeabb..e1a04a4cc 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { Dispatch, State } from '../store' import { OidcErrorCode, oidcAutoStartsSpent, oidcCountAutoStart } from '../services/oidc' +import { MODE } from '../constants' import browser from '../services/browser' import brand from '@common/brand/config' @@ -24,6 +25,12 @@ const SignInError: React.FC<{ code?: OidcErrorCode; detail?: string; retryAfter? retryAfter, }) => { const { t } = useTranslation() + /* The server's own wording, shown only where someone is equipped to read it. It names + internal machinery — resource identifiers, endpoints, an authorization_details type — + which is what makes it useful in a bug report and wrong on a stranger's screen, + untranslated, under a sentence written for them. console.error still carries it for + everyone, so a support session loses nothing. */ + const showDetail = useSelector((state: State) => MODE === 'development' || !!state.ui.testUI) // Round UP: telling someone to wait 6 minutes when the lock lifts in 6:40 just earns // a second failure. Below a minute still reads as "a minute". const minutes = Math.max(1, Math.ceil((retryAfter || 0) / 60)) @@ -69,7 +76,7 @@ const SignInError: React.FC<{ code?: OidcErrorCode; detail?: string; retryAfter? {message()} - {!!detail && ( + {!!detail && showDetail && ( {detail} @@ -87,7 +94,7 @@ export function SignInApp() { const { signInFailed, signInError, signInErrorCode, signInRetryAfter, signingIn } = useSelector( (state: State) => state.auth ) - const { auth } = useDispatch() + const { auth, ui } = useDispatch() /* On the WEB there is nothing to show a signed-out user — the AS login page IS the sign-in surface, so leave for it immediately. Desktop keeps the launcher: its window @@ -99,7 +106,8 @@ export function SignInApp() { actually bit — a path that returns without recording the failure — since an automatic authorize renders nothing to a person and the first visible symptom is the AS rate-limiting the address. A click is never counted against it. */ - const autoStart = !browser.isElectron && !signingIn && !signInFailed && oidcAutoStartsSpent() < AUTO_START_LIMIT + const budgetSpent = oidcAutoStartsSpent() >= AUTO_START_LIMIT + const autoStart = !browser.isElectron && !signingIn && !signInFailed && !budgetSpent useEffect(() => { if (!autoStart) return oidcCountAutoStart() @@ -107,6 +115,21 @@ export function SignInApp() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoStart]) + /* The backstop is silent by construction — it catches the case where NOTHING recorded a + failure, so there is no error on screen to explain why the redirect stopped. Said + through the app's own snackbar (Page renders it over the signed-out screen too) + rather than by growing a second error surface on this panel. */ + useEffect(() => { + if (browser.isElectron || signingIn || signInFailed || !budgetSpent) return + ui.set({ + noticeMessage: t( + 'signIn.autoStopped', + 'Automatic sign-in stopped after repeated attempts. Select Sign In to try again.' + ), + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [budgetSpent, signInFailed, signingIn]) + if (autoStart || (!browser.isElectron && signingIn)) return ( diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 9dd922cb8..06a912d08 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2164,6 +2164,7 @@ "services": "Dienste" }, "signIn": { + "autoStopped": "Die automatische Anmeldung wurde nach wiederholten Versuchen gestoppt. Wählen Sie „Anmelden“, um es erneut zu versuchen.", "button": "Anmelden", "cancel": "Abbrechen", "errorExpired": "Der Anmeldeversuch ist abgelaufen, bevor er abgeschlossen war. Bitte versuchen Sie es erneut.", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 068490832..195689576 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2164,6 +2164,7 @@ "services": "Services" }, "signIn": { + "autoStopped": "Automatic sign-in stopped after repeated attempts. Select Sign In to try again.", "button": "Sign In", "cancel": "Cancel", "errorExpired": "That sign-in attempt expired before it finished. Please try again.", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 1a79c1d24..67dd145ba 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2198,6 +2198,7 @@ "services": "Servicios" }, "signIn": { + "autoStopped": "El inicio de sesión automático se detuvo tras varios intentos. Selecciona Iniciar sesión para volver a intentarlo.", "button": "Iniciar sesión", "cancel": "Cancelar", "errorExpired": "Ese intento de inicio de sesión caducó antes de completarse. Inténtalo de nuevo.", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 4cb693c05..7ed9e5b5b 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2130,6 +2130,7 @@ "services": "サービス" }, "signIn": { + "autoStopped": "繰り返しの試行により自動サインインを停止しました。「サインイン」を選択してもう一度お試しください。", "button": "サインイン", "cancel": "キャンセル", "errorExpired": "サインインの試行が完了する前に期限切れになりました。もう一度お試しください。", From 66a0e4740d25beee7e1ff3f406b94c73e47e7362 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 31 Aug 2026 14:38:55 -0700 Subject: [PATCH 177/238] docs(chat): brief the server-side ai-agent license limit and its client cleanup --- .../2026-08-31-ai-agent-license-limit.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md diff --git a/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md new file mode 100644 index 000000000..9b12cefab --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md @@ -0,0 +1,119 @@ +# Server-side handling for the `ai-agent` license limit + +**Goal:** Make the Remote.It AI chat a real licensed feature by having the API return an +`ai-agent` limit, then remove the client-side scaffolding that stands in for it today. + +**Status:** The CLIENT side is done and shipped on `feature/agent-chat-interface`. The API +returns no such limit yet, so the client forward-declares it. Nothing here is blocked on +more frontend work — this note is for whoever picks up the graphql-api / licensing side. + +**Where the work lives:** the limit itself is a graphql-api + licensing change, in another +repo. The only thing in THIS repo is the cleanup in the last section, which should land at +the same time. + +--- + +## What the client already does + +The whole chat surface hangs off one name, `CHAT_FEATURE = 'ai-agent'` +(`frontend/src/constants.ts`). `useChatEnabled` reads it through `selectLimitsLookup`, the +same selector that gates `tagging`, `saml` and `roles`: + +```ts +export const useChatEnabled = (): boolean => + useSelector((state: State) => !!selectLimitsLookup(state)[CHAT_FEATURE]) +``` + +Nothing chat-related mounts when it is false — no header button, no docked panel, and no +requests to the agent service. So the API turning this on is the entire switch; no release +is needed to enable the feature for an account. + +Because the API does not send it yet, `constants.ts` forward-declares it: + +```ts +export const PENDING_FEATURES: ILookup = { + [CHAT_FEATURE]: MODE === 'development' || CHAT_ALWAYS_ON, +} +``` + +That gives the flag a default (on in local dev and on app.ai.remote.it, off everywhere +else) and a row on the Test page. **The API's value wins the moment it starts arriving** — +`selectLimitsLookup` writes the pending defaults first and then overwrites from the API's +limits — so shipping the limit needs no coordinated client release. + +## The contract the client expects + +A **boolean** limit named `ai-agent`, delivered alongside the existing limits on both the +personal account and organizations. The client already asks for it — no query change is +needed. `frontend/src/services/graphQLRequest.ts` sends: + +```graphql +limits { name value actual base scale license { id } } +``` + +on `login.limits`, `login.account.limits` and each organization's `limits`. Existing +booleans to model it on: `firewall`, `no-splash`, `roles`, `saml`, `tagging` (all +`value: true`, `scale: null`). + +**It must be a boolean.** Two client behaviours depend on it: + +- `selectFeatures` (`frontend/src/selectors/organizations.ts`) lists Test page rows with + `typeof l.value === 'boolean'`, so a numeric limit would silently vanish from that page. +- `useChatEnabled` coerces with `!!`, so a numeric `0` reads as off but any non-zero number + reads as on — a seat count would accidentally work, and confusingly. + +If the feature genuinely needs a numeric dimension (seats, spend cap), raise it before +implementing — that is a client change, not just a server one. + +## Decisions to make first + +1. **Confirm the name.** `ai-agent` is the frontend's assumption, picked to match the + existing kebab-case convention. If licensing wants something else, it is a one-constant + change (`CHAT_FEATURE`) — but agree it before either side ships. + +2. **Per-organization, per-user, or both?** The client reads the limits of the account + currently selected in the sidebar, so as written the chat follows the ORGANIZATION you + are viewing: an org whose license lacks the agent gets no chat, even for a user whose + own account has it. That is consistent with every other paywalled feature, and it is + deliberate — but confirm it is what licensing intends, because the agent service's own + spend limits are per-USER, so the two are scoped differently. + +3. **What happens to app.ai.remote.it.** This one bites the day the limit ships. That + deployment sets `VITE_CHAT_ALWAYS_ON=true`, which today only sets the flag's *default* + — so once the API returns `ai-agent: false` for an unlicensed account, the API value + wins and that user gets an empty app on a site that exists solely to be the AI surface. + Pick one before shipping: + - make `CHAT_ALWAYS_ON` a floor that outranks the API value (one line in + `selectLimitsLookup`), so the portal never paywalls itself; or + - accept that the portal paywalls, and give it a real "you don't have this" screen. + +4. **Which plans carry it,** and whether there is a trial/evaluation form (compare + `aws-evaluation`, `trial-devices`). + +## Client cleanup, to land with the server change + +Once `ai-agent` is live everywhere, in this repo: + +- Delete the `PENDING_FEATURES` entry in `frontend/src/constants.ts`. With the map empty, + also delete `PENDING_FEATURES` itself and its two consumers in + `frontend/src/selectors/organizations.ts` (`selectLimitsLookup`'s seeding loop, and the + `pending` branch of `selectFeatures`), plus the `IFeature.pending` field and the + `testPage.featurePending` string in all four locale catalogs. +- Delete `CHAT_ALWAYS_ON` and `VITE_CHAT_ALWAYS_ON` — unless decision 3 keeps it as a + floor. It appears in `.env`, `frontend/.env`, `electron/.env`, `.env.example`, and the + **Amplify branch environment** for the AI portal (console only, not in the repo — see + the amplify-build-config note). +- Nothing else references the flag; `useChatEnabled` already reads only the license. + +Verification in this repo: `npm run typecheck`, `cd frontend && npm run i18n:check`, and +`npx prettier --check` on changed files. There is no unit-test infrastructure in the +frontend, so behaviour changes are verified by driving the running app. + +## Related, but not this task + +`login.dev.remote.it` currently rejects the client's authorize with +`invalid_authorization_details: unknown authorization_details type "remoteit_mcp_dev"`, so +sign-in on the dev stage fails outright. That is `OAUTH_MCP_DETAIL` not being registered on +that AS. It is unrelated to licensing, but it blocks testing anything on dev, so it likely +needs fixing first. A client-side redirect loop this triggered was fixed in `a0386f40` — +the failure is now reported once instead of retried forever. From d646b22900ca33476ade0802871c9a27c25b8f4d Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 31 Aug 2026 22:04:10 -0700 Subject: [PATCH 178/238] =?UTF-8?q?chat:=20discover=20the=20MCP=20detail?= =?UTF-8?q?=20type=20from=20the=20resource's=20PRM=20=E2=80=94=20never=20p?= =?UTF-8?q?in=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent lane died when the AS retired remoteit_mcp_dev for the stage-stable remoteit_mcp: this bundle PINNED the old name, so the refresh re-authorized for a type that no longer resolves, and the exchange found no MCP slice on the grant ('lost its authority mid-turn' loops). The type NAME is the resource's to declare (RFC 9728): it is now discovered from the MCP PRM at boot and before every authorize, cached in the token store so sync callers read last-known, with the constant (flipped to remoteit_mcp) as offline fallback. The SHAPE stays local on purpose — the device-only action subset and the actor marker are this app's declaration, deliberately narrower than the advertisement. A discovered RENAME flips declarationFingerprint(), so the next retirement heals with one silent re-authorize instead of an error screen. The slice also names its resource via 'locations': the stage-stable name is shared across every stage's MCP resource, and the actor's registered edge may cover more than one (dev also acts toward evan) — the AS fails closed on that ambiguity by design. Pairs with the AS-side change (permitteer c27cad7) that lets an actor-marked slice resolve against the ACTOR's registered edge. Co-Authored-By: Claude Fable 5 --- frontend/src/constants.ts | 5 ++- frontend/src/services/oidc.ts | 57 ++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index c087fed78..18d6c3008 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -39,7 +39,10 @@ export const OAUTH_PASSPORT_RESOURCE = env.VITE_OAUTH_PASSPORT_RESOURCE || 'http // delegated onward to the service actor — which is what makes those tokens exchangeable. export const OAUTH_AGENT_RESOURCE = env.VITE_OAUTH_AGENT_RESOURCE || 'https://agent.dev.remote.it' export const OAUTH_MCP_RESOURCE = env.VITE_OAUTH_MCP_RESOURCE || 'https://mcp.dev.remote.it/mcp' -export const OAUTH_MCP_DETAIL = env.VITE_OAUTH_MCP_DETAIL || 'remoteit_mcp_dev' +// FALLBACK only: the live name is DISCOVERED from the MCP resource's PRM at sign-in +// (services/oidc.ts) — per-resource keying made it stage-stable, and the 2026-08-31 +// retirement of the _dev names is exactly why a pinned copy can't be the source of truth. +export const OAUTH_MCP_DETAIL = env.VITE_OAUTH_MCP_DETAIL || 'remoteit_mcp' export const OAUTH_AGENT_ACTOR = 'svc_ai_agent' export const API_URL = env.VITE_API_URL || 'https://api.remote.it/apv/v27' diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index cbd5c2398..97c68f73e 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -205,7 +205,48 @@ const redirectUri = () => * `passport_account` gates the native security settings; `permitteer_account` is Connected * Apps against the AS's own account API (plan D6) — list + revoke. The graphql audience * stays pure scope-`full` and carries no details, so it is not listed here. */ -const DECLARED: Array<{ resource: string; type: string; actions: string[]; actor?: string }> = [ +// The MCP detail-type NAME is the resource's to declare, not this bundle's to pin: it is +// DISCOVERED from the MCP PRM (RFC 9728 — authorization_details_types_supported and the rich +// catalog), cached in the token store so sync callers read the last-known value, and refreshed +// before every authorize. A pinned copy is exactly what broke on 2026-08-31: the AS retired +// remoteit_mcp_dev for the stage-stable remoteit_mcp, the pinned request stopped resolving, and +// the agent lane died with "needs permissions" / reauth loops. The SHAPE stays local on +// purpose — the device-only action subset and the `actor` marker are this app's declaration +// (deliberately narrower than the advertisement); only the name rides discovery. A discovered +// RENAME flips declarationFingerprint(), so the existing stale-grant path heals it with one +// silent re-authorize instead of an error screen. +const MCP_TYPE_KEY = 'r3.oauth.mcpDetailType' +let mcpTypeMemo: string | undefined +function mcpDetailType(): string { + if (mcpTypeMemo) return mcpTypeMemo + try { const stored = tokenStore().getItem(MCP_TYPE_KEY); if (stored) return (mcpTypeMemo = stored) } catch { /* fall through */ } + return OAUTH_MCP_DETAIL +} +async function refreshMcpDetailType(): Promise { + try { + const r = new URL(OAUTH_MCP_RESOURCE) + const prm = `${r.origin}/.well-known/oauth-protected-resource${r.pathname}` + const doc = (await (await fetch(prm)).json()) as { + authorization_details_types_supported?: string[] + authorization_details_types?: Array<{ type?: string; risk_class?: string }> + } + const rich = doc.authorization_details_types ?? [] + const names = doc.authorization_details_types_supported ?? [] + // The standard (non-org) grant type: the rich catalog says so directly; a names-only + // document falls back to the naming convention the registry has always used. + const picked = rich.find(t => t.risk_class === 'standard' && typeof t.type === 'string')?.type + ?? names.find(n => !n.endsWith('_org')) + if (picked) { + mcpTypeMemo = picked + try { tokenStore().setItem(MCP_TYPE_KEY, picked) } catch { /* best effort */ } + } + } catch { /* offline or blocked — the last-known (or fallback) name stands */ } + return mcpDetailType() +} +// Warm the cache off the boot path so oidcGrantStale() compares against fresh truth early. +void refreshMcpDetailType() + +const declared = (): Array<{ resource: string; type: string; actions: string[]; actor?: string; locations?: string[] }> => [ { resource: OAUTH_PASSPORT_RESOURCE, type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write'] }, // The AI agent's slice (remoteit-ai-agent.md D5): the stage's MCP detail, delegated @@ -213,13 +254,16 @@ const DECLARED: Array<{ resource: string; type: string; actions: string[]; actor // tokens, which is the exchange's precondition. The slice partitions from any plain // request of the same type, and the grant row it mints is the revocable object the // account console shows. - { resource: OAUTH_MCP_RESOURCE, type: OAUTH_MCP_DETAIL, actions: ['device:read', 'device:write', 'device:connect', 'device:execute'], actor: OAUTH_AGENT_ACTOR }, + // `locations` names WHICH resource's type this is (RFC 9396): the stage-stable name is + // shared across every stage's MCP resource, and the actor's registered edge may cover more + // than one (dev also acts toward evan) — the AS fails closed on that ambiguity by design. + { resource: OAUTH_MCP_RESOURCE, type: mcpDetailType(), locations: [OAUTH_MCP_RESOURCE], actions: ['device:read', 'device:write', 'device:connect', 'device:execute'], actor: OAUTH_AGENT_ACTOR }, ] /** A stable fingerprint of what this build asks for. Order-insensitive, so reshuffling the * list is not a change; adding, dropping or renaming an action is. */ const declarationFingerprint = () => - DECLARED.map(d => `${d.resource}=${d.type}:${[...d.actions].sort().join(',')}${d.actor ? `@${d.actor}` : ''}`) + declared().map(d => `${d.resource}=${d.type}:${[...d.actions].sort().join(',')}${d.actor ? `@${d.actor}` : ''}`) .sort() .join('|') @@ -244,6 +288,9 @@ export function oidcGrantStale(): boolean { export async function oidcStart(opts: { prompt?: 'login' | 'select_account'; loginHint?: string } = {}): Promise { const d = await discover() + // The authorize is the moment the name must be RIGHT (a stale one mints a grant the + // exchange can't use) — resolve it fresh, falling back to last-known on failure. + await refreshMcpDetailType() const verifier = randomB64u(48) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) const flow: Flow = { verifier, state: randomB64u(16), nonce: randomB64u(16), redirectUri: redirectUri() } @@ -260,7 +307,7 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account'; log // the passport-audience token minted later via refresh carries this slice, gating the // native security settings (credentials.write); the graphql audience stays pure // scope-`full` (an uncovered resource yields audience-only tokens). - authorization_details: JSON.stringify(DECLARED.map(d => ({ type: d.type, actions: d.actions, ...(d.actor ? { actor: d.actor } : {}) }))), + authorization_details: JSON.stringify(declared().map(d => ({ type: d.type, actions: d.actions, ...(d.locations ? { locations: d.locations } : {}), ...(d.actor ? { actor: d.actor } : {}) }))), state: flow.state, nonce: flow.nonce, } @@ -312,7 +359,7 @@ export async function oidcCompleteFromUrl(): Promise { }).catch(() => {}) } persist({ refresh_token: body.refresh_token, id_token: body.id_token }) - // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant + // The authorize that just completed asked for declared(), and a skipConsent first-party grant // is merged from exactly that — so the grant now covers this build. Stamp it. try { tokenStore().setItem(DECLARATION_KEY, declarationFingerprint()) } catch { /* non-fatal */ } const at = decodeJwt(body.access_token) From 0cf32875614c9fa0081ac892425155b517946768 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 1 Sep 2026 15:33:57 -0700 Subject: [PATCH 179/238] =?UTF-8?q?chat/portal:=20the=20avatar=20menu=20li?= =?UTF-8?q?sts=20every=20signed-in=20account=20=E2=80=94=20switching=20is?= =?UTF-8?q?=20one=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google-style client-side multi-account (permitteer docs/multi-account-sessions.md built the AS half a month ago; this is the portal's half — the AS's session set is browser-cookie state this origin can never read, by the SameSite/CORS doctrine, so the menu lists the accounts THIS app has signed into and the AS chooser on the add-account hop shows the rest): - services/oidc.ts grows an account REGISTRY beside the active token set: one saved entry per subject, filed by persist() — which runs on every refresh, so the single-use rotating refresh token can never go stale-by-replay in a saved copy. Support sessions (act in the id_token) are never saved: impersonation must not become a stored identity. - Completion is sub-aware: the same account re-authing revokes its old family (as before); a DIFFERENT account arriving is the add-account path and the previous account's living session is saved, not revoked. The per-grant declaration stamp swaps with the account. - The ONE DPoP key binds every saved account's refresh token, so it now rotates only when the LAST account signs out — rotating per sign-out would silently kill the other accounts' sessions. - auth.activateAccount: storage swap + full reload so every model boots as the new identity (a soft swap would bleed devices/orgs across accounts). A stale saved session lands on the sign-in screen like any expired one; an unknown sub falls to the chooser. - AvatarMenu renders the other accounts (name + email, no new i18n — identities are not translated) above the existing Switch account chooser item. Typecheck + vite build green. Co-Authored-By: Claude Fable 5 --- frontend/src/components/AvatarMenu.tsx | 18 ++++ frontend/src/models/auth.ts | 20 ++++- frontend/src/services/oidc.ts | 115 +++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/AvatarMenu.tsx b/frontend/src/components/AvatarMenu.tsx index 833c7b6a9..3a8f345b7 100644 --- a/frontend/src/components/AvatarMenu.tsx +++ b/frontend/src/components/AvatarMenu.tsx @@ -13,6 +13,7 @@ import { ListItemLink } from './ListItemLink' import { isRemoteUI } from '../helpers/uiHelper' import { DesktopUI } from './DesktopUI' import { Avatar } from './Avatar' +import { oidcAccounts } from '../services/oidc' import { emit } from '../services/Controller' const ENTER_DELAY = 300 @@ -178,6 +179,23 @@ export const AvatarMenu: React.FC = () => { }} /> + {/* The other accounts this app has signed into (services/oidc.ts registry) — one + click makes one active. Emails render as-is (identities are not translated); + the AS chooser behind "Switch account" below remains the way to ADD one. */} + {oidcAccounts() + .filter(a => !a.active) + .map(a => ( + { + handleClose() + await dispatch.auth.activateAccount(a.sub) + }} + /> + ))} ()({ // panel until the deep link reloads it with the code. /** Account switch: re-run authorize with select_account — the AS chooser shows the * real session chips; nothing is torn down locally, so a canceled chooser costs - * nothing. Completion replaces the session like any sign-in (old family revoked). */ + * nothing. Completion replaces the session like any sign-in (a SAME-account re-auth + * revokes the old family; a DIFFERENT account files the old one in the registry — + * services/oidc.ts). */ async switchAccount(_: void) { try { await oidcStart({ prompt: 'select_account' }) @@ -127,6 +129,20 @@ export default createModel()({ dispatch.auth.set({ signInError: error?.message || 'Could not open the account chooser.' }) } }, + /** Activate a SAVED account from the avatar menu (the oidc registry): a storage swap + * plus a full reload, so every model boots as the new identity — a soft swap would + * bleed one account's devices and orgs into the other's view. A stale saved session + * surfaces on boot exactly like any expired sign-in (refresh fails → sign-in screen), + * which is the honest fallback. An unknown sub falls to the add-account chooser, so a + * menu row that somehow outlived its registry entry still lands somewhere sensible. */ + async activateAccount(sub: string) { + if (oidcClaims()?.sub === sub) return // already active — nothing to do + if (oidcActivateAccount(sub)) { + window.location.assign('/') + } else { + await dispatch.auth.switchAccount() + } + }, async signIn(_: void) { dispatch.auth.set({ signingIn: true, signInError: undefined }) try { diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index b3bf8c594..4a0c364c0 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -29,6 +29,17 @@ const FLOW_KEY = 'oidc.flow' const TOKENS_KEY = 'oidc.tokens' // What the grant behind those tokens was last written from (see oidcGrantStale). const DECLARATION_KEY = 'oidc.declaration' +// The ACCOUNT REGISTRY (multi-account menu): one saved token set per subject this app has +// signed into, beside the single ACTIVE set in TOKENS_KEY. Google-style client-side +// multi-account — the AS's own session set is browser-cookie state this origin can never +// read (SameSite=Lax + no CORS on cookie lanes, by doctrine), so the menu lists the +// accounts THIS APP knows; the AS chooser on the add-account hop shows the rest. Every +// entry's refresh token is DPoP-bound to the ONE browser key (below), so the key rotates +// only when the LAST account leaves — rotating on every sign-out would silently kill the +// other accounts' saved sessions. A support session (id_token carries `act`) is NEVER +// saved: impersonation must not become a stored identity (support tabs keep an isolated +// per-tab store anyway). +const ACCOUNTS_KEY = 'oidc.accounts' // A boot on /signoutCallback is the RETURN from an explicit sign-out: the next authorize // must show the LOGIN PAGE (prompt=login), never silently SSO into another account's @@ -211,8 +222,14 @@ export async function oidcCompleteFromUrl(): Promise { }) const claims = decodeJwt(body.id_token) if (claims?.nonce !== flow.nonce) throw new Error('Sign-in nonce mismatch — try again.') - const previous = stored()?.refresh_token - if (previous && previous !== body.refresh_token) { + // Sub-aware handover: the SAME account signing in again replaces its family (revoke the + // old refresh token — it is dead weight); a DIFFERENT account arriving is the + // add-account path, and the previous account's set is a LIVING saved session — persist() + // already filed it in the registry, so it must absolutely not be revoked here. + const previousSet = stored() + const previousSub = decodeJwt(previousSet?.id_token)?.sub + const previous = previousSet?.refresh_token + if (previous && previous !== body.refresh_token && (!claims?.sub || previousSub === claims.sub)) { fetch(`${OAUTH_ISSUER}/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, @@ -221,8 +238,15 @@ export async function oidcCompleteFromUrl(): Promise { } persist({ refresh_token: body.refresh_token, id_token: body.id_token }) // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant - // is merged from exactly that — so the grant now covers this build. Stamp it. - try { tokenStore().setItem(DECLARATION_KEY, declarationFingerprint()) } catch { /* non-fatal */ } + // is merged from exactly that — so the grant now covers this build. Stamp it — active AND + // this account's registry entry, so a later activation restores the right measurement. + try { + tokenStore().setItem(DECLARATION_KEY, declarationFingerprint()) + if (claims?.sub && !claims?.act) { + const reg = readRegistry() + if (reg[claims.sub]) { reg[claims.sub].declaration = declarationFingerprint(); writeRegistry(reg) } + } + } catch { /* non-fatal */ } const at = decodeJwt(body.access_token) access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return claims @@ -288,16 +312,26 @@ export function invalidateOidcToken() { access = {} } -/** Local-only teardown: clears this app's tokens and NOTHING else. App sign-out never - * ends the AS session (user directive — the browser session at the AS belongs to the - * user, not to this app's error handling). `oidcSignOut` (RP-initiated end_session) - * remains for a future explicit "sign out everywhere" action only. */ +/** Local-only teardown: clears the ACTIVE account's tokens (and its registry entry) and + * NOTHING else. App sign-out never ends the AS session (user directive — the browser + * session at the AS belongs to the user, not to this app's error handling), and it never + * touches the OTHER saved accounts — signing out one identity is not signing out of the + * app's memory of the rest. `oidcSignOut` (RP-initiated end_session) remains for a future + * explicit "sign out everywhere" action only. */ export function oidcClearLocal() { clearLocal() } function clearLocal() { - void clearDpopKey() + const activeSub = oidcClaims()?.sub + const reg = readRegistry() + if (activeSub && reg[activeSub]) { + delete reg[activeSub] + writeRegistry(reg) + } + // The one DPoP key binds EVERY saved account's refresh token, so it rotates only when + // the last account leaves — "key loss ≡ session loss" now means ALL sessions. + if (Object.keys(reg).length === 0) void clearDpopKey() access = {} tokenStore().removeItem(TOKENS_KEY) tokenStore().removeItem(DECLARATION_KEY) @@ -305,6 +339,69 @@ function clearLocal() { function persist(tokens: Stored) { tokenStore().setItem(TOKENS_KEY, JSON.stringify(tokens)) + // Keep the registry entry in step with the ACTIVE set. This runs on every refresh too, + // which is load-bearing: refresh tokens rotate single-use, so a registry copy left + // behind would be a REPLAY when later activated — revoking the whole family. + fileAccount(tokens) +} + +// --- the account registry (multi-account menu) --------------------------------------- + +type RegistryEntry = Stored & { email?: string; name?: string; declaration?: string } + +const readRegistry = (): { [sub: string]: RegistryEntry } => { + try { + const raw = tokenStore().getItem(ACCOUNTS_KEY) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} +const writeRegistry = (reg: { [sub: string]: RegistryEntry }) => { + try { tokenStore().setItem(ACCOUNTS_KEY, JSON.stringify(reg)) } catch { /* storage blocked — menu degrades to active-only */ } +} + +/** File a token set under its subject — silently NOT for support sessions (`act`). */ +function fileAccount(tokens: Stored) { + const claims = decodeJwt(tokens.id_token) + const sub = claims?.sub + if (!sub || claims?.act) return + const reg = readRegistry() + reg[sub] = { + ...tokens, + email: claims?.email, + name: claims?.name, + declaration: reg[sub]?.declaration, + } + writeRegistry(reg) +} + +export type OidcAccount = { sub: string; email?: string; name?: string; active: boolean } + +/** The accounts this app has signed into, for the avatar menu. Active first. */ +export function oidcAccounts(): OidcAccount[] { + const activeSub = oidcClaims()?.sub + const reg = readRegistry() + return Object.entries(reg) + .map(([sub, e]) => ({ sub, email: e.email, name: e.name, active: sub === activeSub })) + .sort((a, b) => Number(b.active) - Number(a.active) || (a.email ?? a.sub).localeCompare(b.email ?? b.sub)) +} + +/** Make a saved account the ACTIVE one. Storage-only — the caller reloads the app so + * every model boots as the new identity (a soft swap would bleed one account's data + * into the other's view). Returns false when the account is unknown. */ +export function oidcActivateAccount(sub: string): boolean { + const entry = readRegistry()[sub] + if (!entry?.refresh_token) return false + access = {} + tokenStore().setItem(TOKENS_KEY, JSON.stringify({ refresh_token: entry.refresh_token, id_token: entry.id_token })) + // The declaration stamp is per-GRANT, and the grant is per-account: swap it with the + // tokens or the boot check would measure account B against account A's grant. + try { + if (entry.declaration) tokenStore().setItem(DECLARATION_KEY, entry.declaration) + else tokenStore().removeItem(DECLARATION_KEY) + } catch { /* non-fatal — worst case is one redundant re-authorize */ } + return true } function cleanUrl() { From 50d2a22f511dd92a415febc23bb19303d20ca735 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 1 Sep 2026 18:24:24 -0700 Subject: [PATCH 180/238] chat/portal: account rows wear the AS's avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AS now stamps the session's IdP picture into the id_token under the profile scope, so the sign-in asks for profile (name + picture — the grant merges silently for this first-party skipConsent client), the registry files the picture per account (https-only belt on the AS's own guard — the string lands in an img src), and the menu rows render it as a small MuiAvatar with an initial fallback for IdPs that supply none. Co-Authored-By: Claude Fable 5 --- frontend/src/components/AvatarMenu.tsx | 8 ++++++-- frontend/src/services/oidc.ts | 12 ++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/AvatarMenu.tsx b/frontend/src/components/AvatarMenu.tsx index 3a8f345b7..2a91f190e 100644 --- a/frontend/src/components/AvatarMenu.tsx +++ b/frontend/src/components/AvatarMenu.tsx @@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { State, Dispatch } from '../store' import { HIDE_SIDEBAR_WIDTH } from '../constants' -import { useMediaQuery, ButtonBase, Divider, Menu } from '@mui/material' +import { useMediaQuery, ButtonBase, Divider, Menu, Avatar as MuiAvatar } from '@mui/material' import { useSelector, useDispatch } from 'react-redux' import { selectLicenseIndicator } from '../models/plans' import { ListItemLocation } from './ListItemLocation' @@ -189,7 +189,11 @@ export const AvatarMenu: React.FC = () => { key={a.sub} label={a.name || a.email || a.sub} subLabel={a.name && a.email ? a.email : undefined} - icon="user" + icon={ + + {(a.name || a.email || '?').charAt(0).toUpperCase()} + + } onClick={async () => { handleClose() await dispatch.auth.activateAccount(a.sub) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 4a0c364c0..8a18162da 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -174,7 +174,9 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account'; log response_type: 'code', code_challenge: b64u(new Uint8Array(digest)), code_challenge_method: 'S256', - scope: 'openid email full', + // `profile` rides for the account menus: name + the IdP avatar (the AS stamps the + // session's picture into the id_token under profile — https-only, its one guard). + scope: 'openid email profile full', // First-party clients declare their own details (no consent screen — skipConsent): // the passport-audience token minted later via refresh carries this slice, gating the // native security settings (credentials.write); the graphql audience stays pure @@ -347,7 +349,7 @@ function persist(tokens: Stored) { // --- the account registry (multi-account menu) --------------------------------------- -type RegistryEntry = Stored & { email?: string; name?: string; declaration?: string } +type RegistryEntry = Stored & { email?: string; name?: string; picture?: string; declaration?: string } const readRegistry = (): { [sub: string]: RegistryEntry } => { try { @@ -371,19 +373,21 @@ function fileAccount(tokens: Stored) { ...tokens, email: claims?.email, name: claims?.name, + // Belt on the AS's own https-only guard — this string lands in an . + picture: typeof claims?.picture === 'string' && /^https:\/\//i.test(claims.picture) ? claims.picture : undefined, declaration: reg[sub]?.declaration, } writeRegistry(reg) } -export type OidcAccount = { sub: string; email?: string; name?: string; active: boolean } +export type OidcAccount = { sub: string; email?: string; name?: string; picture?: string; active: boolean } /** The accounts this app has signed into, for the avatar menu. Active first. */ export function oidcAccounts(): OidcAccount[] { const activeSub = oidcClaims()?.sub const reg = readRegistry() return Object.entries(reg) - .map(([sub, e]) => ({ sub, email: e.email, name: e.name, active: sub === activeSub })) + .map(([sub, e]) => ({ sub, email: e.email, name: e.name, picture: e.picture, active: sub === activeSub })) .sort((a, b) => Number(b.active) - Number(a.active) || (a.email ?? a.sub).localeCompare(b.email ?? b.sub)) } From e50f12cc629f6e3ef4f87ae1263e355241fe7284 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 1 Sep 2026 18:45:16 -0700 Subject: [PATCH 181/238] chat/portal: a stale saved account recovers silently through the AS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activating a registry account leaves a one-shot marker (its email, in sessionStorage — dies with the tab, cleared before the attempt so a failed round can never loop). When the boot finds the activated tokens dead, it spends the marker on ONE authorize with prompt=none + login_hint: the AS's silent selection serves any live session-set member the hint names, so the person lands back signed in with zero screens. A refused silent round falls to the ordinary sign-in screen exactly as before. Co-Authored-By: Claude Fable 5 --- frontend/src/models/auth.ts | 13 +++++++++++-- frontend/src/services/oidc.ts | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 793e420b0..de8a4019c 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,7 +9,7 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, invalidateOidcToken, oidcGrantStale, OidcClaims } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, OidcClaims } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' @@ -75,7 +75,16 @@ export default createModel()({ if (alive) { await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) await dispatch.auth.healGrant() - } else invalidateOidcToken() + } else { + invalidateOidcToken() + // A JUST-ACTIVATED saved account whose refresh family died: one silent + // recovery through the AS — prompt=none + login_hint serves any live + // session-set member the hint names (permitteer silent selection), so the + // person lands back signed in with zero screens. The marker is one-shot; + // a refused silent round falls to the ordinary sign-in screen. + const hint = oidcTakeActivationHint() + if (hint) await oidcStart({ prompt: 'none', loginHint: hint }) + } } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') } catch (error: any) { console.error('AUTH INIT: sign-in completion failed', error) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 8a18162da..2ebafa94b 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -161,7 +161,7 @@ export function oidcGrantStale(): boolean { } } -export async function oidcStart(opts: { prompt?: 'login' | 'select_account'; loginHint?: string } = {}): Promise { +export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'none'; loginHint?: string } = {}): Promise { const d = await discover() const verifier = randomB64u(48) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) @@ -394,9 +394,25 @@ export function oidcAccounts(): OidcAccount[] { /** Make a saved account the ACTIVE one. Storage-only — the caller reloads the app so * every model boots as the new identity (a soft swap would bleed one account's data * into the other's view). Returns false when the account is unknown. */ +/** One-shot marker: WHO was just activated, so a boot that finds the saved tokens dead can + * try ONE silent recovery (prompt=none + login_hint — the AS serves any live set member + * the hint names) before falling to the sign-in screen. sessionStorage: dies with the tab, + * and it is cleared before the attempt so a failed round can never loop. */ +const ACTIVATING_KEY = 'oidc.activating' +export function oidcTakeActivationHint(): string | undefined { + try { + const email = sessionStorage.getItem(ACTIVATING_KEY) ?? undefined + sessionStorage.removeItem(ACTIVATING_KEY) + return email || undefined + } catch { + return undefined + } +} + export function oidcActivateAccount(sub: string): boolean { const entry = readRegistry()[sub] if (!entry?.refresh_token) return false + try { if (entry.email) sessionStorage.setItem(ACTIVATING_KEY, entry.email) } catch { /* recovery hint only */ } access = {} tokenStore().setItem(TOKENS_KEY, JSON.stringify({ refresh_token: entry.refresh_token, id_token: entry.id_token })) // The declaration stamp is per-GRANT, and the grant is per-account: swap it with the From 5908e75abea3f0dd18418019e1ac329c5773dc4e Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 1 Sep 2026 18:57:20 -0700 Subject: [PATCH 182/238] chat/portal: a refresh that lands after sign-out (or activation) may not persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rotation wrote its successor unconditionally, so an in-flight refresh completing just after sign-out resurrected the signed-out account — tokens AND registry entry (persist() files accounts, which is load-bearing for rotation and exactly wrong here). The multi-account e2e caught it on ~50% of runs at the sign-out-keeps-the-other assertion; the same shape would clobber a freshly ACTIVATED account with the previous account's rotation. Persist now only lands on the token set the refresh rotated FROM. The dropped successor costs nothing: sign-out already ended the AS session (revoking its refresh family), and activation replaced the family in use. Co-Authored-By: Claude Fable 5 --- frontend/src/services/oidc.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 2ebafa94b..c4fb68bff 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -274,7 +274,14 @@ async function refresh(resource: string): Promise { refresh_token: current.refresh_token, resource, }) - // Rotated — persist the successor FIRST, before anything can race another mint. + // Rotated — persist the successor FIRST, before anything can race another mint. But + // ONLY onto the same token set we rotated from: a sign-out or an account activation + // that landed mid-flight has already moved the store, and writing the rotation would + // resurrect the signed-out account (persist() re-files it in the registry — caught by + // the multi-account e2e, ~50% of runs) or clobber the activated one. The dropped + // successor costs nothing: sign-out already ended the AS session (revoking its refresh + // family), and activation replaced the family in use. + if (stored()?.refresh_token !== current.refresh_token) return '' persist({ refresh_token: body.refresh_token || current.refresh_token, id_token: body.id_token || current.id_token }) const at = decodeJwt(body.access_token) access[resource] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } From b034236a5abb97e1c4b35f07bf671d137185ba61 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 2 Sep 2026 01:03:55 -0700 Subject: [PATCH 183/238] chat/portal: a support session arrives by ticket and ends with its token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permitteer docs/desktop-support.md P1. The console's "open as user" lands this tab on the app with a one-time ?support_ticket; the AS binds the sign-in to the operator's support session and mints tokens that live exactly as long as it — no refresh token, nothing to renew. - Boot: the ticket is stashed and the URL scrubbed (a reload never replays a spent ticket); the tab's token store stays tab-scoped (the operator's own tabs keep their tokens); auth.init starts the authorize with `support_ticket` on it. Replaces `?support_session=1`, which the AS no longer sends. - A support session is "signed in" by its one access token: stored (tab-scoped) so a reload survives, served until it expires, never refreshed, '' once gone — that expiry IS the end. Never filed as an account, never re-authorized by the grant heal (a plain authorize in this tab would sign the operator in as themselves). - The end is the end: a 401 under a support session ends it (the status now rides into checkSession; a 403 is an ordinary refused write and changes nothing); the sign-in screen never auto-starts in a support tab and says "Support session ended" instead. - The banner shows when the view ends, from the token's own expiry; the string is in four locales. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/SignInApp.tsx | 33 +++++++++++++- frontend/src/components/ViewAsBanner.tsx | 8 +++- frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + frontend/src/models/auth.ts | 26 +++++++++-- frontend/src/services/oidc.ts | 57 +++++++++++++++++++----- frontend/src/services/post.ts | 4 +- 9 files changed, 112 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index 89fa12b34..f09800bb8 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux' import { Dispatch, State } from '../store' import browser from '../services/browser' import brand from '@common/brand/config' +import { oidcIsSupportTab } from '../services/oidc' /** * The sign-in panel is a LAUNCHER now: the whole journey — email-first with org SSO @@ -12,19 +13,47 @@ import brand from '@common/brand/config' * the flow; this panel starts it and waits. */ export function SignInApp() { - const { signInError, signingIn } = useSelector((state: State) => state.auth) + const { signInError, signingIn, initialized } = useSelector((state: State) => state.auth) const { auth } = useDispatch() // On the WEB there is nothing to show a signed-out user — the AS login page IS the // sign-in surface, so leave for it immediately (once per landing; an error return // stays here so a cancel at the AS can't loop). Desktop keeps the launcher: its // window must show something while the SYSTEM browser hosts the journey. - const autoStart = !browser.isElectron && !signingIn && !signInError + // A SUPPORT tab (opened by the console's launch — permitteer docs/desktop-support.md) never + // auto-starts a plain sign-in: that would sign the operator in as THEMSELVES and quietly turn + // the support view into their own account. auth.init drives the ticketed authorize; once the + // session has ended, the tab says so and stops. + const supportTab = oidcIsSupportTab() + const autoStart = !browser.isElectron && !signingIn && !signInError && !supportTab useEffect(() => { if (autoStart) auth.signIn() // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoStart]) + if (supportTab) + return ( + + {initialized ? ( + <> + + Support session ended + + + Close this tab to return to the console. + + + ) : ( + <> + + + Opening the support session… + + + )} + + ) + if (autoStart || (!browser.isElectron && signingIn)) return ( diff --git a/frontend/src/components/ViewAsBanner.tsx b/frontend/src/components/ViewAsBanner.tsx index b6433da3f..41f44ea66 100644 --- a/frontend/src/components/ViewAsBanner.tsx +++ b/frontend/src/components/ViewAsBanner.tsx @@ -4,7 +4,7 @@ import { useSelector, useDispatch } from 'react-redux' import { Box, Typography, IconButton } from '@mui/material' import { State, Dispatch } from '../store' import { Icon } from './Icon' -import { oidcActor } from '../services/oidc' +import { oidcActor, oidcSupportEndsAt } from '../services/oidc' export const ViewAsBanner: React.FC = () => { const { t } = useTranslation() @@ -18,6 +18,10 @@ export const ViewAsBanner: React.FC = () => { // one signal that cannot drift from what the session actually is. const actor = oidcActor() const supportSession = !viewAsUser && !!actor && !!user + // The session's end is the token's expiry (permitteer docs/desktop-support.md): shown so the + // operator knows how long the view lasts — nothing renews it. + const endsAt = oidcSupportEndsAt() + const until = endsAt ? new Date(endsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' if (!viewAsUser && !supportSession) return null const email = viewAsUser?.email || user?.email || '' @@ -46,7 +50,7 @@ export const ViewAsBanner: React.FC = () => { > {supportSession - ? t('viewAsBanner.supportSession', { email, defaultValue: 'Support session — viewing as {{email}}. Tokens are stamped with your identity; the user can see and end this session.' }) + ? t('viewAsBanner.supportSession', { email, until, defaultValue: 'Support session — viewing as {{email}} until {{until}}. Tokens are stamped with your identity; the user can see and end this session.' }) : t('viewAsBanner.viewingAs', { email, defaultValue: 'Viewing as: {{email}}' })} ()({ try { // A boot with ?code&state in the URL IS the sign-in completing (web return, or // the desktop deep-link reload); otherwise restore a stored session. + // A support LAUNCH (permitteer docs/desktop-support.md): this tab arrived with a one-time + // ticket, and the authorize it starts binds the sign-in to the operator's support session. + const ticket = oidcTakeSupportTicket() + if (ticket) { + await oidcStart({ supportTicket: ticket }) + return + } const claims = await oidcCompleteFromUrl() if (claims) await dispatch.auth.handleSignInSuccess(claims) else if (oidcSignedIn()) { @@ -74,7 +81,9 @@ export default createModel()({ const alive = await getToken() if (alive) { await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) - await dispatch.auth.healGrant() + // Never re-authorize a SUPPORT session: a plain authorize in this tab would sign + // the operator in as THEMSELVES and quietly turn the support view into their own. + if (!oidcActor()) await dispatch.auth.healGrant() } else { invalidateOidcToken() // A JUST-ACTIVATED saved account whose refresh family died: one silent @@ -253,11 +262,20 @@ export default createModel()({ // The 401 recovery path (services/post.ts): drop the renderer cache and let the // backend refresh on the next token fetch. If the backend says the session is gone // (refresh family revoked / AS session expired), sign the app out. - async checkSession(options: { refreshToken: boolean; silent?: boolean }, state) { + async checkSession(options: { refreshToken: boolean; silent?: boolean; status?: number }, state) { invalidateOidcToken() + // A SUPPORT session cannot be recovered: no refresh token, and a 401 means the session was + // ended — by the user, by the operator's relaunch, or by its own expiry. The end is the end + // (docs/desktop-support.md). A 403 is an ordinary refused write and changes nothing. + if (oidcActor() && options.status === 401) { + oidcClearLocal() + dispatch.ui.set({ errorMessage: 'Support session ended.' }) + await dispatch.auth.signedOut() + return + } if (!oidcSignedIn() && state.auth.authenticated) { console.error('SESSION ERROR: session gone (refresh family dead or signed out)') - if (!options.silent) dispatch.ui.set({ errorMessage: 'Session expired.' }) + if (!options.silent) dispatch.ui.set({ errorMessage: oidcIsSupportTab() ? 'Support session ended.' : 'Session expired.' }) await dispatch.auth.signedOut() } }, diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index c4fb68bff..12660e935 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -65,11 +65,21 @@ if (window.location.pathname === '/signoutCallback') { // (Module-scope discipline: touch only hoisted consts and the storage APIs here — the // first cut's clearLocal() call hit a temporal dead zone and killed the whole bundle.) const SUPPORT_FLAG = 'oidc.support' -if (new URLSearchParams(window.location.search).has('support_session')) { - try { sessionStorage.setItem(SUPPORT_FLAG, '1') } catch { /* a blocked storage API must not kill the boot */ } - const clean = new URL(window.location.href) - clean.searchParams.delete('support_session') - window.history.replaceState({}, '', clean.toString()) +const SUPPORT_TICKET_KEY = 'oidc.support_ticket' +// Support-session LAUNCH (permitteer docs/desktop-support.md): the console lands this tab on +// the app with a one-time ?support_ticket. The ticket is stashed for the authorize auth.init +// starts (it rides that request as `support_ticket`; the AS binds the sign-in to the operator's +// support session, which also needs the browser's support cookie on the AS origin), the flag +// makes this tab's token store tab-scoped, and the URL is scrubbed so a reload never replays a +// spent ticket. Replaces the earlier `?support_session=1` contract, which the AS no longer sends. +{ + const launch = new URLSearchParams(window.location.search).get('support_ticket') + if (launch) { + try { sessionStorage.setItem(SUPPORT_FLAG, '1'); sessionStorage.setItem(SUPPORT_TICKET_KEY, launch) } catch { /* a blocked storage API must not kill the boot */ } + const clean = new URL(window.location.href) + clean.searchParams.delete('support_ticket') + window.history.replaceState({}, '', clean.toString()) + } } /** The token store for THIS TAB: tab-scoped for a support session, shared otherwise. */ const tokenStore = (): Storage => { @@ -77,7 +87,9 @@ const tokenStore = (): Storage => { } type Flow = { verifier: string; state: string; nonce: string; redirectUri: string } -type Stored = { refresh_token: string; id_token?: string } +/** A support session (`act` in the id_token) has NO refresh token — its one access token IS the + * session, stored so a reload of the support tab survives until it expires. */ +type Stored = { refresh_token?: string; id_token?: string; support?: { access_token: string; exp: number; type?: string } } let access: { [resource: string]: { token: string; exp: number; type?: string } } = {} let refreshing: Promise | undefined @@ -104,7 +116,16 @@ const stored = (): Stored | undefined => { } export const oidcConfigured = () => !!OAUTH_ISSUER -export const oidcSignedIn = () => !!stored()?.refresh_token +const supportLive = (s: Stored | undefined) => !!s?.support && s.support.exp - Math.floor(Date.now() / 1000) > 0 +export const oidcSignedIn = () => { const s = stored(); return !!s?.refresh_token || supportLive(s) } +/** This tab was opened by a support launch (its token store is tab-scoped). */ +export const oidcIsSupportTab = (): boolean => { try { return !!sessionStorage.getItem(SUPPORT_FLAG) } catch { return false } } +/** The launch ticket, ONCE — consumed by the authorize auth.init starts. */ +export function oidcTakeSupportTicket(): string | undefined { + try { const t = sessionStorage.getItem(SUPPORT_TICKET_KEY) ?? undefined; sessionStorage.removeItem(SUPPORT_TICKET_KEY); return t } catch { return undefined } +} +/** When the support session's token — and with it the session — ends (ms), for the banner. */ +export const oidcSupportEndsAt = (): number | undefined => { const s = stored()?.support; return s ? s.exp * 1000 : undefined } export const oidcClaims = (): OidcClaims | undefined => decodeJwt(stored()?.id_token) /** The support-session marker: permitteer stamps `act` (the OPERATOR acting as this * subject) into every token of an impersonated session, the id_token included — the @@ -161,7 +182,7 @@ export function oidcGrantStale(): boolean { } } -export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'none'; loginHint?: string } = {}): Promise { +export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'none'; loginHint?: string; supportTicket?: string } = {}): Promise { const d = await discover() const verifier = randomB64u(48) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) @@ -189,6 +210,8 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'n // chooser — without it, prompt=login lands on the picker and choosing your own account // simply returns you to the same page, which reads as a loop. if (opts.loginHint) params.login_hint = opts.loginHint + // A support launch: the one-time ticket binds THIS authorize to the operator's support session. + if (opts.supportTicket) params.support_ticket = opts.supportTicket if (opts.prompt) { params.prompt = opts.prompt } else if (promptLogin) { @@ -238,7 +261,15 @@ export async function oidcCompleteFromUrl(): Promise { body: new URLSearchParams({ token: previous, token_type_hint: 'refresh_token', client_id: OAUTH_CLIENT_ID }), }).catch(() => {}) } - persist({ refresh_token: body.refresh_token, id_token: body.id_token }) + const at = decodeJwt(body.access_token) + if (claims?.act) { + // A SUPPORT session (docs/desktop-support.md): the AS mints no refresh token, and the access + // token lives exactly as long as the session — so it is stored (tab-scoped) and used until + // it expires; that expiry IS the end of the support session. Never filed as an account. + persist({ id_token: body.id_token, support: { access_token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } }) + } else { + persist({ refresh_token: body.refresh_token, id_token: body.id_token }) + } // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant // is merged from exactly that — so the grant now covers this build. Stamp it — active AND // this account's registry entry, so a later activation restores the right measurement. @@ -249,7 +280,6 @@ export async function oidcCompleteFromUrl(): Promise { if (reg[claims.sub]) { reg[claims.sub].declaration = declarationFingerprint(); writeRegistry(reg) } } } catch { /* non-fatal */ } - const at = decodeJwt(body.access_token) access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return claims } @@ -258,8 +288,13 @@ export async function oidcCompleteFromUrl(): Promise { * single-flight — the rotating single-use refresh makes a concurrent second refresh * token REUSE, which revokes the whole family. */ export async function oidcAccessToken(resource: string = OAUTH_GRAPHQL_RESOURCE): Promise { - const cached = access[resource] const now = Math.floor(Date.now() / 1000) + // A support session's token IS the session: served until it expires (a reload restores it from + // the tab store), never refreshed, and '' — the end — once it is gone. Other audiences have + // nothing to mint from; their features fail closed, as writes do under `act`. + const support = stored()?.support + if (support) return resource === OAUTH_GRAPHQL_RESOURCE && support.exp - now > 0 ? support.access_token : '' + const cached = access[resource] if (cached && cached.exp - now > 30) return cached.token if (!refreshing) refreshing = refresh(resource).finally(() => (refreshing = undefined)) return refreshing diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index 30eb33c79..2925925b8 100644 --- a/frontend/src/services/post.ts +++ b/frontend/src/services/post.ts @@ -81,7 +81,9 @@ export async function apiError(error: unknown) { // on a failure path may end the AS session. Log the URL: it names the offender. console.warn('AUTH-SHAPED API ERROR', { url: error.config?.url, status: error.response?.status }) await sleep(1000 * errorCount * errorCount) - auth.checkSession({ refreshToken: true, silent: true }) + // The status rides along: under a SUPPORT session a 401 is terminal (no refresh token, the + // session is gone) while a 403 is an ordinary refused write — checkSession tells them apart. + auth.checkSession({ refreshToken: true, silent: true, status: error.response?.status }) } } From 288527c3feef5c23caf39a83f90373b113c0fcab Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 2 Sep 2026 15:14:21 -0700 Subject: [PATCH 184/238] chat/portal: the avatar menu learns the accounts signed in on this browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permitteer docs/browser-accounts.md P2. On a fresh portal the menu listed one account — its own registry, filed per login — while the AS's session set already held the others; each appeared only after a trip through the chooser. The AS cookie never reaches this origin, so the account API now serves the set from this token's session (first-party apps only): - The declaration adds `permitteer_account: accounts.read` (the grant heals once per build, no screen for this skip-consent client); the portal fetches /account/api/accounts with its account-audience token after each sign-in and when the menu opens. - Members it holds no tokens for are filed as KNOWN — identity only — and the menu lists them beside the saved ones ("Signed in on this browser"). Picking one is a silent selection (prompt=none + login_hint), no chooser. A known account the AS no longer lists is dropped; saved accounts stay. A support session asks for nothing. - A refused silent selection (signed out elsewhere meanwhile) restores the stored session and says why, instead of stranding a signed-in person on the sign-in screen. Four locales for the one new string. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/AvatarMenu.tsx | 9 +++-- frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + frontend/src/models/auth.ts | 15 ++++++-- frontend/src/services/oidc.ts | 50 ++++++++++++++++++++++++-- 7 files changed, 70 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/AvatarMenu.tsx b/frontend/src/components/AvatarMenu.tsx index 2a91f190e..f560a1b6f 100644 --- a/frontend/src/components/AvatarMenu.tsx +++ b/frontend/src/components/AvatarMenu.tsx @@ -13,7 +13,7 @@ import { ListItemLink } from './ListItemLink' import { isRemoteUI } from '../helpers/uiHelper' import { DesktopUI } from './DesktopUI' import { Avatar } from './Avatar' -import { oidcAccounts } from '../services/oidc' +import { oidcAccounts, oidcRefreshBrowserAccounts } from '../services/oidc' import { emit } from '../services/Controller' const ENTER_DELAY = 300 @@ -25,6 +25,8 @@ const AVATAR_BORDER = 6 export const AvatarMenu: React.FC = () => { const history = useHistory() const [open, setOpen] = useState(false) + // The registry's accounts, re-read after each refresh of the browser's set on open. + const [accounts, setAccounts] = useState(oidcAccounts()) const [altMenu, setAltMenu] = useState(false) const buttonRef = useRef(null) const enterTimer = useRef() @@ -41,6 +43,7 @@ export const AvatarMenu: React.FC = () => { const userAdmin = useSelector((state: State) => state.auth.user?.admin || false) const handleOpen = () => { + void oidcRefreshBrowserAccounts().then(() => setAccounts(oidcAccounts())).catch(() => setAccounts(oidcAccounts())) window.addEventListener('keydown', checkAltMenu) setOpen(true) } @@ -182,13 +185,13 @@ export const AvatarMenu: React.FC = () => { {/* The other accounts this app has signed into (services/oidc.ts registry) — one click makes one active. Emails render as-is (identities are not translated); the AS chooser behind "Switch account" below remains the way to ADD one. */} - {oidcAccounts() + {accounts .filter(a => !a.active) .map(a => ( {(a.name || a.email || '?').charAt(0).toUpperCase()} diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index dfb2f0b22..c48ad3e09 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -1018,6 +1018,7 @@ "settings": "Einstellungen", "signOut": "Abmelden", "signOutConfirm": "Durch das Abmelden kann dieses Gerät übertragen oder ein anderer Benutzer angemeldet werden. Dadurch werden alle Verbindungen beendet.", + "signedInOnBrowser": "In diesem Browser angemeldet", "support": "Support", "supportForum": "Support-Forum", "testUI": "Test-UI", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index f0be06bda..0133428f6 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1042,6 +1042,7 @@ "settings": "Settings", "signOut": "Sign out", "signOutConfirm": "Signing out will allow this device to be transferred or another user to sign in. It will stop all connections.", + "signedInOnBrowser": "Signed in on this browser", "support": "Support", "supportForum": "Support Forum", "testUI": "Test UI", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index e749228da..25c762c37 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -1030,6 +1030,7 @@ "settings": "Configuración", "signOut": "Cerrar sesión", "signOutConfirm": "Al cerrar sesión, este dispositivo podrá transferirse o permitir que otro usuario inicie sesión. Esto detendrá todas las conexiones.", + "signedInOnBrowser": "Con sesión iniciada en este navegador", "support": "Soporte", "supportForum": "Foro de soporte", "testUI": "UI de prueba", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 5afeee7f3..0ad033379 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -1006,6 +1006,7 @@ "settings": "設定", "signOut": "サインアウト", "signOutConfirm": "サインアウトすると、このデバイスの譲渡や他のユーザーのサインインが可能になります。すべての接続が停止します。", + "signedInOnBrowser": "このブラウザでサインイン済み", "support": "サポート", "supportForum": "サポートフォーラム", "testUI": "テストUI", diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index b38dcf877..9ffca4d41 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,7 +9,7 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, OidcClaims } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, oidcRefreshBrowserAccounts, oidcSelectKnownAccount, OidcClaims } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' @@ -97,7 +97,13 @@ export default createModel()({ } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') } catch (error: any) { console.error('AUTH INIT: sign-in completion failed', error) - if (!options.silent) dispatch.auth.set({ signInError: error?.message || 'Sign in failed, please try again.' }) + // A REFUSED silent selection (a known account signed out elsewhere meanwhile) must not + // strand a signed-in person on the sign-in screen: the stored session is intact — restore + // it, say why, and let the menu re-learn the browser's accounts. + if (String(error?.message || '').includes('login_required') && oidcSignedIn() && (await getToken())) { + await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) + dispatch.ui.set({ errorMessage: 'That account is no longer signed in on this browser.' }) + } else if (!options.silent) dispatch.auth.set({ signInError: error?.message || 'Sign in failed, please try again.' }) } } dispatch.auth.set({ initialized: true }) @@ -157,6 +163,9 @@ export default createModel()({ if (oidcClaims()?.sub === sub) return // already active — nothing to do if (oidcActivateAccount(sub)) { window.location.assign('/') + } else if (await oidcSelectKnownAccount(sub)) { + // A KNOWN account (signed in on this browser, not in this app yet): silent selection — + // the AS serves the live set member the hint names, no chooser (docs/browser-accounts.md). } else { await dispatch.auth.switchAccount() } @@ -290,6 +299,8 @@ export default createModel()({ }) await dispatch.auth.fetchUser() console.log('AUTHENTICATED SUCCESS') + // The other accounts signed in on this browser, for the avatar menu — best effort. + void oidcRefreshBrowserAccounts().catch(() => {}) }, async backendAuthenticated(_: void, state) { if (state.auth.authenticated) { diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 12660e935..fac044ddd 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -153,7 +153,9 @@ const redirectUri = () => * stays pure scope-`full` and carries no details, so it is not listed here. */ const DECLARED: Array<{ resource: string; type: string; actions: string[] }> = [ { resource: OAUTH_PASSPORT_RESOURCE, type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, - { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write'] }, + // accounts.read: the OTHER accounts signed in on this browser, served by the account API from + // this token's session — first-party apps only (permitteer docs/browser-accounts.md). + { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write', 'accounts.read'] }, ] /** A stable fingerprint of what this build asks for. Order-insensitive, so reshuffling the @@ -422,14 +424,16 @@ function fileAccount(tokens: Stored) { writeRegistry(reg) } -export type OidcAccount = { sub: string; email?: string; name?: string; picture?: string; active: boolean } +/** `known`: signed in on this BROWSER (the AS's session set) but not in this app yet — no tokens + * here; picking it runs a silent selection instead of a storage swap. */ +export type OidcAccount = { sub: string; email?: string; name?: string; picture?: string; active: boolean; known: boolean } /** The accounts this app has signed into, for the avatar menu. Active first. */ export function oidcAccounts(): OidcAccount[] { const activeSub = oidcClaims()?.sub const reg = readRegistry() return Object.entries(reg) - .map(([sub, e]) => ({ sub, email: e.email, name: e.name, picture: e.picture, active: sub === activeSub })) + .map(([sub, e]) => ({ sub, email: e.email, name: e.name, picture: e.picture, active: sub === activeSub, known: !e.refresh_token && !e.support })) .sort((a, b) => Number(b.active) - Number(a.active) || (a.email ?? a.sub).localeCompare(b.email ?? b.sub)) } @@ -585,3 +589,43 @@ async function tokenRequest(params: { [key: string]: string }): Promise { } return body } + +// --- the browser's accounts (permitteer docs/browser-accounts.md) -------------------------- +// The AS keeps a per-browser session SET, but its cookie never reaches this origin, so the +// account API serves the set from this token's own session. Members this app holds no tokens +// for are filed as KNOWN — identity only — and the menu offers them; picking one is a silent +// selection (prompt=none + login_hint), which the AS answers for any live set member. +const ACCOUNT_RESOURCE = `${OAUTH_ISSUER}/account/api` +export async function oidcRefreshBrowserAccounts(): Promise { + if (oidcActor()) return // a support session is no set member and has nothing to switch to + const url = `${ACCOUNT_RESOURCE}/accounts` + const headers = await oidcAuthHeaders('GET', url, ACCOUNT_RESOURCE) + if (!headers.authorization) return + const r = await fetch(url, { headers }) + if (!r.ok) return + const body = (await r.json()) as { multi?: boolean; accounts?: { sub: string; email?: string | null; name?: string | null; picture?: string | null; current?: boolean }[] } + const listed = new Set() + const reg = readRegistry() + for (const a of body.accounts ?? []) { + if (!a.sub || a.current) continue + listed.add(a.sub) + const prev = reg[a.sub] + reg[a.sub] = { + ...(prev ?? {}), + email: a.email ?? prev?.email, + name: a.name ?? prev?.name, + picture: typeof a.picture === 'string' && /^https:\/\//i.test(a.picture) ? a.picture : prev?.picture, + } + } + // A known-only entry the AS no longer lists was signed out elsewhere: drop it. Saved accounts + // (tokens here) are this app's own and stay. + for (const [sub, e] of Object.entries(reg)) if (!e.refresh_token && !e.support && !listed.has(sub) && sub !== oidcClaims()?.sub) delete reg[sub] + writeRegistry(reg) +} +/** Pick a KNOWN account: silent selection through the AS. False when the sub is not a known entry. */ +export async function oidcSelectKnownAccount(sub: string): Promise { + const e = readRegistry()[sub] + if (!e || e.refresh_token || !e.email) return false + await oidcStart({ prompt: 'none', loginHint: e.email }) + return true +} From aeb9eeaf2f45eefe02f2353a4af3c05d6c26b8f6 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 2 Sep 2026 15:50:17 -0700 Subject: [PATCH 185/238] style(chat): drop the card behind agent messages --- .../src/components/Chat/ChatMessageItem.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index f32cbeda1..f5221f45c 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -55,13 +55,13 @@ export const ChatMessageItem = React.memo(({ message }) => { // strip sits on the card itself. Keep these in step with the `& pre` / // `& th, & td` backgrounds below — a stale color shows as a stray bar. '& pre': scrollbarStyles(theme, { background: 'grayLighter', width: SCROLLBAR_WIDTH_NARROW }), - '& table': scrollbarStyles(theme, { background: 'white', width: SCROLLBAR_WIDTH_NARROW }), + '& table': scrollbarStyles(theme, { background: 'grayLightest', width: SCROLLBAR_WIDTH_NARROW }), }), { - bgcolor: 'white.main', - borderRadius: `${radius.lg}px`, - paddingX: 3, - paddingY: 2, + // No card: the agent's words sit on the panel itself, flush with the + // column edge the user bubble hangs from. The user's turns are then the + // only cards, which is what makes each of them stand out in a long thread. + paddingY: 0.5, fontSize: fontSizes.base, lineHeight: 1.7, wordBreak: 'break-word', @@ -91,6 +91,10 @@ export const ChatMessageItem = React.memo(({ message }) => { '& pre': { overflowX: 'auto', bgcolor: 'grayLighter.main', + // One step of gray against the panel is not enough on its own to read + // as a block; the hairline does that work without darkening the fill. + border: '1px solid', + borderColor: 'grayLight.main', borderRadius: `${radius.lg}px`, padding: 1.5, '& code': { padding: 0, bgcolor: 'transparent' }, @@ -103,18 +107,19 @@ export const ChatMessageItem = React.memo(({ message }) => { marginY: 2, borderRadius: `${radius.sm}px`, }, - // Inverted against the card: the cells carry the fill and the grid is drawn - // in the card's own white, so the rules read as gaps rather than lines. + // The cells carry the fill and the grid is drawn in the panel's own gray, so + // the rules read as gaps rather than lines — the same trick as before, with + // the colors swapped now that the table sits on the panel and not a card. '& th, & td': { - bgcolor: 'grayLightest.main', + bgcolor: 'white.main', border: '1px solid', - borderColor: 'white.main', + borderColor: 'grayLightest.main', paddingX: 1.5, paddingY: 0.5, textAlign: 'left', whiteSpace: 'nowrap', '& code': { - bgcolor: 'white.main', + bgcolor: 'grayLighter.main', }, }, '& blockquote': { From ff55c23be7b56057d64a277518cb2b6b1c612e96 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 2 Sep 2026 15:51:51 -0700 Subject: [PATCH 186/238] docs(chat): record the MCP detail retirement fix and drop the retired name from .env.example --- .env.example | 2 +- .../plans/2026-08-31-ai-agent-license-limit.md | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index ebed52754..bc10d2dd2 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ VITE_OAUTH_GRAPHQL_RESOURCE="https://graphql.dev.remote.it/graphql" VITE_OAUTH_PASSPORT_RESOURCE="https://passport.dev.remote.it/account/api" VITE_OAUTH_AGENT_RESOURCE="https://agent.dev.remote.it" VITE_OAUTH_MCP_RESOURCE="https://mcp.dev.remote.it/mcp" -VITE_OAUTH_MCP_DETAIL="remoteit_mcp_dev" +VITE_OAUTH_MCP_DETAIL="remoteit_mcp" OAUTH_ISSUER="https://login.dev.remote.it" # The GraphQL and WebSocket URLs are NOT set here. They default to the stage named by diff --git a/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md index 9b12cefab..9bfdcb7b3 100644 --- a/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md +++ b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md @@ -111,9 +111,10 @@ frontend, so behaviour changes are verified by driving the running app. ## Related, but not this task -`login.dev.remote.it` currently rejects the client's authorize with -`invalid_authorization_details: unknown authorization_details type "remoteit_mcp_dev"`, so -sign-in on the dev stage fails outright. That is `OAUTH_MCP_DETAIL` not being registered on -that AS. It is unrelated to licensing, but it blocks testing anything on dev, so it likely -needs fixing first. A client-side redirect loop this triggered was fixed in `a0386f40` — -the failure is now reported once instead of retried forever. +On 2026-08-31 the AS retired the `remoteit_mcp_dev` authorization_details type for the +stage-stable `remoteit_mcp`, which broke dev sign-in for any build that pinned the old +name. Fixed in `d646b229`: the type is now discovered from the MCP resource's PRM at +sign-in, with the constant as an offline fallback. If dev sign-in still fails on a machine, +check that its `.env` does not set `VITE_OAUTH_MCP_DETAIL` to the retired name — an env +value overrides the fallback. A client-side redirect loop the retirement triggered was +fixed separately in `a0386f40`; a refused authorize is now reported once, not retried. From 21fa307eb4df221ba137cef9b59054290fb72754 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 2 Sep 2026 17:18:09 -0700 Subject: [PATCH 187/238] chat/portal: view-as lands on THIS portal's origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin user page's "view as" deep-links the console with ?launch=remoteit_portal, and the console fires the launch at once — landing on whichever web redirect URI the client lists first. With app.dev and the app.evan lane both registered, that was list order deciding the lane. The link now names this portal's own origin (&origin=…), which the mint validates against the registration (permitteer docs/support-cookie.md): viewing-as from app.evan opens app.evan, from app.dev opens app.dev. Co-Authored-By: Claude Fable 5.1 --- frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index 2916602b1..8471b3378 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -65,7 +65,10 @@ export const AdminUserDetailPage: React.FC = () => { // The EMAIL is the key both worlds share: permitteer subjects are sub_, not r3 // GUIDs — the authorizer joins them by email — and the console resolves an unknown // deep-link id through its user search (one match opens; else honestly unknown). - windowOpen(`${OAUTH_ISSUER}/admin/console/users/${encodeURIComponent(user.email || user.id)}?launch=remoteit_portal`, '_blank', true) + // The launch lands on THIS portal's origin — the lane the operator is on (app.dev, app.evan, + // latest) — not on whichever redirect URI the client happens to list first + // (permitteer docs/support-cookie.md: the mint validates the origin against the registration). + windowOpen(`${OAUTH_ISSUER}/admin/console/users/${encodeURIComponent(user.email || user.id)}?launch=remoteit_portal&origin=${encodeURIComponent(window.location.origin)}`, '_blank', true) } return ( From 4e3c23464c617c03bb5122a8ef2e3c60c7c94add Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 2 Sep 2026 18:09:16 -0700 Subject: [PATCH 188/238] chat/portal: the support banner's close falls back to the console when the tab cannot close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only a script-opened window may close itself. After a step-up on the way into a support session the console's own tab became the session (the popup had no click behind it), so the banner's X did nothing. Now: close(); if the tab is still here, end this tab's support state (its tab-scoped tokens and flag) and go back to the console's users page — where the operator came from. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/ViewAsBanner.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ViewAsBanner.tsx b/frontend/src/components/ViewAsBanner.tsx index 41f44ea66..087e326cf 100644 --- a/frontend/src/components/ViewAsBanner.tsx +++ b/frontend/src/components/ViewAsBanner.tsx @@ -4,7 +4,8 @@ import { useSelector, useDispatch } from 'react-redux' import { Box, Typography, IconButton } from '@mui/material' import { State, Dispatch } from '../store' import { Icon } from './Icon' -import { oidcActor, oidcSupportEndsAt } from '../services/oidc' +import { oidcActor, oidcSupportEndsAt, oidcClearLocal } from '../services/oidc' +import { OAUTH_ISSUER } from '../constants' export const ViewAsBanner: React.FC = () => { const { t } = useTranslation() @@ -30,8 +31,19 @@ export const ViewAsBanner: React.FC = () => { window.sessionStorage.removeItem('viewAsUser') // Clear from Redux state dispatch.ui.set({ viewAsUser: null }) - // Close the window/tab + // Close the window/tab. Only a script-opened window may close itself — after a step-up on + // the way in, the console's OWN tab became the support session (the popup had no click behind + // it), so close() is a no-op there. Then: end this tab's support state and go back to the + // console, which is where the operator came from (permitteer docs/desktop-support.md). window.close() + window.setTimeout(() => { + if (window.closed) return + if (supportSession) { + oidcClearLocal() + try { window.sessionStorage.removeItem('oidc.support') } catch { /* nothing to clear */ } + window.location.assign(`${OAUTH_ISSUER}/admin/console/users`) + } + }, 150) } return ( From 5cc8b7c328927973f63f7c42ca990bfe56f17778 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 3 Sep 2026 13:33:24 -0700 Subject: [PATCH 189/238] =?UTF-8?q?chat/portal:=20view-as=20is=20a=20navig?= =?UTF-8?q?ation=20into=20the=20AS=20=E2=80=94=20the=20confirm=20page,=20t?= =?UTF-8?q?hen=20the=20portal=20as=20the=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eye button opens ${issuer}/elevate/launch?user=&client=remoteit_portal&origin= in a new window. The AS runs every launch gate on the operator's own session and either opens the portal as the user straight away (elevated) or shows its "confirm it's you" page first — one tap with a factor, or the first factor's set-up in three steps — and opens the portal from there (permitteer docs/as-elevation.md). No admin console pane in between any more. Co-Authored-By: Claude Fable 5.1 --- .../AdminUsersPage/AdminUserDetailPage.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index 8471b3378..55a10329c 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -59,16 +59,18 @@ export const AdminUserDetailPage: React.FC = () => { const handleViewAsUser = () => { // Permitteer lane: view-as is a SUPPORT SESSION, not a header (docs/remoteit-desktop- // login.md Phase 4d). The X-R3-User lane is deliberately dead for these tokens (no - // `delegate` scope is minted), so the eye button deep-links to the operator console's - // user page, whose "open as user" mints the launch behind its own gates: - // kill-switch, operator roster, an MFA-carrying sign-in fresher than ten minutes. - // The EMAIL is the key both worlds share: permitteer subjects are sub_, not r3 - // GUIDs — the authorizer joins them by email — and the console resolves an unknown - // deep-link id through its user search (one match opens; else honestly unknown). - // The launch lands on THIS portal's origin — the lane the operator is on (app.dev, app.evan, - // latest) — not on whichever redirect URI the client happens to list first - // (permitteer docs/support-cookie.md: the mint validates the origin against the registration). - windowOpen(`${OAUTH_ISSUER}/admin/console/users/${encodeURIComponent(user.email || user.id)}?launch=remoteit_portal&origin=${encodeURIComponent(window.location.origin)}`, '_blank', true) + // `delegate` scope is minted), so the eye button is a NAVIGATION into the AS + // (permitteer docs/as-elevation.md): the AS runs every launch gate on the operator's own + // session — the kill-switch, the operator roster, the target (never an operator), and its + // own elevation stamp — then either opens this portal as the user straight away or shows its + // "confirm it's you" page first (one tap with a factor, or the first factor's set-up) and + // opens the portal from there. No admin console in between. + // The EMAIL is the key both worlds share: permitteer subjects are sub_, not r3 GUIDs — + // the authorizer joins them by email — and the AS resolves the user by email or id. + // `origin` names THIS portal — the lane the operator is on (app.dev, app.evan, latest) — so + // the support session lands here rather than on whichever redirect URI the client lists first + // (the AS validates it against the registration). + windowOpen(`${OAUTH_ISSUER}/elevate/launch?user=${encodeURIComponent(user.email || user.id)}&client=remoteit_portal&origin=${encodeURIComponent(window.location.origin)}`) } return ( From 9a6c56860bdad41c1c24ebb1b1039f1690be236e Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 4 Sep 2026 01:04:25 -0700 Subject: [PATCH 190/238] graphql calls present bound tokens with the DPoP scheme + a per-request proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container now ENFORCES the binding the AS stamps (graphql-permitteer docs/dpop-enforcement.md): a cnf-bound token must arrive as 'DPoP ' with a proof over the exact method+URL, and Bearer presentation of a bound token is refused. This app's tokens are bound (requireDpop), but every graphql/REST call flowed through getToken() and went out as Bearer. apiAuthHeaders(method, url) wraps the existing oidcAuthHeaders machinery (already doing DPoP + ath proofs for the account API) with the switcher's api resource; get/post/jobLogs and the two direct axios sites now build the URL first and take scheme-aware headers. getToken() stays for liveness probes and the events subscribe envelope — that wire is frozen and exempt on the container by contract. Co-Authored-By: Claude Fable 5 --- frontend/src/models/auth.ts | 4 ++-- frontend/src/models/user.ts | 4 ++-- frontend/src/services/get.ts | 11 ++++++----- frontend/src/services/jobLogs.ts | 11 ++++++----- frontend/src/services/post.ts | 13 +++++++------ frontend/src/services/remoteit.ts | 14 +++++++++++++- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 9ffca4d41..e5e1e4c3e 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -8,7 +8,7 @@ import { selectDeviceModelAttributes } from '../selectors/devices' import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' -import { getToken } from '../services/remoteit' +import { getToken, apiAuthHeaders } from '../services/remoteit' import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, oidcRefreshBrowserAccounts, oidcSelectKnownAccount, OidcClaims } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' @@ -252,7 +252,7 @@ export default createModel()({ headers: { 'Content-Type': 'application/json', developerKey: DEVELOPER_KEY, - Authorization: await getToken(), + ...(await apiAuthHeaders('POST', `${API_URL}/user/email/`)), }, } ) diff --git a/frontend/src/models/user.ts b/frontend/src/models/user.ts index 28ebe2913..bca1d4a51 100644 --- a/frontend/src/models/user.ts +++ b/frontend/src/models/user.ts @@ -5,7 +5,7 @@ import { graphQLNotificationSettings, graphQLSetAttributes, graphQLLeaveReseller import { graphQLUser } from '../services/graphQLRequest' import { RootModel } from '.' import i18n, { LanguageMode } from '../i18n' -import { getToken } from '../services/remoteit' +import { apiAuthHeaders } from '../services/remoteit' type IUserState = { id: string @@ -89,7 +89,7 @@ export default createModel()({ headers: { 'Content-Type': 'application/json', developerKey: DEVELOPER_KEY, - Authorization: await getToken(), + ...(await apiAuthHeaders('POST', `${API_URL}/user/language/`)), }, } ) diff --git a/frontend/src/services/get.ts b/frontend/src/services/get.ts index 7d40bff5a..7bab68f25 100644 --- a/frontend/src/services/get.ts +++ b/frontend/src/services/get.ts @@ -1,19 +1,20 @@ import axios from 'axios' import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { getToken } from './remoteit' +import { apiAuthHeaders } from './remoteit' import { apiError } from './post' import { store } from '../store' export async function get(path: string = '') { if (store.getState().ui.offline) return - const token = await getToken() - if (!token) { + const url = getApiURL() + path + const auth = await apiAuthHeaders('GET', url) + if (!auth.authorization) { console.warn('Unable to get token for API request.') return } - const headers: any = { Authorization: token, ...getTestHeader() } + const headers: any = { ...auth, ...getTestHeader() } // Add x-r3-user header if in view-as mode const viewAsUser = store.getState().ui.viewAsUser @@ -22,7 +23,7 @@ export async function get(path: string = '') { } const request = { - url: getApiURL() + path, + url, method: 'get', headers, } diff --git a/frontend/src/services/jobLogs.ts b/frontend/src/services/jobLogs.ts index 6da420f30..a05b33d82 100644 --- a/frontend/src/services/jobLogs.ts +++ b/frontend/src/services/jobLogs.ts @@ -1,6 +1,6 @@ import axios from 'axios' import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { getToken } from './remoteit' +import { apiAuthHeaders } from './remoteit' import { store } from '../store' export type DeviceLogEntry = { @@ -36,17 +36,18 @@ export type GetJobLogsResult = * for transient operational failures and gave users no recovery path. */ export async function getJobLogs(jobId: string): Promise { - const token = await getToken() - if (!token) { + const url = `${getApiURL()}/job/log/all/${jobId}` + const auth = await apiAuthHeaders('GET', url) + if (!auth.authorization) { return { kind: 'error', status: 401, message: 'Not signed in' } } - const headers: any = { Authorization: token, ...getTestHeader() } + const headers: any = { ...auth, ...getTestHeader() } const viewAsUser = store.getState().ui.viewAsUser if (viewAsUser) headers['X-R3-User'] = viewAsUser.id try { - const response = await axios.get(`${getApiURL()}/job/log/all/${jobId}`, { headers }) + const response = await axios.get(url, { headers }) return { kind: 'ok', data: response?.data as JobLogsResponse } } catch (err: any) { const status: number | undefined = err?.response?.status diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index 2925925b8..24f8b13d3 100644 --- a/frontend/src/services/post.ts +++ b/frontend/src/services/post.ts @@ -1,6 +1,6 @@ import axios from 'axios' import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { getToken } from './remoteit' +import { apiAuthHeaders } from './remoteit' import { store } from '../store' import network from './Network' import sleep from '../helpers/sleep' @@ -14,13 +14,14 @@ export function resetErrorCount() { export async function post(data: ILookup = {}, path: string = '') { if (store.getState().ui.offline) return - const token = await getToken() - if (!token) { + const url = getApiURL() + path + const auth = await apiAuthHeaders('POST', url) + if (!auth.authorization) { console.warn('Unable to get token for API request.', data) return } - - const headers: any = { Authorization: token, ...getTestHeader() } + + const headers: any = { ...auth, ...getTestHeader() } // Add x-r3-user header if in view-as mode const viewAsUser = store.getState().ui.viewAsUser @@ -29,7 +30,7 @@ export async function post(data: ILookup = {}, path: string = '') { } const request = { - url: getApiURL() + path, + url, method: 'post' as 'post', headers, data, diff --git a/frontend/src/services/remoteit.ts b/frontend/src/services/remoteit.ts index 2767aea03..426211bc6 100644 --- a/frontend/src/services/remoteit.ts +++ b/frontend/src/services/remoteit.ts @@ -1,4 +1,4 @@ -import { oidcAccessToken } from './oidc' +import { oidcAccessToken, oidcAuthHeaders } from './oidc' import { getApiResource } from '../helpers/apiHelper' /** @@ -16,3 +16,15 @@ export async function getToken(): Promise { export async function hasCredentials() { return !!(await oidcAccessToken()) } + +/** Scheme-aware auth headers for a graphql/REST call (permitteer docs — the container now + * ENFORCES the DPoP binding: a bound token must arrive as `DPoP ` with a proof over + * this exact method+URL, and presenting it as Bearer is refused). Same machinery the account + * API calls already use (oidcAuthHeaders); resolves to {} when signed out — callers no-op on + * a missing authorization, exactly as they did on an empty getToken(). getToken() itself + * stays for liveness probes and the events subscribe envelope (in-band bearer, exempt by the + * frozen wire contract). + */ +export async function apiAuthHeaders(method: string, url: string): Promise> { + return await oidcAuthHeaders(method, url, getApiResource()) +} From 17a317611d50ca2e0ab7071385802a854f0fccfc Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 4 Sep 2026 08:54:35 -0700 Subject: [PATCH 191/238] the account menu follows the AS's answer, and says so when it cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The avatar menu was rendering its local cache with the AS's reply as a soft overlay: the reconcile in oidcRefreshBrowserAccounts pruned only identity-only entries, so an account this app held tokens for stayed on the menu forever — even after the AS stopped listing it. Reported from the field as a row that could not be switched to: its session had ended elsewhere, the click swapped in tokens whose family died with it, the prompt=none recovery found no live set member, and the person landed on the sign-in screen. The /accounts call was a clean 200 the whole time, which is why nothing looked wrong. - The reconcile is authoritative on a SUCCESSFUL refresh: an entry the AS does not list is dropped whether or not we hold its tokens. Guarded by `multi` — with multi-account off the AS answers "just you" by design, and pruning on that would sign out every saved account. The active account and tab-scoped support entries are held out. - oidcRefreshBrowserAccounts returns its outcome instead of void, and a refused call now warns. It used to `return` silently, so a 401/403 left stale accounts on screen with nothing in the console — the same picture as a correct empty set. - oidcAuthHeaders warns when it degrades a cnf-bound token to Bearer (no WebCrypto/IndexedDB, typically private browsing). The AS then refuses per RFC 9449, and that 401 arrived with no hint the cause was a missing key rather than a dead session. No Electron special case: the desktop app runs its AS journey in the SYSTEM browser (the main process bounces issuer-origin navigations out), so its session joins that browser's set and browserSet is stamped normally — the same reconcile is correct there. Pinned end to end in r3/e2e-tests multi-account.spec.ts: the offered rows must equal what the AS reports, before and after a switch, and an account it no longer lists must not be offered from cache. Verified against the real module across six states incl. a 401 and a 403. Co-Authored-By: Claude Opus 5 --- frontend/src/services/oidc.ts | 57 ++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index fac044ddd..35027b0d6 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -569,6 +569,12 @@ export async function oidcAuthHeaders(method: string, url: string, resource: str if (access[resource]?.type === 'DPoP') { const proof = await dpopProof(method, url, token) if (proof) return { authorization: `DPoP ${token}`, DPoP: proof } + // A bound token with no proof to present. Falling through to Bearer is deliberate — the AS + // decides, and it will refuse (RFC 9449) — but that refusal arrives as a bare 401 with no + // hint that the cause was a missing key rather than a dead session. Name it here, because + // this is the only place that knows. Reached when WebCrypto/IndexedDB are unavailable, + // which on a phone usually means private browsing. + console.warn(`AUTH: no DPoP proof available for ${resource} — presenting a sender-constrained token as Bearer, which the AS will refuse`) } return { authorization: `Bearer ${token}` } } @@ -596,13 +602,26 @@ async function tokenRequest(params: { [key: string]: string }): Promise { // for are filed as KNOWN — identity only — and the menu offers them; picking one is a silent // selection (prompt=none + login_hint), which the AS answers for any live set member. const ACCOUNT_RESOURCE = `${OAUTH_ISSUER}/account/api` -export async function oidcRefreshBrowserAccounts(): Promise { - if (oidcActor()) return // a support session is no set member and has nothing to switch to + +/** Why a refresh did not happen. `refused` is the one that used to be invisible: the menu + * kept rendering its cache while the AS was turning the call away, so a stale list and a + * broken one looked identical — on screen and in the console. */ +export type BrowserAccountsRefresh = + | { ok: true; multi: boolean } + | { ok: false; reason: 'support-session' | 'no-token' | 'refused'; status?: number } + +export async function oidcRefreshBrowserAccounts(): Promise { + if (oidcActor()) return { ok: false, reason: 'support-session' } // no set member, nothing to switch to const url = `${ACCOUNT_RESOURCE}/accounts` const headers = await oidcAuthHeaders('GET', url, ACCOUNT_RESOURCE) - if (!headers.authorization) return + if (!headers.authorization) return { ok: false, reason: 'no-token' } const r = await fetch(url, { headers }) - if (!r.ok) return + if (!r.ok) { + // Never silently: an unreconciled menu is showing accounts that may not exist and hiding + // ones that do, and the person has no way to tell. Say so where a bug report can find it. + console.warn(`AUTH: the browser's accounts could not be refreshed (${r.status}) — the menu is showing its last known list`) + return { ok: false, reason: 'refused', status: r.status } + } const body = (await r.json()) as { multi?: boolean; accounts?: { sub: string; email?: string | null; name?: string | null; picture?: string | null; current?: boolean }[] } const listed = new Set() const reg = readRegistry() @@ -617,10 +636,34 @@ export async function oidcRefreshBrowserAccounts(): Promise { picture: typeof a.picture === 'string' && /^https:\/\//i.test(a.picture) ? a.picture : prev?.picture, } } - // A known-only entry the AS no longer lists was signed out elsewhere: drop it. Saved accounts - // (tokens here) are this app's own and stay. - for (const [sub, e] of Object.entries(reg)) if (!e.refresh_token && !e.support && !listed.has(sub) && sub !== oidcClaims()?.sub) delete reg[sub] + // The AS has just told us who is signed in on this browser, so that answer WINS: a member it + // no longer lists was signed out elsewhere or has expired, and an entry we keep is one the + // menu offers. Keeping a saved-but-unlisted account is what put a dead row on the menu — + // activating it cannot work (its session, and with it its refresh family, is gone), so the + // click swaps in tokens that fail and drops the person on the sign-in screen. + // + // The `multi` guard is the one thing that must not be reconciled away: with multi-account + // switched off the AS answers "just you" by design, not "everyone else is gone", and pruning + // on that would sign out every account this app has saved. Identity-only entries carry no + // such risk — they exist only because some earlier answer named them. + // + // The active account is never in `listed` (it arrives as `current` and is skipped above), so + // it is held out explicitly. Support entries live in a tab-scoped store and are not members. + // + // One case this deliberately treats as "gone": a session the AS's write cap evicted from the + // set (SESSION_SET_CAP = 10) stays LIVE but stops being part of this browser, and nothing in + // the answer distinguishes it from a sign-out. Dropping it costs one "Switch account" to get + // back, needs an eleventh account on one browser to happen at all, and the alternative is the + // dead row this whole change exists to remove. + const reportsSet = body.multi === true + const activeSub = oidcClaims()?.sub + for (const [sub, e] of Object.entries(reg)) { + if (e.support || listed.has(sub) || sub === activeSub) continue + if (e.refresh_token && !reportsSet) continue + delete reg[sub] + } writeRegistry(reg) + return { ok: true, multi: reportsSet } } /** Pick a KNOWN account: silent selection through the AS. False when the sub is not a known entry. */ export async function oidcSelectKnownAccount(sub: string): Promise { From e47324abc96d9a4b29fcf8d8cfbf7ae1f1c3826f Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 4 Sep 2026 11:19:16 -0700 Subject: [PATCH 192/238] the account menu drops a saved account only when the AS answered from the browser itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prune added in 17a31761 assumed the AS's account list was an authoritative statement about the browser. It was not: the list came from a snapshot copied onto each session row, so it could be a copy that predates the others — and a client had no way to tell that from a correct answer. The failure mode is the worst available one: silently deleting an account the person is still signed into, whose tokens still work. The AS now says which it is (permitteer docs/browser-id-plan.md — the browser has an id, and membership is looked up by it rather than read out of whichever row held the token). Saved accounts are dropped only when `authoritative` AND `multi` are both true: - not authoritative — the AS fell back to a snapshot, so the list is a copy, not the membership; - multi off — the AS answers "just you" by design, not "everyone else is gone"; - an AS that says neither (an older deployment) never prunes saved entries, which is exactly the behaviour before that commit. Identity-only entries keep pruning on any successful answer either way: they exist only because some earlier answer named them, so nothing of the app's own is lost. Verified against the real module across seven states, including the one this exists for: a 200 that is NOT authoritative leaves a saved account alone while still dropping an identity-only stray. Co-Authored-By: Claude Opus 5 --- frontend/src/services/oidc.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 35027b0d6..085601d62 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -607,7 +607,7 @@ const ACCOUNT_RESOURCE = `${OAUTH_ISSUER}/account/api` * kept rendering its cache while the AS was turning the call away, so a stale list and a * broken one looked identical — on screen and in the console. */ export type BrowserAccountsRefresh = - | { ok: true; multi: boolean } + | { ok: true; multi: boolean; authoritative: boolean } | { ok: false; reason: 'support-session' | 'no-token' | 'refused'; status?: number } export async function oidcRefreshBrowserAccounts(): Promise { @@ -622,7 +622,7 @@ export async function oidcRefreshBrowserAccounts(): Promise() const reg = readRegistry() for (const a of body.accounts ?? []) { @@ -642,10 +642,14 @@ export async function oidcRefreshBrowserAccounts(): Promise { From 7aa0f053dc5dffe6095e2bcd061d8a945c487655 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 4 Sep 2026 16:19:50 -0700 Subject: [PATCH 193/238] =?UTF-8?q?user=20model:=20a=20login=20payload=20w?= =?UTF-8?q?ithout=20an=20account=20writes=20nothing=20=E2=80=94=20and=20Gu?= =?UTF-8?q?ideBubble=20survives=20a=20persisted=20null=20created=20date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stage switch that failed the account query wrote `{created: Invalid Date}` into the user model; redux-persist serialised it as `created: null`, and every later boot threw in GuideBubble's selector (`state.user.created.getTime()`) during the first render — before the next login could overwrite it, and clearing localStorage did not help because the persisted store lives in IndexedDB. Two fixes: parse() returns nothing when the payload carries no account, and stores `created` only when it is a valid date; GuideBubble reads the date defensively (NaN for anything that is not a Date), which the existing NaN guard already treats as "unknown". A browser holding the bad value now boots, logs in, and is healed by the next successful account fetch. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/GuideBubble.tsx | 11 +++++++---- frontend/src/models/user.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/GuideBubble.tsx b/frontend/src/components/GuideBubble.tsx index 677a1ec00..b88b4efcd 100644 --- a/frontend/src/components/GuideBubble.tsx +++ b/frontend/src/components/GuideBubble.tsx @@ -129,10 +129,13 @@ export const GuideBubble: React.FC = ({ const cohortExpired = useSelector((state: State) => { // An explicit "Reset interactive guides" re-anchors the cohort to the reset // moment, so even accounts that predate the guides get onboarded again. - // `created` is new Date(apiValue) and is NaN if the account payload has no - // created date — guard it, or Math.max would return NaN and every - // comparison below would be false, silently ungating every bubble. - const created = state.user.created.getTime() + // `created` may be NaN (no created date in the account payload) or, in a browser that + // persisted a failed login before models/user.ts parse() refused to store one, null — + // redux-persist writes an invalid date as null and the date transform leaves null alone. + // Either reads as "unknown" (0): a throw here took the whole app down on every boot, and + // Math.max with NaN would silently ungate every bubble. + const createdDate = state.user.created + const created = createdDate instanceof Date ? createdDate.getTime() : NaN const cohortAnchor = Math.max(Number.isNaN(created) ? 0 : created, state.ui.guidesResetDate || 0) return startDate.getTime() > cohortAnchor && !state.ui.testUI }) diff --git a/frontend/src/models/user.ts b/frontend/src/models/user.ts index bca1d4a51..cb465649e 100644 --- a/frontend/src/models/user.ts +++ b/frontend/src/models/user.ts @@ -48,10 +48,18 @@ export default createModel()({ async parse(result: AxiosResponse | undefined) { const data = result?.data?.data?.login?.account console.log('USER DATA', data) + // No account in the payload (a failed or foreign-stage query) is NOT a user: writing + // `{created: Invalid Date}` into state persisted as `created: null` (redux-persist + // serialises an invalid date to null), and every later boot crashed on it before the + // next login could overwrite it. Return nothing, so nothing is set. + if (!data) return undefined + const created = data.created ? new Date(data.created) : undefined return { ...data, - created: new Date(data?.created), - attributes: data?.attributes?.$remoteit, + // Only a VALID date replaces the one in state; an absent or unparseable value leaves + // it alone (the default is the epoch, which every reader already treats as "unknown"). + ...(created && !isNaN(created.getTime()) ? { created } : {}), + attributes: data.attributes?.$remoteit, } }, async leaveReseller() { From 4289753d34bb782991aa86135e916c9abbfbf1c4 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 4 Sep 2026 21:18:06 -0700 Subject: [PATCH 194/238] =?UTF-8?q?refresh=20under=20a=20cross-tab=20lock?= =?UTF-8?q?=20=E2=80=94=20two=20tabs=20were=20double-spending=20one=20refr?= =?UTF-8?q?esh=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh token rotates single-use and lives in localStorage, which every tab of the origin shares; the single-flight guard next to it is module-scoped, so it stops a tab racing ITSELF and nothing else. Two tabs therefore read the same token, both redeem it, and the AS — correctly — reads a double-spend as theft: it revokes the family and mails the person "A sign-in was ended as a precaution". Dev logged two reuse_detected events 4ms apart on one session and client (2026-09-04), which is concurrency, not a stolen credential replayed later. The AS is not what should change. Its own comment is right that racing must answer identically to replaying, or racing becomes the way to evade detection. The defect is client-side. So refresh() now holds navigator.locks('oidc.refresh') around the redeem, and the body moves to refreshOnce() unchanged. That the body reads `stored()` at its top is what makes it correct rather than merely serialised: the read now happens INSIDE the lock, so a tab that waited redeems the successor the winner just wrote instead of the token it saw before waiting — which would be the same double-spend. The waiter pays one extra rotation and nobody pays an alarm. The wait is bounded at 10s because tokenRequest has no timeout: a hung fetch holds the lock, and unbounded that would stall EVERY tab where today it stalls only the one. On timeout — or where Web Locks is absent (older webviews, non-secure contexts) — it proceeds unlocked, which is exactly today's behaviour, so the fallback is never worse than the status quo. The lock is global rather than per-resource: one refresh token backs every audience, so two tabs refreshing for DIFFERENT resources still redeem the same credential. Proved by driving the real module as two module instances over one shared store, against a fake AS that enforces the rotation rule. Without the lock: redeems=2, reuse_detected=1, neither tab ends up with a token. With it: redeems=2, reuse_detected=0, both tabs served. Does NOT address the ChatGPT Codex connector (183 events on robo+ent@remote.it in two days), which is a separate client and the dominant source of these alarms. Co-Authored-By: Claude Opus 5 --- frontend/src/services/oidc.ts | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 085601d62..fd12ec527 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -302,7 +302,43 @@ export async function oidcAccessToken(resource: string = OAUTH_GRAPHQL_RESOURCE) return refreshing } +// CROSS-TAB single-flight. The `refreshing` guard above stops a tab racing itself, but the refresh +// token lives in localStorage and every tab of this origin shares it — so two tabs redeem the SAME +// token, the AS sees a double-spend and answers as theft: the family is revoked and the person is +// mailed "A sign-in was ended as a precaution". Not hypothetical — dev logged two reuse_detected +// events 4ms apart on one session/client (2026-09-04), and the AS is right to do it: treating a +// race more leniently than a replay would make racing the way to evade detection. +// +// The lock makes the redeem exclusive; the RE-READ is what makes it correct. refreshOnce reads the +// stored token AFTER the lock is held, so a tab that waited redeems the successor the winner just +// wrote instead of the token it saw before waiting — which would be the very double-spend this +// exists to prevent. Costs the waiter one extra rotation; costs nobody an alarm. +// +// Bounded wait, because tokenRequest has no timeout: a hung fetch holds the lock, and without a +// bound that would stall EVERY tab where today it stalls only the one. On timeout we proceed +// unlocked — which is exactly today's behaviour, so the fallback can only be as bad as the status +// quo, never worse. Same for an environment without Web Locks (older webviews, non-secure +// contexts): run unlocked rather than not at all. +const REFRESH_LOCK = 'oidc.refresh' +const REFRESH_LOCK_WAIT_MS = 10_000 + async function refresh(resource: string): Promise { + const locks = (navigator as { locks?: { request: Function } } | undefined)?.locks + if (!locks?.request || typeof AbortSignal?.timeout !== 'function') return refreshOnce(resource) + try { + return await locks.request(REFRESH_LOCK, { signal: AbortSignal.timeout(REFRESH_LOCK_WAIT_MS) }, () => + refreshOnce(resource) + ) + } catch (error: any) { + // Only the WAIT aborts here — refreshOnce swallows its own failures and returns ''. Waiting + // longer than the bound means some tab is wedged mid-refresh; go ahead unlocked rather than + // leave this tab unable to call anything. + console.warn('OIDC REFRESH LOCK: proceeding unlocked —', error?.name || error?.message) + return refreshOnce(resource) + } +} + +async function refreshOnce(resource: string): Promise { const current = stored() if (!current?.refresh_token) return '' try { From fd75750b8355c01b3fddb69945905dbeff66dc20 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sat, 5 Sep 2026 04:57:41 -0700 Subject: [PATCH 195/238] =?UTF-8?q?oidc:=20a=20sign-in=20that=20completes?= =?UTF-8?q?=20in=20another=20tab=20finishes=20=E2=80=94=20the=20flow=20rec?= =?UTF-8?q?ord=20is=20mirrored=20to=20localStorage=20by=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PKCE flow (verifier, state, nonce, redirect URI) lived only in per-tab sessionStorage. A sign-in that STARTS in one tab and COMPLETES in another — what every email link does: signup's set-password link, a password reset — arrived at the callback in a fresh tab with an empty sessionStorage and a state the first tab minted, and the portal answered "Sign-in state mismatch — try again." Every self-signup through the portal ended there. The record now ALSO goes to localStorage keyed by its state (single-use, pruned after 24h — the set-password link's own life), and the completion takes this tab's record first, then the shared one. A support tab keeps its flow tab-scoped, like its tokens. No token ever rides the record. Co-Authored-By: Claude Fable 5.1 --- frontend/src/services/oidc.ts | 57 +++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index fd12ec527..a4475d5e4 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -5,7 +5,8 @@ import { OAUTH_ISSUER, OAUTH_CLIENT_ID, OAUTH_GRAPHQL_RESOURCE, OAUTH_PASSPORT_R * The renderer-owned OIDC client (permitteer docs/remoteit-desktop-login.md, D8): * IDENTICAL on web and desktop — the backend is for machine-local concerns, never auth. * - * Flow: authorize redirect with PKCE (verifier/state in sessionStorage) → the app + * Flow: authorize redirect with PKCE (verifier/state in sessionStorage, mirrored to a + * state-keyed localStorage record so another tab can finish it — see rememberFlow) → the app * (re)boots with ?code&state in its URL → exchange completes here. The only per-shell * difference is how the code returns: the page's own /authCallback URL on web; the * remoteit://authCallback deep link reloading the window with the same query on packaged @@ -26,6 +27,15 @@ export type OidcClaims = { } const FLOW_KEY = 'oidc.flow' +// A flow's record ALSO goes to localStorage, keyed by its state, so a sign-in that completes in +// ANOTHER tab of this browser can finish it — the email-link case: signup's set-password link +// (or a password reset) opens in a fresh tab whose sessionStorage is empty, and the AS then sends +// that tab to the callback carrying a state the first tab minted. Until 2026-09-05 that ended in +// "Sign-in state mismatch — try again." for every self-signup. sessionStorage stays the same-tab +// fast path; the shared record is the fallback, single-use, pruned after FLOW_TTL_MS. A support +// tab keeps its flow tab-scoped, like its tokens — a support launch never arrives by email. +const FLOW_SHARED_PREFIX = 'oidc.flow:' +const FLOW_TTL_MS = 24 * 60 * 60 * 1000 // the set-password link's own life (Passport mints it for 24h) const TOKENS_KEY = 'oidc.tokens' // What the grant behind those tokens was last written from (see oidcGrantStale). const DECLARATION_KEY = 'oidc.declaration' @@ -87,6 +97,43 @@ const tokenStore = (): Storage => { } type Flow = { verifier: string; state: string; nonce: string; redirectUri: string } + +function rememberFlow(flow: Flow): void { + sessionStorage.setItem(FLOW_KEY, JSON.stringify(flow)) + if (oidcIsSupportTab()) return + try { + const now = Date.now() + for (const key of Object.keys(localStorage)) { + if (!key.startsWith(FLOW_SHARED_PREFIX)) continue + let at = 0 + try { at = (JSON.parse(localStorage.getItem(key) || '{}') as { at?: number }).at ?? 0 } catch { /* unreadable: drop it */ } + if (now - at > FLOW_TTL_MS) localStorage.removeItem(key) + } + localStorage.setItem(FLOW_SHARED_PREFIX + flow.state, JSON.stringify({ ...flow, at: now })) + } catch { /* a blocked storage API leaves the same-tab path intact */ } +} + +/** The flow a callback's `state` belongs to: this tab's, else one another tab of this browser + * started (the email-link case). Single-use either way — both records are cleared. */ +function takeFlow(state: string): Flow | undefined { + let flow: Flow | undefined + try { + const raw = sessionStorage.getItem(FLOW_KEY) + sessionStorage.removeItem(FLOW_KEY) + const own: Flow | undefined = raw ? JSON.parse(raw) : undefined + if (own?.state === state) flow = own + } catch { /* fall through to the shared record */ } + try { + const key = FLOW_SHARED_PREFIX + state + const raw = localStorage.getItem(key) + localStorage.removeItem(key) + if (!flow && raw) { + const shared = JSON.parse(raw) as Flow & { at?: number } + if (Date.now() - (shared.at ?? 0) <= FLOW_TTL_MS) flow = shared + } + } catch { /* nothing shared */ } + return flow +} /** A support session (`act` in the id_token) has NO refresh token — its one access token IS the * session, stored so a reload of the support tab survives until it expires. */ type Stored = { refresh_token?: string; id_token?: string; support?: { access_token: string; exp: number; type?: string } } @@ -189,7 +236,7 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'n const verifier = randomB64u(48) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) const flow: Flow = { verifier, state: randomB64u(16), nonce: randomB64u(16), redirectUri: redirectUri() } - sessionStorage.setItem(FLOW_KEY, JSON.stringify(flow)) + rememberFlow(flow) const url = new URL(d.authorization_endpoint) const params: { [key: string]: string } = { client_id: OAUTH_CLIENT_ID, @@ -232,11 +279,9 @@ export async function oidcCompleteFromUrl(): Promise { const state = query.get('state') if (!state || !(query.get('code') || query.get('error'))) return undefined - const raw = sessionStorage.getItem(FLOW_KEY) - sessionStorage.removeItem(FLOW_KEY) + const flow = takeFlow(state) cleanUrl() - const flow: Flow | undefined = raw ? JSON.parse(raw) : undefined - if (!flow || flow.state !== state) throw new Error('Sign-in state mismatch — try again.') + if (!flow) throw new Error('Sign-in state mismatch — try again.') const error = query.get('error') if (error) throw new Error(query.get('error_description') || error) From be5fe88daec381f6b3c53f756d9893d62a9d9710 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 6 Sep 2026 14:57:48 -0700 Subject: [PATCH 196/238] note why the unified front also skips minting a socket audience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing changes: wss://cloud..remote.it/api/ws does not match EVENTS_RESOURCE, so the socket already presents the graphql-resource token — which on that front is the RIGHT audience, because the socket lives inside the merged /api resource rather than being its own. The comment said the fallback existed for the legacy shared-domain URL and its dual-accept window. That is still true and no longer the only reason, and a reader acting on the old comment would widen the pattern to cover cloud.* — which would make the portal mint for a resource that does not exist and fail invalid_target. That is precisely how the e2e suite found the same assumption on its own side today, so the warning is written down rather than left to be rediscovered. Co-Authored-By: Claude Opus 5 --- frontend/src/services/cloudController.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/services/cloudController.ts b/frontend/src/services/cloudController.ts index bf0992215..13322df7e 100644 --- a/frontend/src/services/cloudController.ts +++ b/frontend/src/services/cloudController.ts @@ -29,12 +29,20 @@ import { emit } from './Controller' const stateTimes = new CloudTimes() const connectTimes = new CloudTimes() -// D11a (permitteer docs/remoteit-desktop-login.md Phase 4c): the events stream is its OWN -// audience — for the per-stage fronts the WS URL IS the resource identifier -// (wss://ws..remote.it/v1; prod's bare wss://ws.remote.it/v1). The legacy -// shared-domain URL (wss://ws.remote.it/) is not a registered resource, so -// connections through it keep presenting the graphql-audience token — admitted by the -// authorizer's dual-accept window until that contract retires. +// D11a (permitteer docs/remoteit-desktop-login.md Phase 4c): the events stream is SOMETIMES its own +// audience. Where it is, the WS URL IS the resource identifier (wss://ws..remote.it/v1; +// prod's bare wss://ws.remote.it/v1) and we mint for it. Where it is NOT, we present the graphql +// token instead — and there are now two such cases, for different reasons: +// +// * the legacy shared-domain URL (wss://ws.remote.it/), which is not a registered resource +// at all, admitted by the authorizer's dual-accept window until that contract retires; +// * the UNIFIED FRONT (wss://cloud..remote.it/api/ws), where the socket lives INSIDE the +// merged /api resource — so the graphql token is not a stand-in, it is the right audience +// (graphql-permitteer docs/CLOUD-EDGE.md). +// +// Do NOT widen this pattern to match the unified front. Minting for the socket URL there asks the AS +// for a resource that does not exist and fails `invalid_target` — which is exactly how the e2e +// suite discovered the same assumption on its own side. const EVENTS_RESOURCE = /^wss:\/\/ws(\.[a-z0-9-]+)?\.remote\.it\/v1$/ async function wsAuthorization(): Promise { const url = getWebSocketURL() || '' From 7c038d004a5622a2b2cecd50b00c97ebb8fea4f3 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 6 Sep 2026 20:31:56 -0700 Subject: [PATCH 197/238] the dev fallback resource is the unified front, not the host that was destroyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAUTH_GRAPHQL_RESOURCE's default named https://graphql.dev.remote.it/graphql, and that host was destroyed 2026-09-06 with the other three per-stage dev fronts (graphql-permitteer docs/CLOUD-EDGE.md). app.dev's Amplify variable was repointed at cloud.dev.remote.it/api the same day, so the deployed portal was fine — but the default is what every build WITHOUT that variable gets, which is a local `npm start`, an Electron build, and any Amplify branch nobody updated. The identifier is the TREE, not a graphql URL: /api covers graphql, the user REST surface and the events socket behind one audience. It is deliberately NOT the socket's own identifier — see the warning in cloudController.ts, which this does not change. Timing matters here: https://graphql.dev.remote.it/graphql is being retired from the AS registry, so a build still falling back to it stops getting a 404 from DNS and starts getting invalid_target from the mint — a failure that reads like a broken client rather than a stale default. Co-Authored-By: Claude Opus 5 --- .env.example | 2 +- frontend/src/constants.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index da9d89d2c..1eb3579c5 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ # Electron main process, which only bounces issuer navigations to the system browser. VITE_OAUTH_ISSUER="https://login.dev.remote.it" VITE_OAUTH_CLIENT_ID="remoteit_desktop" -VITE_OAUTH_GRAPHQL_RESOURCE="https://graphql.dev.remote.it/graphql" +VITE_OAUTH_GRAPHQL_RESOURCE="https://cloud.dev.remote.it/api" OAUTH_ISSUER="https://login.dev.remote.it" BRAND=remoteit AIRBRAKE_PROJECT_KEY="..." diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 8b84aa104..e3d9f7188 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -6,7 +6,12 @@ export const MODE = env.MODE || 'development' // web and desktop; the backend never touches auth. export const OAUTH_ISSUER = env.VITE_OAUTH_ISSUER || '' export const OAUTH_CLIENT_ID = env.VITE_OAUTH_CLIENT_ID || 'remoteit_desktop' -export const OAUTH_GRAPHQL_RESOURCE = env.VITE_OAUTH_GRAPHQL_RESOURCE || 'https://graphql.dev.remote.it/graphql' +// The dev stage's UNIFIED FRONT (graphql-permitteer docs/CLOUD-EDGE.md). The identifier is the +// TREE, not the graphql URL: /api covers graphql, the user REST surface and the events socket, so +// one token serves all three. Was https://graphql.dev.remote.it/graphql until 2026-09-06, when +// that host was destroyed — a build falling back to the old default now asks for an audience whose +// resource server is being retired, and gets invalid_target. +export const OAUTH_GRAPHQL_RESOURCE = env.VITE_OAUTH_GRAPHQL_RESOURCE || 'https://cloud.dev.remote.it/api' export const OAUTH_PASSPORT_RESOURCE = env.VITE_OAUTH_PASSPORT_RESOURCE || 'https://passport.dev.remote.it/account/api' export const API_URL = env.VITE_API_URL || 'https://api.remote.it/apv/v27' From ad18d10d81f14c6d34e26b1a2603b51e9ffdf82f Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 6 Sep 2026 21:38:05 -0700 Subject: [PATCH 198/238] the grant heal stops latching a refusal it can no longer justify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit healGrant re-authorizes when this build declares more than the standing grant carries, bounded to ONE attempt per browser session so a client whose declaration outruns what the AS will grant cannot cycle someone through the browser forever. That bound is right for the automatic path and wrong everywhere else, and 2026-09-06 showed both ways it fails. app.ai was repointed at a new MCP resource hours before its actor was registered to act toward it. The boot heal fired into that window, was refused (invalid_authorization_details), and set the marker. When the registry was fixed the client had no way to learn it: every reload hit the loop-breaker, and the "Refresh permissions" button the chat shows in exactly that state routes to the same guard — so the one control offered to the person did nothing, logged a console warning and gave no feedback. Worse, the boot heal runs first, so that button is dead from the moment the automatic attempt fails, which is the only moment it is ever shown. Two changes, plus a hook for the third (in the chat model, which lives on the agent branch): * `force` skips the loop-breaker. A person clicking a button IS the loop-breaker — they stop clicking. The infinite-cycle hazard the bound exists for is a property of automatic retries. * the marker now records WHICH declaration was attempted rather than a bare '1', so a deploy that changes what this build asks for gets a fresh attempt instead of inheriting the old refusal. (Would not have helped this case — the AS moved, not the declaration — but it is the same class and one line.) * forgetGrantHealAttempt() lets a resource server's own "this grant does not cover me" clear the marker. That is server truth and strictly newer than the fingerprint guess. It deliberately only FORGETS: re-authorizing from a failed turn would redirect the person mid-conversation and lose what they were typing, when a working button and a clear message are already on screen. oidcDeclaration() exports the fingerprint rather than recomputing it in the model, so the marker and the staleness check can never disagree about what "the same request" means. Co-Authored-By: Claude Opus 5 --- frontend/src/models/auth.ts | 41 ++++++++++++++++++++++++++++++----- frontend/src/services/oidc.ts | 5 +++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index e5e1e4c3e..1f4970359 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,13 +9,18 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken, apiAuthHeaders } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, oidcRefreshBrowserAccounts, oidcSelectKnownAccount, OidcClaims } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcDeclaration, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, oidcRefreshBrowserAccounts, oidcSelectKnownAccount, OidcClaims } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' import axios from 'axios' import i18n from '../i18n' +// One re-authorize attempt per browser session, keyed by the declaration it was made from +// (healGrant below). sessionStorage rather than local: the bound is meant to survive reloads of +// this tab and nothing more, so a new tab is always a clean slate. +const GRANT_HEAL_KEY = 'oidc.regrant' + export interface AWSUser { authProvider: string email?: string @@ -120,24 +125,48 @@ export default createModel()({ * whose declaration outruns what the AS will grant it — a second try would return here * and loop the person through the browser forever. Same loop-breaker the console's * renew marker uses. */ - async healGrant() { - const ATTEMPTED = 'oidc.regrant' + async healGrant(options?: { force?: boolean }) { try { if (!oidcGrantStale()) { - window.sessionStorage.removeItem(ATTEMPTED) + window.sessionStorage.removeItem(GRANT_HEAL_KEY) return } - if (window.sessionStorage.getItem(ATTEMPTED)) { + // FORCE is for a deliberate human action (the chat's "Refresh permissions" button). The + // loop-breaker below exists to stop an AUTOMATIC retry cycling someone through the browser + // forever; a person clicking a button is their own loop-breaker, and suppressing them makes + // the control inert with no feedback — which is exactly what it did, since the boot heal + // above spends the attempt before the button is ever shown. + // + // The marker records WHICH declaration was tried, not merely that something was, so a + // deploy that changes what this build asks for gets a fresh attempt instead of inheriting + // the previous refusal. + if (!options?.force && window.sessionStorage.getItem(GRANT_HEAL_KEY) === oidcDeclaration()) { console.warn('AUTH: grant still stale after re-authorizing; not retrying this session') return } console.log('AUTH: grant predates this build’s declaration — re-authorizing') - window.sessionStorage.setItem(ATTEMPTED, '1') + window.sessionStorage.setItem(GRANT_HEAL_KEY, oidcDeclaration()) await oidcStart({}) } catch (error) { console.warn('AUTH: grant heal check failed (leaving the session as it is)', error) } }, + /** A resource server answering "this grant does not cover me" is SERVER truth, and newer than + * the client-side fingerprint the marker was written from — the declaration can be unchanged + * while the registry behind it moved (2026-09-06: app.ai was repointed at a new MCP resource + * hours before the actor was registered to act toward it, so the one automatic attempt was + * spent on a refusal that a later apply fixed, and nothing could try again). + * + * Deliberately only FORGETS the attempt. Re-authorizing from here would redirect the person to + * the AS mid-turn and lose whatever they were typing; this just makes the button live and lets + * the next boot heal on its own. */ + async forgetGrantHealAttempt() { + try { + window.sessionStorage.removeItem(GRANT_HEAL_KEY) + } catch { + /* storage unavailable — the marker was never written either */ + } + }, // Leave for the AS (the whole login UX — email-first, org SSO, MFA, signup, forgot — // lives there). On web the page departs; on desktop the window shows the waiting // panel until the deep link reloads it with the code. diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index a4475d5e4..f59cca8ba 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -231,6 +231,11 @@ export function oidcGrantStale(): boolean { } } +/** The fingerprint itself, for a caller that must record WHICH declaration an attempt was made + * from rather than merely that one was (the grant-heal marker in models/auth.ts). Exported rather + * than recomputed there, so the two can never disagree about what "the same request" means. */ +export const oidcDeclaration = (): string => declarationFingerprint() + export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'none'; loginHint?: string; supportTicket?: string } = {}): Promise { const d = await discover() const verifier = randomB64u(48) From a7d89677d6ceb5287bedf9eb2ecb5d3cae1786f0 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 6 Sep 2026 21:39:12 -0700 Subject: [PATCH 199/238] the chat's own failure is what unlatches the grant heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three call sites, closing the loop the auth model opened. "Refresh permissions" now forces. That button is shown precisely when health is 'unauthorized', and the boot heal has by then already spent the session's one automatic attempt — the failure that put the button on screen is the same failure that disarmed it. Pressing it logged a console warning and did nothing. A streamed `reauth_required`, and a thrown AgentAuthError, both forget the recorded attempt. The agent is reporting something the client could not know: the fingerprint it compares against is a claim about what this BUILD asks for, and says nothing about whether the registry behind it moved. On 2026-09-06 it moved twice in half an hour, and the marker written in between outlived its reason. Forgetting, not re-authorizing. A failed turn is the worst moment to redirect someone to the AS — they lose what they were typing, to fix a problem the button beside the message already fixes. Co-Authored-By: Claude Opus 5 --- frontend/src/models/chat.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 65dc0c1c5..add79e2a6 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -231,13 +231,21 @@ export default createModel()({ } else { // Buffered text must land before the next non-text event flushDeltas() + // The agent saying the grant does not cover it is SERVER truth, newer than the + // fingerprint the heal marker was written from — so a refusal recorded earlier in + // this browser session stops standing in the way of trying again. + if (event.type === 'error' && event.message.startsWith('reauth_required')) + dispatch.auth.forgetGrantHealAttempt() dispatch.chat.applyEvent(event) } }, }) } catch (error) { flushDeltas() - if (error instanceof AgentAuthError) dispatch.chat.set({ error: authRequiredError(), health: 'unauthorized' }) + if (error instanceof AgentAuthError) { + dispatch.auth.forgetGrantHealAttempt() // same reason as the streamed reauth_required above + dispatch.chat.set({ error: authRequiredError(), health: 'unauthorized' }) + } else if (error instanceof UsageLimitError) dispatch.chat.applyEvent({ type: 'error', message: usageLimitMessage(error) }) else if ((error as Error).name !== 'AbortError') @@ -332,7 +340,11 @@ export default createModel()({ app works means the standing grant predates this build's agent slice, so the fix is the grant heal: one silent re-authorize that merges it in. */ async signIn() { - await dispatch.auth.healGrant() + // FORCE: this is the person pressing "Refresh permissions" after being told the agent lacks + // authority. Without it the press reaches healGrant's one-attempt loop-breaker — already + // spent by the boot heal, which runs first and is the very failure that put this button on + // screen — and returns silently, leaving a control that does nothing. + await dispatch.auth.healGrant({ force: true }) await dispatch.chat.checkHealth() }, /* The history picker's list — refreshed on mount, after a turn, and after a delete. */ From 908e0888fd2df0ce4c5ea41f065c29cbdcd4866b Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 6 Sep 2026 22:00:22 -0700 Subject: [PATCH 200/238] the API target picker understands a front whose identifier is not a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Settings offered one row, labelled with a host that no longer resolves, and lit none of them. Three faults, all the same root: this lane was written when the graphql URL and the RFC 8707 resource were the same string, and the unified front separates them — the identifier is the TREE (https://cloud..remote.it/api) with graphql and the socket as PATHS inside it. * The row was MISSING. stagePairs matched only graphql.…/graphql and wss://ws.…/v1, so the tree identifier fell through the "not a switch target" branch with passport and the account APIs. The only row left was the legacy dev pair — a destroyed host presented as the option, which is what made the list look like it had lost its contents. * NOTHING was lit. The current selection compared the build's RESOURCE against each row's URL. Those matched for years; now the resource is …/api and no row's URL is. Compare on the URL the app actually calls (getApiURL), which is what a radio in this list means. * Selecting it would have 401'd. The mint asked for pair.graphql and then pair.ws — two audiences, right for a legacy stage, wrong for a tree where the socket has no identity of its own and asking for one answers invalid_target. A pair now carries its RESOURCES apart from its URLs: two on a legacy stage, one on the unified front. Keyed by shape AND stage, never stage alone — a client allowed both, which every dev client is mid-migration, would otherwise collide "legacy dev" and "cloud dev" into one row describing neither. Also fixes apiHelper.getApiResource(), flagged three times today and the reason a switched target would have failed even if the picker had offered it: it returned the switched URL verbatim as the audience. resourceForApiURL() does the mapping once, derived rather than persisted so a hand-typed custom URL resolves the same way a picked one does. Verified against remoteit_portal's live allowlist: two rows, the unified front lit, the legacy row minting two audiences and the cloud row one. The legacy row is correct to still be there — it is in the client's allowlist — and disappears on its own when that identifier is retired, which is the picker's whole contract: it shows what the AS will actually mint. Co-Authored-By: Claude Opus 5 --- frontend/src/helpers/apiHelper.ts | 21 ++++++++++--- frontend/src/pages/TestPage.tsx | 50 +++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/frontend/src/helpers/apiHelper.ts b/frontend/src/helpers/apiHelper.ts index 1762d0481..8c446f4e5 100644 --- a/frontend/src/helpers/apiHelper.ts +++ b/frontend/src/helpers/apiHelper.ts @@ -15,16 +15,29 @@ export function getApiURL(): string | undefined { return apiGraphqlURL && switchApi ? apiGraphqlURL : defaultURL } +/** The RESOURCE (RFC 8707 audience) to mint for when calling a given GraphQL URL. + * + * On the legacy per-stage hosts the two are the same string — the identifier IS the graphql URL, + * which is the assumption this whole lane was written on. The UNIFIED FRONT breaks it + * (graphql-permitteer docs/CLOUD-EDGE.md): one identifier per stage covers graphql, the REST + * surface and the socket, and each of those is a PATH inside it. Asking the AS for the leaf + * answers invalid_target, correctly — nothing registered that URL. So the leaf comes off. + * + * Derived rather than stored, so a target typed by hand into Test Settings resolves the same way a + * picked one does, with no new persisted field to fall out of step. */ +export function resourceForApiURL(url: string): string { + return url.match(/^(https:\/\/cloud(?:\.[a-z0-9-]+)?\.remote\.it\/api)\/graphql$/)?.[1] ?? url +} + // D10 (permitteer docs/remoteit-desktop-login.md Phase 4c): the token's audience follows the -// switched URL — for the Permitteer-era fronts the graphql URL IS the resource identifier, so -// switching APIs means switching WHICH resource we mint for. Off-allowlist targets fail at -// MINT with a legible invalid_target instead of as ambient 403s an hour later. Only the +// switched URL, so switching APIs means switching WHICH resource we mint for. Off-allowlist targets +// fail at MINT with a legible invalid_target instead of as ambient 403s an hour later. Only the // switcher lane follows; the default lane stays pinned to the env's declared resource (the // backend-override lane predates audience binding and never fed the token layer). export function getApiResource(): string { if (!store) return OAUTH_GRAPHQL_RESOURCE const { apiGraphqlURL, switchApi } = store.getState().ui.apis - return switchApi && apiGraphqlURL ? apiGraphqlURL : OAUTH_GRAPHQL_RESOURCE + return switchApi && apiGraphqlURL ? resourceForApiURL(apiGraphqlURL) : OAUTH_GRAPHQL_RESOURCE } export function getRestApi(): string | undefined { diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 76be1d3ce..0d2e04dc4 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import cloudSync from '../services/CloudSync' -import { TEST_HEADER, OAUTH_GRAPHQL_RESOURCE } from '../constants' +import { TEST_HEADER, GRAPHQL_API } from '../constants' import { Dispatch, State } from '../store' import { Typography, List, ListItem, Divider } from '@mui/material' -import { getApiURL, getWebSocketURL } from '../helpers/apiHelper' +import { getApiURL, getWebSocketURL, resourceForApiURL } from '../helpers/apiHelper' import { bindableResources } from '../services/permitteerAccount' import { oidcAccessToken } from '../services/oidc' import { selectLimitsLookup, selectLimits } from '../selectors/organizations' @@ -45,20 +45,42 @@ export const TestPage: React.FC = () => { bindableResources().then(setTargets) }, []) - type StagePair = { stage: string; name: string; graphql?: string; ws?: string } + // `resources` is what we MINT for, kept apart from the URLs we CALL because the two front shapes + // disagree about that. A legacy stage is two hosts and two identifiers (graphql + events); a + // unified-front stage is ONE identifier with both as paths inside it. Keyed by shape AND stage, + // never stage alone: a client allowed both — which every dev client is, mid-migration — would + // otherwise collide the two into one row that describes neither. + type StagePair = { key: string; name: string; graphql?: string; ws?: string; resources: string[] } const stagePairs: StagePair[] = React.useMemo(() => { const pairs = new Map() + const at = (key: string, name: string) => pairs.get(key) || { key, name, resources: [] } for (const target of targets) { + // The UNIFIED FRONT (graphql-permitteer docs/CLOUD-EDGE.md). The identifier is not a URL to + // call: graphql and the socket hang off it, and one audience covers both. + const cloud = target.identifier.match(/^https:\/\/cloud(?:\.([a-z0-9-]+))?\.remote\.it\/api$/) + if (cloud) { + const key = `cloud:${cloud[1] || 'prod'}` + pairs.set(key, { + ...at(key, target.name), + name: target.name, + graphql: `${target.identifier}/graphql`, + ws: `${target.identifier.replace(/^https:/, 'wss:')}/ws`, + resources: [target.identifier], + }) + continue + } const gql = target.identifier.match(/^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/) const ws = target.identifier.match(/^wss:\/\/ws(?:\.([a-z0-9-]+))?\.remote\.it\/v1$/) if (!gql && !ws) continue // passport / account-api entries are not switch targets const stage = (gql?.[1] ?? ws?.[1]) || 'prod' - const pair = pairs.get(stage) || { stage, name: stage } + const key = `legacy:${stage}` + const pair = at(key, stage) if (gql) { pair.graphql = target.identifier pair.name = target.name } else pair.ws = target.identifier - pairs.set(stage, pair) + pair.resources = [...pair.resources, target.identifier] + pairs.set(key, pair) } return [...pairs.values()].filter(pair => pair.graphql) }, [targets]) @@ -68,7 +90,10 @@ export const TestPage: React.FC = () => { // so the switch is gone and `switchApi` (still read by the Electron backend to configure // the CLI binary) is set from here. `customMode` is held locally because a hand-typed URL // may coincide with a registered stage, and the choice should not silently jump to it. - const currentGraphql = apis.switchApi && apis.apiGraphqlURL ? apis.apiGraphqlURL : OAUTH_GRAPHQL_RESOURCE + // Compare on the URL the app actually CALLS, not on the audience it mints for. Those were the + // same string until the unified front, where the build's resource (…/api) matches no row's URL + // (…/api/graphql) — so every radio read unchecked and the picker looked broken. + const currentGraphql = getApiURL() const [customMode, setCustomMode] = useState(undefined) const customSelected = customMode ?? (!!apis.switchApi && stagePairs.length > 0 && !stagePairs.some(p => p.graphql === currentGraphql)) @@ -88,7 +113,7 @@ export const TestPage: React.FC = () => { async function selectStage(pair: StagePair) { setMintError('') setCustomMode(false) - const isDefault = pair.graphql === OAUTH_GRAPHQL_RESOURCE + const isDefault = pair.graphql === GRAPHQL_API const values = { switchApi: !isDefault, apiGraphqlURL: pair.graphql!, @@ -97,10 +122,9 @@ export const TestPage: React.FC = () => { await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) emit('preferences', { ...preferences, ...values }) try { - if (!isDefault) { - await oidcAccessToken(pair.graphql!) - if (pair.ws) await oidcAccessToken(pair.ws) - } + // One mint per RESOURCE, which is two on a legacy stage and one on the unified front — where + // asking for the socket URL separately would answer invalid_target, correctly. + if (!isDefault) for (const resource of pair.resources) await oidcAccessToken(resource) emit('binaries/install') cloudSync.all() } catch (error) { @@ -176,7 +200,7 @@ export const TestPage: React.FC = () => { {stagePairs.map(pair => ( { setMintError('') await setAPIPreference('apiGraphqlURL', url) try { - await oidcAccessToken(url) + await oidcAccessToken(resourceForApiURL(url)) } catch (error) { setMintError(error instanceof Error ? error.message : String(error)) } From db913e4e347e3f0947fb706e5f450fe188eb67b9 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 7 Sep 2026 16:44:01 -0700 Subject: [PATCH 201/238] oidc: a stale copy recovers the session instead of ending it; a switched-away rotation keeps its successor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two refresh-rotation edges the AS trail showed on dev (auth.token.replayed): 1. The AS refuses a replay of a spent refresh token whose successor is ALSO spent with "…this copy is stale and the session was not ended" — the family rotated on without this tab (a response lost to a navigation, a second tab) and this store holds nothing newer, but the AS session is alive. We treated it as a dead grant and signed the person out. Now: one silent round through the AS, prompt=none + login_hint naming THIS account (the lane a just-activated account already uses in models/auth init), so a multi-account browser gets the same person back rather than whichever member the AS has active. One-shot per account per minute (sessionStorage — rides the same-tab round trip, dies with the tab): a refused silent round is a sign-out, never a loop. 2. A rotation that completed after a sign-out or account switch had moved the store dropped its successor — leaving the account's registry entry holding the SPENT token, so the next activation replayed it (dev: the app.ai replays a minute after a switch). The successor is re-filed onto that entry — update only, never insert, so a signed-out account stays gone. Typecheck clean (frontend + common). Pairs with permitteer review/2026-09 cluster 7, where the AS keeps the replay grace for DPoP-bound families (this client) and makes a public client's bearer family strict. Co-Authored-By: Claude Fable 5.1 --- frontend/src/services/oidc.ts | 60 +++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index f59cca8ba..75783fb64 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -401,19 +401,39 @@ async function refreshOnce(resource: string): Promise { // ONLY onto the same token set we rotated from: a sign-out or an account activation // that landed mid-flight has already moved the store, and writing the rotation would // resurrect the signed-out account (persist() re-files it in the registry — caught by - // the multi-account e2e, ~50% of runs) or clobber the activated one. The dropped - // successor costs nothing: sign-out already ended the AS session (revoking its refresh - // family), and activation replaced the family in use. - if (stored()?.refresh_token !== current.refresh_token) return '' + // the multi-account e2e, ~50% of runs) or clobber the activated one. The successor is + // not dropped, though: it still belongs to the account we rotated FOR, whose saved + // registry token is now the SPENT one — switching back would replay it (dev: the app.ai + // replays a minute after a switch). Re-file it there — update only; a signed-out account + // has no entry left, so nothing comes back. + if (stored()?.refresh_token !== current.refresh_token) { + refileSuccessor(current.refresh_token, { refresh_token: body.refresh_token || current.refresh_token, id_token: body.id_token || current.id_token }) + return '' + } persist({ refresh_token: body.refresh_token || current.refresh_token, id_token: body.id_token || current.id_token }) const at = decodeJwt(body.access_token) access[resource] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } return body.access_token } catch (error: any) { console.error('OIDC REFRESH FAILED', error?.message) - // A dead grant (revoked / expired session / family revoked on reuse) ends the - // session; transient network errors keep it and the next call retries. - if (error?.oauthError === 'invalid_grant') clearLocal() + if (error?.oauthError === 'invalid_grant') { + // The AS tells a STALE COPY apart from a dead grant: "…this copy is stale and the session was + // not ended" means the family rotated on without this tab (a response lost to a navigation, + // another tab) and the successor is spent too — this store holds nothing newer, but the AS + // session is alive. Recover on it the way a just-activated account does (models/auth init): + // ONE silent round, prompt=none + login_hint naming THIS account so a multi-account browser + // gets the same person back rather than whichever member the AS has active. One-shot: a + // second stale refusal for the same account within a minute means the silent round came + // back refused (the AS cookie is gone while the tokens lingered), and that is a sign-out. + const email = decodeJwt(current.id_token)?.email + if (/session was not ended/.test(String(error?.message)) && email && recoverOnce(email)) { + void oidcStart({ prompt: 'none', loginHint: email }) + return '' + } + // A dead grant (revoked / expired session / family revoked on reuse) ends the session; + // transient network errors keep it and the next call retries. + clearLocal() + } return '' } } @@ -510,6 +530,32 @@ function fileAccount(tokens: Stored) { writeRegistry(reg) } +/** The stale-copy recovery's loop-breaker: true the FIRST time an account asks within a minute, + * false for a repeat — sessionStorage, so it rides the same-tab round trip through the AS and + * dies with the tab. Storage refused → no recovery (a plain sign-out), never a loop. */ +const RECOVERING_KEY = 'oidc.recovering' +function recoverOnce(email: string): boolean { + try { + const [who, at] = (sessionStorage.getItem(RECOVERING_KEY) ?? '').split('|') + if (who === email && Date.now() - Number(at) < 60_000) return false + sessionStorage.setItem(RECOVERING_KEY, `${email}|${Date.now()}`) + return true + } catch { + return false + } +} + +/** A rotation that completed for an account no longer in the active store: put its successor on + * that account's registry entry — UPDATE only, never insert (a signed-out account has no entry and + * must stay gone). Without this the entry keeps the spent token and the next activation replays it. */ +function refileSuccessor(spent: string, tokens: Stored) { + const reg = readRegistry() + const sub = Object.keys(reg).find(k => reg[k].refresh_token === spent) + if (!sub) return + reg[sub] = { ...reg[sub], refresh_token: tokens.refresh_token, id_token: tokens.id_token ?? reg[sub].id_token } + writeRegistry(reg) +} + /** `known`: signed in on this BROWSER (the AS's session set) but not in this app yet — no tokens * here; picking it runs a silent selection instead of a storage swap. */ export type OidcAccount = { sub: string; email?: string; name?: string; picture?: string; active: boolean; known: boolean } From c22c09a0b54655554b8a40bfea51144e089ffec7 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 9 Sep 2026 13:19:03 -0700 Subject: [PATCH 202/238] fix(devices): drop the accountId left orphaned by merging main --- frontend/src/pages/DevicesPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index e2dfb03a3..47e906933 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -3,7 +3,6 @@ import { Dispatch, State } from '../store' import { useHistory } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' import { selectDeviceListAttributes, selectDeviceModelAttributes, selectVisibleDevices } from '../selectors/devices' -import { selectActiveAccountId } from '../selectors/accounts' import { getConnectionsLookup } from '../selectors/connections' import { selectCanRegister } from '../selectors/organizations' import { selectDefaultAccountId } from '../selectors/accounts' @@ -25,7 +24,6 @@ export const DevicesPage: React.FC = ({ restore, select }) => { const { attributes, required } = useSelector(selectDeviceListAttributes) const { fetching: deviceFetching, initialized, applicationTypes } = useSelector(selectDeviceModelAttributes) const devices = useSelector(selectVisibleDevices) - const accountId = useSelector(selectActiveAccountId) const canRegister = useSelector(selectCanRegister) const defaultAccountId = useSelector(selectDefaultAccountId) const connections = useSelector(getConnectionsLookup) From 2bcab9030375b0c30fae4b2ab8123da6ef247d2a Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Wed, 9 Sep 2026 13:19:03 -0700 Subject: [PATCH 203/238] i18n(test): translate the API target picker strings for de, es and ja --- frontend/src/i18n/locales/de/app.json | 6 ++++++ frontend/src/i18n/locales/es/app.json | 6 ++++++ frontend/src/i18n/locales/ja/app.json | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 9da371a28..de3ff22a5 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2106,8 +2106,13 @@ "testPage": { "addQueryHeader": "Abfrage-Header hinzufügen", "addQueryHeaderPlaceholder": "Beispiel: \"key:value\"", + "apiTarget": "API-Ziel", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", "clearViewedAnnouncementsHint": "Markiert alle geladenen Ankündigungen für dieses Konto als ungelesen.", + "customAPITarget": "Benutzerdefiniert", + "customAPITargetHint": "Auf eine URL zeigen, die der Autorisierungsserver nicht registriert hat.", + "customGraphQLURL": "Benutzerdefinierte GraphQL-URL (erweitert)", + "customWebSocketURL": "Benutzerdefinierte WebSocket-URL (erweitert)", "disableTestUI": "Test-UI deaktivieren", "disableTestUIHint": "Um die Alpha-UI wieder zu aktivieren, müssen Sie das Avatar-Menü bei gedrückter Alt-Umschalt-Taste auswählen.", "featureLabel": "{{name}} (Standard {{state}})", @@ -2115,6 +2120,7 @@ "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", "licensingOptions": "Lizenzierungsoptionen", + "mintError": "Dieses Ziel wurde beim Token-Mint abgelehnt: {{error}}", "overrideDefaultAPIs": "Standard-APIs überschreiben", "overrideLicensesAndLimits": "Lizenzen und Limits überschreiben", "reset": "Zurücksetzen", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index b51165011..8ce571517 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2141,8 +2141,13 @@ "testPage": { "addQueryHeader": "Agregar encabezado de consulta", "addQueryHeaderPlaceholder": "Ejemplo: \"key:value\"", + "apiTarget": "Destino de la API", "clearViewedAnnouncements": "Borrar anuncios vistos", "clearViewedAnnouncementsHint": "Marca todos los anuncios cargados como no leídos para esta cuenta.", + "customAPITarget": "Personalizado", + "customAPITargetHint": "Apuntar a una URL que el servidor de autorización no ha registrado.", + "customGraphQLURL": "URL de GraphQL personalizada (avanzado)", + "customWebSocketURL": "URL de WebSocket personalizada (avanzado)", "disableTestUI": "Deshabilitar la interfaz de prueba", "disableTestUIHint": "Para volver a habilitar la interfaz alfa, debes seleccionar el menú de avatar mientras mantienes presionado alt-shift.", "featureLabel": "{{name}} (predeterminado {{state}})", @@ -2150,6 +2155,7 @@ "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", "licensingOptions": "Opciones de licencia", + "mintError": "Este destino fue rechazado al emitir el token: {{error}}", "overrideDefaultAPIs": "Anular las API predeterminadas", "overrideLicensesAndLimits": "Anular licencias y límites", "reset": "Restablecer", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index a972ef43e..31b9c198b 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2071,8 +2071,13 @@ "testPage": { "addQueryHeader": "クエリヘッダーを追加", "addQueryHeaderPlaceholder": "例: \"key:value\"", + "apiTarget": "APIターゲット", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", + "customAPITarget": "カスタム", + "customAPITargetHint": "認可サーバーに登録されていないURLを指定します。", + "customGraphQLURL": "カスタムGraphQL URL(詳細設定)", + "customWebSocketURL": "カスタムWebSocket URL(詳細設定)", "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", "featureLabel": "{{name}}(デフォルト: {{state}})", @@ -2080,6 +2085,7 @@ "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", "licensingOptions": "ライセンスオプション", + "mintError": "このターゲットはトークン発行時に拒否されました: {{error}}", "overrideDefaultAPIs": "デフォルトのAPIをオーバーライド", "overrideLicensesAndLimits": "ライセンスと制限をオーバーライド", "reset": "リセット", From 39bb1dd23a64c0892e70a0621aac7e225e8bf37c Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 10 Sep 2026 18:29:35 -0700 Subject: [PATCH 204/238] =?UTF-8?q?ci:=20app.next.remote.it=20=E2=80=94=20?= =?UTF-8?q?the=20`next`=20branch=20mirrors=20this=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NEXT portal is this branch built for production's AS and container (Amplify branch `next`: VITE_OAUTH_ISSUER=https://login.remote.it, cloud.remote.it for the API — app.dev's six branch-level variables with prod values). Amplify deploys a branch once, so `next` is a git branch that mirrors this one; this workflow pushes every commit through and Amplify's webhook builds it. Nobody commits to `next`. Co-Authored-By: Claude Opus 5 --- .github/workflows/mirror-next.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/mirror-next.yml diff --git a/.github/workflows/mirror-next.yml b/.github/workflows/mirror-next.yml new file mode 100644 index 000000000..11898f4f7 --- /dev/null +++ b/.github/workflows/mirror-next.yml @@ -0,0 +1,25 @@ +# app.next.remote.it — the NEXT portal — is this branch built for PRODUCTION's AS and container +# (Amplify branch `next`, env VITE_OAUTH_ISSUER=https://login.remote.it and cloud.remote.it for +# the API). Amplify deploys a branch once, so `next` is a git branch that MIRRORS this one: every +# push here is pushed through, and Amplify's webhook builds it. Nobody commits to `next`. +# +# Why a mirror rather than a second Amplify app: one app, one GitHub connection, one build spec +# and one set of app-level env — `next` differs from app.dev by six branch-level variables. +name: Mirror to next + +on: + push: + branches: [feat/permitteer-login] + +permissions: + contents: write + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Push this commit to next + run: git push --force origin HEAD:refs/heads/next From dec3099b6594d65fb0e29c790fac84aa3a6f50c5 Mon Sep 17 00:00:00 2001 From: evanrbowers Date: Sun, 13 Sep 2026 16:50:50 -0700 Subject: [PATCH 205/238] Sign-out is local to the app; default API endpoint moves to the unified front (#1216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signOut is LOCAL to this app (drops local session; leaves the AS session to the user); signIn always authorizes with prompt=select_account, so sign-out + reload lands on the AS chooser instead of a silent SSO. The dead in-memory promptLogin guard is removed. - globalSignOut ("Sign Out Everywhere") ends the AS session BEFORE local teardown (single-session RP-logout; all-device /logout/all remains Phase 2b — tracked on the PR). - Default API endpoint moves to the unified front: GRAPHQL_API -> cloud.remote.it/api/graphql, OAUTH_GRAPHQL_RESOURCE -> cloud.remote.it/api (local dev overrides via .env.local). - Frontend test harness (vitest + jsdom) with sign-out effect tests; `npm test` now runs the electron + frontend suites. Verified: frontend (3) + electron (44) suites green, typecheck clean, e2e test:dev 80 passed / 1 skipped against the dev deploy. --- frontend/package.json | 8 +- frontend/src/constants.ts | 4 +- frontend/src/models/auth.test.ts | 74 +++ frontend/src/models/auth.ts | 37 +- frontend/src/services/oidc.ts | 17 - frontend/vitest.config.ts | 17 + package-lock.json | 1052 +++++++++++++++++++++++++++++- package.json | 2 +- 8 files changed, 1160 insertions(+), 51 deletions(-) create mode 100644 frontend/src/models/auth.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package.json b/frontend/package.json index 22c68eb7c..0ceb5a586 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' vite build", "typecheck": "tsc --noEmit", "i18n:extract": "i18next -c i18next-parser.config.js", - "i18n:check": "node scripts/i18n-check.mjs" + "i18n:check": "node scripts/i18n-check.mjs", + "test": "vitest run", + "test-watch": "vitest" }, "dependencies": { "@airbrake/browser": "^2.1.9", @@ -95,8 +97,10 @@ "@vitejs/plugin-react": "^4.3.2", "eslint": "^8.53.0", "i18next-parser": "^9.4.0", + "jsdom": "^27.0.1", "typescript": "^5.9.2", - "vite": "^6.4.3" + "vite": "^6.4.3", + "vitest": "^3.2.7" }, "eslintConfig": { "extends": "react-app" diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index e3d9f7188..a9421f782 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -11,12 +11,12 @@ export const OAUTH_CLIENT_ID = env.VITE_OAUTH_CLIENT_ID || 'remoteit_desktop' // one token serves all three. Was https://graphql.dev.remote.it/graphql until 2026-09-06, when // that host was destroyed — a build falling back to the old default now asks for an audience whose // resource server is being retired, and gets invalid_target. -export const OAUTH_GRAPHQL_RESOURCE = env.VITE_OAUTH_GRAPHQL_RESOURCE || 'https://cloud.dev.remote.it/api' +export const OAUTH_GRAPHQL_RESOURCE = env.VITE_OAUTH_GRAPHQL_RESOURCE || 'https://cloud.remote.it/api' export const OAUTH_PASSPORT_RESOURCE = env.VITE_OAUTH_PASSPORT_RESOURCE || 'https://passport.dev.remote.it/account/api' export const API_URL = env.VITE_API_URL || 'https://api.remote.it/apv/v27' export const AUTH_API_URL = env.VITE_AUTH_API_URL || env.AUTH_API_URL || 'https://auth.api.remote.it/v1' -export const GRAPHQL_API = env.VITE_GRAPHQL_API || 'https://api.remote.it/graphql/v1' +export const GRAPHQL_API = env.VITE_GRAPHQL_API || 'https://cloud.remote.it/api/graphql' export const GRAPHQL_BETA_API = env.VITE_GRAPHQL_BETA_API || 'https://api.remote.it/graphql/beta' export const PORTAL = (env.VITE_PORTAL || env.PORTAL) === 'true' ? true : false export const PORTAL_URL = env.VITE_PORTAL_URL || brand.package?.homepage || 'https://app.remote.it' diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts new file mode 100644 index 000000000..f93265566 --- /dev/null +++ b/frontend/src/models/auth.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// The auth model pulls in the whole service layer at import time; stub everything the module +// touches so we can exercise the sign-in / sign-out EFFECTS in isolation. Only the two OIDC +// functions the tests assert on carry real spies — declared via vi.hoisted so they exist before +// the hoisted vi.mock factory runs. +const { oidcStart, oidcEndSessionSilently } = vi.hoisted(() => ({ + oidcStart: vi.fn(), + oidcEndSessionSilently: vi.fn(), +})) + +vi.mock('../services/oidc', () => ({ oidcStart, oidcEndSessionSilently })) +vi.mock('../services/Controller', () => ({ default: {}, emit: vi.fn(() => false) })) +vi.mock('../services/CloudSync', () => ({ default: {} })) +vi.mock('../services/cloudController', () => ({ default: {} })) +vi.mock('../services/Network', () => ({ default: {} })) +vi.mock('../services/browser', () => ({ default: { isElectron: false, hasBackend: false } })) +vi.mock('../services/analytics', () => ({ default: {} })) +vi.mock('../services/zendesk', () => ({ default: {} })) +vi.mock('../services/graphQLRequest', () => ({ graphQLLogin: vi.fn() })) +vi.mock('../services/remoteit', () => ({ getToken: vi.fn(), apiAuthHeaders: vi.fn() })) +vi.mock('../selectors/devices', () => ({ selectDeviceModelAttributes: vi.fn() })) +vi.mock('../store', () => ({ persistor: { purge: vi.fn() } })) +vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) +vi.mock('../constants', () => ({ API_URL: '', DEVELOPER_KEY: '', SIGN_OUT_BACKEND_TIMEOUT: 1000 })) +vi.mock('axios', () => ({ default: {} })) + +// The effects are `dispatch => ({...})`; build them against a fake dispatch so each auth.* +// call is an observable spy rather than a real reducer/effect. +function makeDispatch() { + return { auth: { set: vi.fn(), signedOut: vi.fn(), signOut: vi.fn() } } +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires +import authModel from './auth' + +const effectsFor = (dispatch: any) => (authModel as any).effects(dispatch) + +beforeEach(() => { + oidcStart.mockReset() + oidcEndSessionSilently.mockReset() +}) + +describe('auth model — sign-in always offers the chooser', () => { + it('signIn authorizes with prompt=select_account (never a promptless / silent SSO)', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).signIn() + expect(oidcStart).toHaveBeenCalledTimes(1) + expect(oidcStart).toHaveBeenCalledWith({ prompt: 'select_account' }) + }) +}) + +describe('auth model — sign-out is local to the app', () => { + it('signOut does NOT end the AS session (no oidcEndSessionSilently)', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).signOut(undefined, { auth: { backendAuthenticated: false } }) + expect(oidcEndSessionSilently).not.toHaveBeenCalled() + // Local teardown still happens. + expect(dispatch.auth.signedOut).toHaveBeenCalledTimes(1) + }) +}) + +describe('auth model — "Sign out everywhere" stays AS-wide', () => { + it('globalSignOut ends the AS session BEFORE local teardown', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).globalSignOut() + expect(oidcEndSessionSilently).toHaveBeenCalledTimes(1) + expect(dispatch.auth.signOut).toHaveBeenCalledTimes(1) + // Order matters: the AS logout must precede the local sign-out. + expect(oidcEndSessionSilently.mock.invocationCallOrder[0]).toBeLessThan( + dispatch.auth.signOut.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 1f4970359..3dc6e6182 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -202,13 +202,12 @@ export default createModel()({ async signIn(_: void) { dispatch.auth.set({ signingIn: true, signInError: undefined }) try { - // Desktop sign-in always offers the CHOOSER (prompt=select_account): a live chip - // in the browser would otherwise silently SSO whoever was last signed in, and a - // button that says "Sign in" should let the person pick. This also covers the - // post-signout rule (never silently reuse a chip) — a deliberate selection is - // not silent. Web keeps the plain path: its auto-start SSO is the point there, - // and its signout-return lane still forces prompt=login. - await oidcStart(browser.isElectron ? { prompt: 'select_account' } : {}) + // Sign-in ALWAYS offers the CHOOSER (prompt=select_account), web and desktop alike. + // A "Sign in" button should let the person pick; and with a live AS cookie a + // PROMPTLESS authorize would silently SSO the last user straight back in — which is + // exactly the "sign-out doesn't stick" bug. select_account also means that signing + // out and reloading always lands on the picker, never a silent re-login. + await oidcStart({ prompt: 'select_account' }) } catch (error: any) { console.error('SIGN IN FAILED', error) dispatch.auth.set({ signingIn: false, signInError: error?.message || 'Sign in failed, please try again.' }) @@ -392,14 +391,12 @@ export default createModel()({ if (!browser.hasBackend) dispatch.auth.appReady() }, async signOut(_: void, state) { - // EXPLICIT sign-out ends the AS session too — SILENTLY (fetch, before teardown - // clears the id_token): no end_session redirect parade, no navigation race with - // the sign-in auto-start. The next authorize carries prompt=login so the user - // lands on the LOGIN PAGE, never a silent SSO into another chip's live session. - // Failure-driven teardown (signedOut via the error paths) stays local-only. - const { oidcEndSessionSilently, oidcRequireLoginPrompt } = await import('../services/oidc') - await oidcEndSessionSilently() - oidcRequireLoginPrompt() + // Sign-out is LOCAL to this app: drop this app's tokens/session (dispatch.auth.signedOut + // below). The AS browser session belongs to the user and is NOT ended here — a true + // "sign out everywhere" is a separate, explicit action (oidcEndSessionSilently / + // end_session remain for it). Because signIn always uses prompt=select_account, the next + // sign-in and any reload land on the AS chooser rather than silently SSO-ing back in, so + // no login-prompt guard is needed. // emit returns false when the local socket isn't connected, and // backendAuthenticated can still be true at that moment - the flag is only // cleared once the socket's disconnect event lands. Without checking the @@ -469,9 +466,13 @@ export default createModel()({ Controller.close() }, async globalSignOut() { - // Pilot: signs this session out at the AS (RP-initiated logout). Every-device - // sign-out maps to the AS's /logout/all and rides Phase 2b with the rest of the - // security surface. + // "Sign out everywhere" (SecurityPage) is the EXPLICIT, AS-wide action, distinct from the + // avatar-menu sign-out which is local to this app: end the AS browser session (RP-initiated + // logout) BEFORE the local teardown, so the security control does what it reports. The + // every-device /logout/all lands with Phase 2b. signOut itself stays LOCAL — a failure-path + // or menu sign-out must never end the AS session. + const { oidcEndSessionSilently } = await import('../services/oidc') + await oidcEndSessionSilently() dispatch.auth.signOut() }, }), diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 75783fb64..88e4d0f3f 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -51,20 +51,6 @@ const DECLARATION_KEY = 'oidc.declaration' // per-tab store anyway). const ACCOUNTS_KEY = 'oidc.accounts' -// A boot on /signoutCallback is the RETURN from an explicit sign-out: the next authorize -// must show the LOGIN PAGE (prompt=login), never silently SSO into another account's -// live session in the multi-account cookie. -let promptLogin = false -/** The NEXT authorize must land on the login page (no silent SSO into another chip) — - * set by the silent sign-out just before the app re-enters the sign-in flow. */ -export function oidcRequireLoginPrompt() { - promptLogin = true -} -if (window.location.pathname === '/signoutCallback') { - promptLogin = true - window.history.replaceState({}, '', window.location.origin + '/') -} - // A support TAB keeps its tokens in sessionStorage — per-tab — never in the shared // localStorage. The first cut CLEARED localStorage instead, and localStorage is // origin-wide: the support tab's impersonated tokens replaced the operator's own, so @@ -268,9 +254,6 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'n if (opts.supportTicket) params.support_ticket = opts.supportTicket if (opts.prompt) { params.prompt = opts.prompt - } else if (promptLogin) { - params.prompt = 'login' - promptLogin = false } for (const key in params) url.searchParams.set(key, params[key]) window.location.assign(url.toString()) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 000000000..0d1d04351 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +// Frontend unit tests (the app is Vite, so tests run under vitest, not the electron jest +// suite). Kept separate from vite.config.ts so the build config is untouched; the one alias +// the source relies on is mirrored here. +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { '@common': path.resolve(__dirname, '../common/src') }, + }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, +}) diff --git a/package-lock.json b/package-lock.json index 25cd9b1b5..527dde299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -250,8 +250,10 @@ "@vitejs/plugin-react": "^4.3.2", "eslint": "^8.53.0", "i18next-parser": "^9.4.0", + "jsdom": "^27.0.1", "typescript": "^5.9.2", - "vite": "^6.4.3" + "vite": "^6.4.3", + "vitest": "^3.2.7" } }, "frontend/node_modules/@types/node": { @@ -293,6 +295,61 @@ "node": ">=10" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1351,6 +1408,146 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -4438,6 +4635,17 @@ "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4720,6 +4928,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5302,6 +5517,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/sudo-prompt": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", @@ -5920,6 +6250,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -6322,6 +6662,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -6773,6 +7123,16 @@ "node": ">=6.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -6915,6 +7275,23 @@ "@capacitor/core": ">=7.0.0" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6951,6 +7328,16 @@ "node": "*" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -7870,6 +8257,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -7882,6 +8283,32 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -8307,21 +8734,82 @@ "node": ">=8" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { "url": "https://github.com/sponsors/ljharb" } }, @@ -8421,6 +8909,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -8457,6 +8952,16 @@ "integrity": "sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -9617,6 +10122,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -9777,6 +10292,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -11177,6 +11702,19 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12063,6 +12601,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13104,6 +13649,109 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -13457,6 +14105,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -13608,6 +14263,13 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -15120,6 +15782,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -17062,6 +17741,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -17221,6 +17907,19 @@ "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -17611,6 +18310,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -18034,6 +18740,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -18059,6 +18772,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -18314,6 +19034,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -18358,6 +19098,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -18593,6 +19340,20 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -18623,6 +19384,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -18683,6 +19494,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -19530,6 +20354,54 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite-plugin-dynamic-import": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/vite-plugin-dynamic-import/-/vite-plugin-dynamic-import-1.6.0.tgz", @@ -19555,6 +20427,117 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -19564,6 +20547,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", @@ -19775,6 +20771,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/winston": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", @@ -19968,6 +20981,16 @@ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -19999,6 +21022,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/package.json b/package.json index 5a0178dda..57e429037 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "install-electron": "npm run build-frontend && npm install --workspace=electron", "start": "npm run brand-web && npm start -w=frontend", "test-watch": "npm run test-watch -w=electron", - "test": "npm run test -w=electron", + "test": "npm run test -w=electron && npm run test -w=frontend", "version": "npm version $npm_package_version --workspaces && sh version.sh $npm_package_version" }, "dependencies": { From 4e1a8077f13766d95b0daf321e42fbb50b594a90 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 19:14:11 -0700 Subject: [PATCH 206/238] Test Settings: plain "Agent service URL (advanced)" field (no toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adds a way to repoint the AI chat at a deployed agent from Test Settings, in permitteer-login's style — a standalone advanced field, no switchAgent override toggle. agentURL() now takes the override whenever it is a valid https URL (the switchAgent gate is gone); ui.ts drops the now-unused switchAgent flag. Co-Authored-By: Claude Opus 4.8 --- frontend/src/models/ui.ts | 1 - frontend/src/pages/TestPage.tsx | 37 ++++++++++++++++++++++++++++++++- frontend/src/services/agent.ts | 12 +++++------ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index e4ad0ef4e..2bbd3652a 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -49,7 +49,6 @@ export type UIState = { webSocketURL?: IPreferences['webSocketURL'] apiURL?: IPreferences['apiURL'] // Test UI: point the Remote.It AI chat at a deployed agent (https only) - switchAgent?: boolean agentURL?: string } layout: ILayout diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 0d2e04dc4..55adbfc99 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,12 +1,13 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import cloudSync from '../services/CloudSync' -import { TEST_HEADER, GRAPHQL_API } from '../constants' +import { TEST_HEADER, GRAPHQL_API, OAUTH_AGENT_RESOURCE } from '../constants' import { Dispatch, State } from '../store' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL, resourceForApiURL } from '../helpers/apiHelper' import { bindableResources } from '../services/permitteerAccount' import { oidcAccessToken } from '../services/oidc' +import { isSecureAgentURL } from '../services/agent' import { selectLimitsLookup, selectLimits } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' @@ -41,6 +42,7 @@ export const TestPage: React.FC = () => { // an illegal target fails here with a legible error, never as ambient 403s an hour later. const [targets, setTargets] = useState>([]) const [mintError, setMintError] = useState('') + const [agentError, setAgentError] = useState('') useEffect(() => { bindableResources().then(setTargets) }, []) @@ -261,6 +263,39 @@ export const TestPage: React.FC = () => { + + {t('testPage.aiAgent', 'AI Agent')} + + + + + { + const url = result.toString().trim() + if (url && !isSecureAgentURL(url)) { + setAgentError(t('testPage.agentURLInvalid', 'Agent service URL must start with https://')) + return + } + setAgentError('') + setAPIPreference('agentURL', url) + }} + hideIcon + /> + {!!agentError && ( + + + {agentError} + + + )} + + + + {t('testPage.features', 'Features')} {limits.map(l => { diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 5f706dffe..10490a809 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -18,14 +18,14 @@ import { OAUTH_AGENT_RESOURCE } from '../constants' the Test Settings validation so what saves is exactly what engages. */ export const isSecureAgentURL = (url: string): boolean => /^https:\/\//i.test(url) -/* Base URL for the agent service, resolved per request. The Test UI override - wins (Test Settings → Override agent service). Otherwise dev rides the vite - proxy (same-origin, CSP-clean) even when VITE_AGENT_URL is set, staying out - of CORS; builds have no proxy and use the deployed agent domain from +/* Base URL for the agent service, resolved per request. A Test UI override + wins (Test Settings → Agent service URL, https only). Otherwise dev rides the + vite proxy (same-origin, CSP-clean) even when VITE_AGENT_URL is set, staying + out of CORS; builds have no proxy and use the deployed agent domain from VITE_AGENT_URL. */ export function agentURL(): string { - const { switchAgent, agentURL: override } = store.getState().ui.apis - if (switchAgent && override && isSecureAgentURL(override)) return override.replace(/\/+$/, '') + const override = store.getState().ui.apis.agentURL + if (override && isSecureAgentURL(override)) return override.replace(/\/+$/, '') return import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent' } From 25b1c05035e654ecd76beb647063382a0be29be2 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 20:42:47 -0700 Subject: [PATCH 207/238] Codex review fixes (merge/reconciliation scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package-lock.json: restore the frontend test deps (vitest, jsdom). The merge committed the pre-merge lock before `npm install` reconciled it, so `npm ci` (the typecheck CI) would fail on the lock/manifest mismatch. (Codex P1) - TestPage Features: list features via selectFeatures (API limits + soft-launching PENDING_FEATURES, incl. `ai-agent`) with the pending sub-label, toggling the effective lookup — restoring what taking permitteer-login's TestPage wholesale had dropped, so the chat is toggleable before licensing returns the limit. (Codex P2) - TestPage "Agent service URL": Reset now CLEARS the override (persist '') instead of pinning OAUTH_AGENT_RESOURCE, so agentURL() falls back to the /agent proxy (dev) / VITE_AGENT_URL (build) rather than turning the OAuth audience into the transport. (Codex P2) Frontend + electron suites green; typecheck + build clean. Co-Authored-By: Claude Opus 4.8 --- frontend/src/pages/TestPage.tsx | 59 +- package-lock.json | 1052 ++++++++++++++++++++++++++++++- 2 files changed, 1074 insertions(+), 37 deletions(-) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 55adbfc99..6d54b29ef 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -8,7 +8,7 @@ import { getApiURL, getWebSocketURL, resourceForApiURL } from '../helpers/apiHel import { bindableResources } from '../services/permitteerAccount' import { oidcAccessToken } from '../services/oidc' import { isSecureAgentURL } from '../services/agent' -import { selectLimitsLookup, selectLimits } from '../selectors/organizations' +import { selectLimitsLookup, selectFeatures } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' import { ListItemSetting } from '../components/ListItemSetting' @@ -27,8 +27,9 @@ export const TestPage: React.FC = () => { const apis = useSelector((state: State) => state.ui.apis) const testUI = useSelector((state: State) => state.ui.testUI) const preferences = useSelector((state: State) => state.backend.preferences) - const limitsOverride = useSelector(selectLimitsLookup) - const limits = useSelector(selectLimits) + const featureValues = useSelector(selectLimitsLookup) + const features = useSelector(selectFeatures) + const overrides = useSelector((state: State) => state.ui.limitsOverride) async function setAPIPreference(key: string, value: string | number | boolean) { await dispatch.ui.setPersistent({ apis: { ...apis, [key]: value } }) @@ -281,7 +282,9 @@ export const TestPage: React.FC = () => { return } setAgentError('') - setAPIPreference('agentURL', url) + // Reset (or entering the default) CLEARS the override so agentURL() falls back to the + // /agent proxy (dev) or VITE_AGENT_URL (build) — never pinning the OAuth audience as the transport. + setAPIPreference('agentURL', url === OAUTH_AGENT_RESOURCE ? '' : url) }} hideIcon /> @@ -298,28 +301,32 @@ export const TestPage: React.FC = () => { {t('testPage.features', 'Features')} - {limits.map(l => { - if (typeof l.value === 'boolean') - return ( - - dispatch.ui.setPersistent({ - limitsOverride: { ...limitsOverride, [l.name]: !limitsOverride[l.name] }, - }) - } - /> - ) - })} + {features.map(f => ( + + dispatch.ui.setPersistent({ limitsOverride: { ...overrides, [f.name]: !featureValues[f.name] } }) + } + /> + ))} =10" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1353,6 +1410,146 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -4440,6 +4637,17 @@ "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4721,6 +4929,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5334,6 +5549,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/sudo-prompt": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", @@ -5952,6 +6282,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -6364,6 +6704,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -6815,6 +7165,16 @@ "node": ">=6.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -6967,6 +7327,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7043,6 +7420,16 @@ "node": "*" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -7972,6 +8359,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -7984,6 +8385,32 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -8409,21 +8836,82 @@ "node": ">=8" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { "url": "https://github.com/sponsors/ljharb" } }, @@ -8523,6 +9011,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -8572,6 +9067,16 @@ "integrity": "sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -9764,6 +10269,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -9924,6 +10439,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -11370,6 +11895,19 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12316,6 +12854,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13357,6 +13902,109 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -13720,6 +14368,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -14151,6 +14806,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -16251,6 +16913,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -18296,6 +18975,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -18455,6 +19141,19 @@ "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -18845,6 +19544,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -19278,6 +19984,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -19303,6 +20016,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -19572,6 +20292,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -19634,6 +20374,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -19869,6 +20616,20 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -19899,6 +20660,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -19959,6 +20770,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -20953,6 +21777,54 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite-plugin-dynamic-import": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/vite-plugin-dynamic-import/-/vite-plugin-dynamic-import-1.6.0.tgz", @@ -20978,6 +21850,117 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -20987,6 +21970,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", @@ -21198,6 +22194,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/winston": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", @@ -21391,6 +22404,16 @@ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -21422,6 +22445,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", From 98f8777815429b0567a1b0cdb3d360b03a04db79 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 20:43:05 -0700 Subject: [PATCH 208/238] test(agent): pin agentURL() override resolution Covers the change to the agent service URL resolution: agentURL() honors a valid https override and otherwise falls back to the built-in (proxy/VITE_AGENT_URL); isSecureAgentURL accepts https only. Co-Authored-By: Claude Opus 4.8 --- frontend/src/services/agent.test.ts | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 frontend/src/services/agent.test.ts diff --git a/frontend/src/services/agent.test.ts b/frontend/src/services/agent.test.ts new file mode 100644 index 000000000..6e57d882b --- /dev/null +++ b/frontend/src/services/agent.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// agentURL() resolves the agent service base per request; isolate it from the store and the +// heavy oidc/constants modules it pulls in at import time. +const state: { ui: { apis: { agentURL?: string } } } = { ui: { apis: {} } } +vi.mock('../store', () => ({ store: { getState: () => state } })) +vi.mock('./oidc', () => ({ oidcAuthHeaders: vi.fn() })) +vi.mock('../constants', () => ({ OAUTH_AGENT_RESOURCE: 'https://agent.remote.it' })) + +import { agentURL, isSecureAgentURL } from './agent' + +beforeEach(() => { + state.ui.apis = {} +}) + +describe('isSecureAgentURL', () => { + it('accepts https only (CSP blocks plain http)', () => { + expect(isSecureAgentURL('https://agent.dev.remote.it')).toBe(true) + expect(isSecureAgentURL('http://agent.dev.remote.it')).toBe(false) + expect(isSecureAgentURL('agent.dev.remote.it')).toBe(false) + }) +}) + +describe('agentURL', () => { + it('honors a valid https override, trailing slash stripped', () => { + state.ui.apis = { agentURL: 'https://my-agent.example.com/' } + expect(agentURL()).toBe('https://my-agent.example.com') + }) + + it('ignores a non-https override and an unset override alike — falling back to the built-in', () => { + // No toggle any more: a plain field controls it, but only when the value is a valid https URL. + const fallback = agentURL() // unset + state.ui.apis = { agentURL: 'http://insecure.example.com' } + expect(agentURL()).toBe(fallback) // non-https override is ignored + expect(fallback).not.toContain('insecure') // the fallback is the built-in, never the override + }) +}) From 8c1b609435296d2a0cfa9e068eae17107e3a8487 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 21:04:21 -0700 Subject: [PATCH 209/238] =?UTF-8?q?Codex=20review=20fixes=20=E2=80=94=20AI?= =?UTF-8?q?-agent=20subsystem=20(holistic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining Codex findings, root-caused where they clustered. Chat popout persistence (store.ts): - Popout is a 2nd app instance on the shared 'app' key; whitelist:[] still wrote _persist and clobbered the main window. It now uses a no-op storage adapter (adopts via BroadcastChannel, persists nothing). (P1) - Persist chat ownerId so the identity guard preserves same-user chats across reload. (P2) Chat turn lifecycle (chat.ts, ChatHeader): - clearConversation (a reducer) can't abort the in-flight streamChat, so New Chat / identity change / deleting the open conversation orphaned a running turn. New effect newConversation (stop + clear) now backs all three. (P1) Chat popout handoff (ChatPanel, useChatSync): - Disable Pop out while streaming or an approval is pending (the handoff can't carry/resume it, and popping out stop()s the source, stranding the server turn). (P1) - syncTranscript on popout close/loss so the server's journaled remainder replaces the partial handback. (P2) Agent resilience (oidc.ts, agent.ts, chat.ts): - Bound the MCP protected-resource lookup (AbortSignal.timeout) so a slow endpoint can't block sign-in/switch/heal; the cached/fallback name stands. (P1) - fetchConversation returns null only on 404; other errors throw and openConversation preserves the transcript + reports instead of clearing as "vanished". (P2) Devices (DevicesPage): - Default-account-selection effect triggers on the inputs it reads (empty-list, default account) with a per-account guard, so late memberships / a switch to a cached empty account re-decide. (P2) i18n (locales, typecheck.yml): - Extract the untracked chat.*/signIn.* + backlog keys into the catalogs, and make CI run extraction + clean-diff before the parity check so a source key missing from every catalog can't pass unnoticed. (P2) ja/de/es values are placeholders for translation. Frontend + electron suites green; typecheck, i18n:check, vite build clean. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/typecheck.yml | 13 +- frontend/src/components/Chat/ChatHeader.tsx | 2 +- frontend/src/components/Chat/ChatPanel.tsx | 5 + frontend/src/hooks/useChatSync.ts | 11 +- frontend/src/i18n/locales/de/app.json | 137 ++++++++++++++++++- frontend/src/i18n/locales/en/app.json | 137 ++++++++++++++++++- frontend/src/i18n/locales/en/notices.json | 4 +- frontend/src/i18n/locales/es/app.json | 139 +++++++++++++++++++- frontend/src/i18n/locales/ja/app.json | 135 ++++++++++++++++++- frontend/src/models/chat.ts | 25 +++- frontend/src/pages/DevicesPage.tsx | 14 +- frontend/src/services/agent.ts | 5 +- frontend/src/services/oidc.ts | 6 +- frontend/src/store.ts | 22 +++- 14 files changed, 625 insertions(+), 30 deletions(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 306d3763c..37032bd25 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -39,7 +39,14 @@ jobs: - name: Run typecheck run: npm run typecheck - # Catalog parity: fails on a key missing from ja/de/es, a dead key, or an - # empty English value. Runs after typecheck so a broken build reports first. + # Catalog COVERAGE + parity. i18n:check alone only compares the catalogs to each other, so a + # t() key missing from ALL of them (a new UI string shipped on its inline default) passes + # unnoticed and renders untranslated for ja/de/es. So first re-extract from source: any + # missing key changes a catalog and the clean-diff check fails until it is committed. Then + # i18n:check verifies parity, no dead keys, and non-empty English. After typecheck so a + # broken build reports first. - name: Check translation catalogs - run: npm run i18n:check -w=frontend + run: | + npm run i18n:extract -w=frontend + git diff --exit-code -- frontend/src/i18n/locales || (echo "::error::Untracked i18n keys — run 'npm run i18n:extract -w frontend' and commit." && exit 1) + npm run i18n:check -w=frontend diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index d70187f5a..5e46bc37c 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -64,7 +64,7 @@ export const NewChatButton: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() return ( - dispatch.chat.clearConversation()} /> + dispatch.chat.newConversation()} /> ) } diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index a089916d6..1e91f0bde 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -33,6 +33,10 @@ export const ChatPanel: React.FC = () => { const open = useSelector((state: State) => state.chat.open) const insets = useSelector((state: State) => state.ui.layout.insets) const layout = useSelector((state: State) => state.ui.layout) + // Popping out hands the conversation to a second window and stop()s this one. While a turn is + // still streaming or an approval card is pending, the handoff can't carry/resume it — the popup + // couldn't action the approval and the server-side turn would strand — so block it until idle. + const turnActive = useSelector((state: State) => state.chat.streaming || !!state.chat.pendingConfirmation) const docked = useChatDocked() const chatWidth = useChatWidth() const maxWidth = useChatMaxWidth() @@ -140,6 +144,7 @@ export const ChatPanel: React.FC = () => { dispatch.chat.popOut()} /> )} diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index dd8d48152..56f7cae44 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -54,12 +54,21 @@ export const useChatMainSync = (): void => { adopt: payload => { dispatch.chat.adoptTranscript(payload) dispatch.chat.set({ poppedOut: false, open: true }) + // The handback carries only the partial response rendered when the popout closed; the + // server journals the rest of the turn, so pull its copy or the remainder is missing + // (and the partial looks complete) until a reload. + dispatch.chat.syncTranscript() }, onPopoutOpened: () => { dispatch.chat.stop() dispatch.chat.set({ open: false, poppedOut: true }) }, - onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }), + // A lost popout leaves no handback at all — reconcile against the server so the dock + // reopens on the true transcript rather than this window's stale copy. + onPopoutLost: () => { + dispatch.chat.set({ poppedOut: false, open: true }) + dispatch.chat.syncTranscript() + }, onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }), } const unsubscribe = initChatPopoutMain(handlers) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index de3ff22a5..dc7d30be7 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -280,6 +280,8 @@ }, "changePassword": { "currentPassword": "Aktuelles Passwort", + "mfaCode": "", + "mfaPrompt": "", "noticeAfter": "automatisch von anderen Sitzungen abgemeldet.", "noticeBefore": "Durch das Ändern Ihres Passworts werden Sie", "noticeEmphasis": "NICHT", @@ -287,6 +289,54 @@ "noticeTitle": "Hinweis", "title": "Passwort ändern" }, + "chat": { + "approve": "", + "close": "", + "deleteConfirmAction": "", + "deleteConfirmTitle": "", + "deleteConversation": "", + "deny": "", + "guideAgentBody": "", + "guideAgentTitle": "", + "guideComposeBody": "", + "guideComposeTitle": "", + "guideHistoryBody": "", + "guideHistoryTitle": "", + "history": "", + "historyEmpty": "", + "inputPlaceholder": "", + "interrupted": "", + "introBody": "", + "introTitle": "", + "newChat": "", + "newPrompt1": "", + "newPrompt2": "", + "newPrompt3": "", + "newSession": "", + "popIn": "", + "popOut": "", + "prompt1": "", + "prompt2": "", + "prompt3": "", + "send": "", + "signIn": "", + "signInFromMain": "", + "signInNeeded": "", + "stop": "", + "toolRequest": "", + "toolsUsed_one": "", + "toolsUsed_other": "", + "unavailable": "", + "untitled": "", + "usage": "", + "usageResets": "", + "usageSession": "", + "usageTitle": "", + "usageUnlimited": "", + "usageWeekly": "", + "waitingApproval": "", + "windowTitle": "" + }, "claimDevice": { "claim": "Beanspruchen", "claimCode": "Anspruchscode", @@ -404,6 +454,7 @@ "close": "Schließen", "confirm": "Bestätigen", "confirmingEllipsis": "Wird bestätigt...", + "continue": "", "delete": "Löschen", "disable": "Deaktivieren", "done": "Fertig", @@ -420,6 +471,7 @@ "saved": "Gespeichert", "saving": "Wird gespeichert", "savingEllipsis": "Wird gespeichert...", + "verify": "", "yes": "Ja" }, "connect": { @@ -445,26 +497,48 @@ "resetConnection": "Verbindung zurücksetzen" }, "connectedAppDetailPage": { + "accounts": "", + "addAccount": "", + "allAccounts": "", + "allAccountsAdd": "", + "allAccountsPlain": "", "authorized": "Autorisiert", + "confirmExtend": "", + "delayedReach": "", "details": "Details", "deviceAccess": "Gerätezugriff", + "editHint": "", "grantedWhenSignedIn": "Gewährt, als {{name}} sich angemeldet hat. Um sie zu ändern, widerrufen Sie den Zugriff und melden Sie sich erneut an.", "lastActive": "Zuletzt aktiv", + "lastUsed": "", "noActivityYet": "Noch keine Aktivität", "noDeviceAccess": "Kein Gerätezugriff – die App kann Ihre Identität bestätigen, aber keine Geräte sehen oder steuern.", "noLongerAuthorized": "Diese App ist nicht mehr autorisiert.", + "notGranted": "", + "notGrantedHint": "", "permissions": "Berechtigungen", "requestAgain": "Sie kann durch erneutes Anmelden erneut Zugriff anfordern.", "revoke": "Widerrufen", "revokeAccess": "Zugriff widerrufen", "revokeAccessConfirmTitle": "Zugriff widerrufen?", + "revoked": "", + "revokedHint": "", + "revokeExplain": "", + "revokeSection": "", + "save": "", + "saveFailed": "", "service": "Dienst", + "signInOnly": "", + "signInScopes": "", "signOutBefore": "wird abgemeldet. Neuer Zugriff wird sofort blockiert; bereits laufende Sitzungen enden innerhalb von", - "title": "Verbundene App" + "title": "Verbundene App", + "willAdd": "" }, "connectedApps": { "empty": "Sie haben noch keine Apps autorisiert.", "loading": "Wird geladen…", + "reauth": "", + "reauthAction": "", "title": "Apps & KI-Agenten" }, "connectedAppsPage": { @@ -897,6 +971,7 @@ "dismissAll": "alle ausblenden" }, "header": { + "aiAgent": "", "back": "Zurück", "create": "", "deviceSearch": "Gerätesuche", @@ -1070,6 +1145,32 @@ "logsPage": { "title": "Protokolle" }, + "mfa": { + "choose": "", + "codesTitle": "", + "confirmPassword": "", + "disable": "", + "enable": "", + "failed": "", + "federated": "", + "method": "", + "off": "", + "on": "", + "phone": "", + "prefer": "", + "preferred": "", + "protects": "", + "relay": "", + "relayHint": "", + "scan": "", + "secret": "", + "setPassword": "", + "smsSent": "", + "suggest": "", + "title": "", + "wrongCode": "", + "wrongPassword": "" + }, "mfaConfigureApp": { "code": "Code: {{code}}", "generateNewCode": "Neuen QR-Code generieren", @@ -1147,11 +1248,12 @@ "quitConfirm": "Das Beenden schließt Ihre Verbindungen nicht.", "scripting": "Skripte", "settings": "Einstellungen", + "signedInOnBrowser": "In diesem Browser angemeldet", "signOut": "Abmelden", "signOutConfirm": "Durch das Abmelden kann dieses Gerät übertragen oder ein anderer Benutzer angemeldet werden. Dadurch werden alle Verbindungen beendet.", - "signedInOnBrowser": "In diesem Browser angemeldet", "support": "Support", "supportForum": "Support-Forum", + "switchAccount": "", "testUI": "Test-UI", "testUIConfirm": "Das Aktivieren von Alpha-Funktionen kann zu Instabilität führen. Dies ist nur für Test- und Entwicklungszwecke vorgesehen.", "thisDevice": "Dieses Gerät", @@ -1499,6 +1601,15 @@ "noPartnersFound": "Keine Partner gefunden", "searchPlaceholder": "Partner suchen..." }, + "passkeys": { + "add": "", + "added": "", + "defaultName": "", + "explainer": "", + "failed": "", + "needFactor": "", + "title": "" + }, "passwordStrengthInput": { "confirmNewPassword": "Neues Passwort bestätigen", "enterNewPassword": "Neues Passwort eingeben", @@ -2056,6 +2167,24 @@ "chooseServices": "Wählen Sie die Dienste aus, für die Sie Zugriff gewähren möchten.", "services": "Dienste" }, + "signIn": { + "autoStopped": "", + "button": "", + "cancel": "", + "errorExpired": "", + "errorRateLimited": "", + "errorRateLimitedWait_one": "", + "errorRateLimitedWait_other": "", + "errorRefused": "", + "errorUnavailable": "", + "errorUnknown": "", + "errorUnreachable": "", + "redirecting": "", + "retry": "", + "subtitle": "", + "title": "", + "waiting": "" + }, "statusChip": { "connected": "Verbunden", "idle": "Inaktiv", @@ -2106,6 +2235,9 @@ "testPage": { "addQueryHeader": "Abfrage-Header hinzufügen", "addQueryHeaderPlaceholder": "Beispiel: \"key:value\"", + "agentURL": "", + "agentURLInvalid": "", + "aiAgent": "", "apiTarget": "API-Ziel", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", "clearViewedAnnouncementsHint": "Markiert alle geladenen Ankündigungen für dieses Konto als ungelesen.", @@ -2116,6 +2248,7 @@ "disableTestUI": "Test-UI deaktivieren", "disableTestUIHint": "Um die Alpha-UI wieder zu aktivieren, müssen Sie das Avatar-Menü bei gedrückter Alt-Umschalt-Taste auswählen.", "featureLabel": "{{name}} (Standard {{state}})", + "featurePending": "", "features": "Funktionen", "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 207e076d7..0731de171 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -280,6 +280,8 @@ }, "changePassword": { "currentPassword": "Current Password", + "mfaCode": "Authentication code", + "mfaPrompt": "Enter the 6-digit code from your authenticator to finish changing your password.", "noticeAfter": "automatically sign you out of other sessions.", "noticeBefore": "Changing your password will", "noticeEmphasis": "NOT", @@ -287,6 +289,54 @@ "noticeTitle": "Notice", "title": "Change Password" }, + "chat": { + "approve": "Approve", + "close": "Close", + "deleteConfirmAction": "Delete", + "deleteConfirmTitle": "Delete this conversation?", + "deleteConversation": "Delete", + "deny": "Deny", + "guideAgentBody": "Ask about your devices, connections and services — or tell it to make changes. Open and close it here any time.", + "guideAgentTitle": "Meet Remote.It AI", + "guideComposeBody": "Type a question, or say what you want changed. Anything that alters your account pauses for your approval first.", + "guideComposeTitle": "Just ask", + "guideHistoryBody": "Chats are saved. Switch between them, or start a new one, from here.", + "guideHistoryTitle": "Your conversations", + "history": "History", + "historyEmpty": "No past conversations", + "inputPlaceholder": "Chat with Remote.It", + "interrupted": "Interrupted", + "introBody": "Manage your devices, connections, and services — just ask.", + "introTitle": "Remote.It AI", + "newChat": "New Chat", + "newPrompt1": "How do I add my first device?", + "newPrompt2": "Try it out with the demo device", + "newPrompt3": "How do I reach a Raspberry Pi without port forwarding?", + "newSession": "New chat", + "popIn": "Pop back in", + "popOut": "Pop out", + "prompt1": "Which of my devices are offline?", + "prompt2": "Show my recent connections", + "prompt3": "Help me add a new device", + "send": "Send", + "signIn": "Refresh permissions", + "signInFromMain": "Refresh permissions from the main app window.", + "signInNeeded": "The AI agent needs permissions your session doesn’t carry yet.", + "stop": "Stop", + "toolRequest": "The agent wants to run {{tool}}", + "toolsUsed_one": "Used {{count}} tool", + "toolsUsed_other": "Used {{count}} tools", + "unavailable": "Remote.It AI is temporarily unavailable. Try again in a few minutes.", + "untitled": "New conversation", + "usage": "Usage", + "usageResets": "Resets {{when}}", + "usageSession": "5-hour session", + "usageTitle": "Usage", + "usageUnlimited": "No limit", + "usageWeekly": "This week", + "waitingApproval": "Waiting for approval…", + "windowTitle": "remote.it chat" + }, "claimDevice": { "claim": "Claim", "claimCode": "Claim Code", @@ -404,6 +454,7 @@ "close": "Close", "confirm": "Confirm", "confirmingEllipsis": "Confirming...", + "continue": "Continue", "delete": "Delete", "disable": "Disable", "done": "Done", @@ -420,6 +471,7 @@ "saved": "Saved", "saving": "Saving", "savingEllipsis": "Saving...", + "verify": "Verify", "yes": "Yes" }, "connect": { @@ -445,26 +497,48 @@ "resetConnection": "Reset connection" }, "connectedAppDetailPage": { + "accounts": "Accounts", + "addAccount": "{{label}} — add", + "allAccounts": "All accounts, including ones added later", + "allAccountsAdd": "All accounts, including ones added later — add", + "allAccountsPlain": "every account, including ones you join later", "authorized": "Authorized", + "confirmExtend": "Give {{name}} access it does not have yet?\n\nAdding: {{list}}", + "delayedReach": "Access already in progress at {{apis}} ends within {{window}}.", "details": "Details", "deviceAccess": "Device access", + "editHint": "Granted when {{name}} signed in. Tap a permission to disable it — it stays listed so you can re-enable it later.", "grantedWhenSignedIn": "Granted when {{name}} signed in. To change them, revoke access and have it sign in again.", "lastActive": "Last active", + "lastUsed": "Last used", "noActivityYet": "No activity yet", "noDeviceAccess": "No device access — it can confirm your identity, but cannot see or control any devices.", "noLongerAuthorized": "This app is no longer authorized.", + "notGranted": "{{label}} — not granted", + "notGrantedHint": "This app asked for this and you did not grant it. You can turn it on here.", "permissions": "Permissions", "requestAgain": "It can request access again by signing in.", "revoke": "Revoke", "revokeAccess": "Revoke access", "revokeAccessConfirmTitle": "Revoke access?", + "revoked": "revoked", + "revokedHint": "This access was revoked — shown for the record. {{name}} can request access again by signing in.", + "revokeExplain": "Signs {{name}} out of your account and blocks it from getting new access. It can request access again by signing in.", + "revokeSection": "Revoke access", + "save": "Save changes", + "saveFailed": "That change could not be saved.", "service": "Service", + "signInOnly": "Sign-in only — it can confirm your identity, but was granted nothing else.", + "signInScopes": "Sign-in scopes", "signOutBefore": "will be signed out. New access is blocked immediately; any session already in progress ends within", - "title": "Connected App" + "title": "Connected App", + "willAdd": "This gives the app access it does not have yet: {{list}}" }, "connectedApps": { "empty": "You have not authorized any apps yet.", "loading": "Loading…", + "reauth": "Sign in again to see your connected apps — your current session started before this page could ask for them.", + "reauthAction": "Sign in again", "title": "Apps & AI agents" }, "connectedAppsPage": { @@ -897,6 +971,7 @@ "dismissAll": "dismiss all" }, "header": { + "aiAgent": "AI Agent", "back": "Back", "create": "Create", "deviceSearch": "Device Search", @@ -1070,6 +1145,32 @@ "logsPage": { "title": "Logs" }, + "mfa": { + "choose": "How would you like to get your code? (totp or sms)", + "codesTitle": "Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.", + "confirmPassword": "Confirm your password to continue — changing a credential re-proves the one you hold.", + "disable": "Turn Off", + "enable": "Set Up", + "failed": "Something went wrong — try again.", + "federated": "You sign in with an identity provider (like Google), so your password and two-factor are managed there. To add a Remote.It password — usable alongside your provider — set one up first.", + "method": "Method", + "off": "Off", + "on": "On", + "phone": "Mobile number (+15555550123)", + "prefer": "Make preferred", + "preferred": "On · preferred", + "protects": "The preferred method challenges every sign-in with this account.", + "relay": "Enter the 6-digit code from your current second factor.", + "relayHint": "Enter the code sent to {{hint}}.", + "scan": "Scan with your authenticator app, then enter its 6-digit code.", + "secret": "Or enter the key manually:", + "setPassword": "Set a Password", + "smsSent": "We texted a code to your phone — enter it to finish turning on text-message codes.", + "suggest": "Protect your account with an authenticator app or text messages.", + "title": "Two-Factor Authentication", + "wrongCode": "That code didn't match — try again.", + "wrongPassword": "That password didn't match." + }, "mfaConfigureApp": { "code": "Code: {{code}}", "generateNewCode": "Generate new QR Code", @@ -1147,11 +1248,12 @@ "quitConfirm": "Quitting will not close your connections.", "scripting": "Scripting", "settings": "Settings", + "signedInOnBrowser": "Signed in on this browser", "signOut": "Sign out", "signOutConfirm": "Signing out will allow this device to be transferred or another user to sign in. It will stop all connections.", - "signedInOnBrowser": "Signed in on this browser", "support": "Support", "supportForum": "Support Forum", + "switchAccount": "Switch account", "testUI": "Test UI", "testUIConfirm": "Enabling alpha features may be unstable. It is only intended for testing and development.", "thisDevice": "This Device", @@ -1499,6 +1601,15 @@ "noPartnersFound": "No partners found", "searchPlaceholder": "Search partners..." }, + "passkeys": { + "add": "Add a Passkey", + "added": "Passkey added — next sign-in, use it instead of typing a code.", + "defaultName": "This device", + "explainer": "A passkey signs you in here with a touch instead of a code. Text or authenticator codes still protect sign-ins from older apps.", + "failed": "Something went wrong — try again.", + "needFactor": "Set up an authenticator or text codes first.", + "title": "Passkeys" + }, "passwordStrengthInput": { "confirmNewPassword": "Confirm new password", "enterNewPassword": "Enter new password", @@ -2056,6 +2167,24 @@ "chooseServices": "Choose the services you'd like to provide access to.", "services": "Services" }, + "signIn": { + "autoStopped": "Automatic sign-in stopped after repeated attempts. Select Sign In to try again.", + "button": "Sign In", + "cancel": "Cancel", + "errorExpired": "That sign-in attempt expired before it finished. Please try again.", + "errorRateLimited": "Too many sign-in attempts from this network. Please wait a few minutes and try again.", + "errorRateLimitedWait_one": "Too many sign-in attempts from this network. Please try again in about a minute.", + "errorRateLimitedWait_other": "Too many sign-in attempts from this network. Please try again in about {{count}} minutes.", + "errorRefused": "The sign-in service refused this request. Try again, and contact support if it keeps happening.", + "errorUnavailable": "The sign-in service is temporarily unavailable. Please try again in a few minutes.", + "errorUnknown": "Sign in didn't complete. Please try again.", + "errorUnreachable": "We couldn't reach the sign-in service. Check your internet connection, then try again.", + "redirecting": "Taking you to sign in…", + "retry": "Try again", + "subtitle": "We'll open your browser to sign you in with Remote.It Single Sign-On.", + "title": "Sign in to {{app}}", + "waiting": "Waiting for your browser… finish signing in there." + }, "statusChip": { "connected": "Connected", "idle": "Idle", @@ -2106,6 +2235,9 @@ "testPage": { "addQueryHeader": "Add query header", "addQueryHeaderPlaceholder": "Example: \"key:value\"", + "agentURL": "Agent service URL (advanced)", + "agentURLInvalid": "Agent service URL must start with https://", + "aiAgent": "AI Agent", "apiTarget": "API Target", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", @@ -2116,6 +2248,7 @@ "disableTestUI": "Disable Test UI", "disableTestUIHint": "To re-enable the alpha UI you will have to select the Avatar menu while holding alt-shift.", "featureLabel": "{{name}} (default {{state}})", + "featurePending": "Not in any license yet. Switches here apply to your personal account only.", "features": "Features", "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", diff --git a/frontend/src/i18n/locales/en/notices.json b/frontend/src/i18n/locales/en/notices.json index 6e55ec136..3817787bf 100644 --- a/frontend/src/i18n/locales/en/notices.json +++ b/frontend/src/i18n/locales/en/notices.json @@ -10,11 +10,11 @@ "passwordChanged": "Password changed successfully." }, "chat": { - "usageReset": "{{msg}} Resets {{when}}.", "authRequired": "Agent authentication required — sign in to continue.", "popupBlocked": "Pop out was blocked — allow popups for this site and try again.", "sessionExpired": "The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.", - "signInFailed": "Agent sign-in failed — {{error}}" + "signInFailed": "Agent sign-in failed — {{error}}", + "usageReset": "{{msg}} Resets {{when}}." }, "connection": { "surveyFailed": "Connection survey submission failed. Please contact support." diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 8ce571517..5ac0f19c9 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -283,6 +283,8 @@ }, "changePassword": { "currentPassword": "Contraseña actual", + "mfaCode": "", + "mfaPrompt": "", "noticeAfter": "cerrará automáticamente tu sesión en otros dispositivos.", "noticeBefore": "Cambiar tu contraseña", "noticeEmphasis": "NO", @@ -290,6 +292,55 @@ "noticeTitle": "Aviso", "title": "Cambiar contraseña" }, + "chat": { + "approve": "", + "close": "", + "deleteConfirmAction": "", + "deleteConfirmTitle": "", + "deleteConversation": "", + "deny": "", + "guideAgentBody": "", + "guideAgentTitle": "", + "guideComposeBody": "", + "guideComposeTitle": "", + "guideHistoryBody": "", + "guideHistoryTitle": "", + "history": "", + "historyEmpty": "", + "inputPlaceholder": "", + "interrupted": "", + "introBody": "", + "introTitle": "", + "newChat": "", + "newPrompt1": "", + "newPrompt2": "", + "newPrompt3": "", + "newSession": "", + "popIn": "", + "popOut": "", + "prompt1": "", + "prompt2": "", + "prompt3": "", + "send": "", + "signIn": "", + "signInFromMain": "", + "signInNeeded": "", + "stop": "", + "toolRequest": "", + "toolsUsed_one": "", + "toolsUsed_many": "", + "toolsUsed_other": "", + "unavailable": "", + "untitled": "", + "usage": "", + "usageResets": "", + "usageSession": "", + "usageTitle": "", + "usageUnlimited": "", + "usageWeekly": "", + "waitingApproval": "", + "windowTitle": "" + }, "claimDevice": { "claim": "Reclamar", "claimCode": "Código de reclamo", @@ -407,6 +458,7 @@ "close": "Cerrar", "confirm": "Confirmar", "confirmingEllipsis": "Confirmando...", + "continue": "", "delete": "Eliminar", "disable": "Deshabilitar", "done": "Listo", @@ -423,6 +475,7 @@ "saved": "Guardado", "saving": "Guardando", "savingEllipsis": "Guardando...", + "verify": "", "yes": "Sí" }, "connect": { @@ -448,26 +501,48 @@ "resetConnection": "Restablecer conexión" }, "connectedAppDetailPage": { + "accounts": "", + "addAccount": "", + "allAccounts": "", + "allAccountsAdd": "", + "allAccountsPlain": "", "authorized": "Autorizado", + "confirmExtend": "", + "delayedReach": "", "details": "Detalles", "deviceAccess": "Acceso a dispositivos", + "editHint": "", "grantedWhenSignedIn": "Concedido cuando {{name}} inició sesión. Para cambiarlos, revoca el acceso y haz que vuelva a iniciar sesión.", "lastActive": "Última actividad", + "lastUsed": "", "noActivityYet": "Aún sin actividad", "noDeviceAccess": "Sin acceso a dispositivos: puede confirmar tu identidad, pero no puede ver ni controlar ningún dispositivo.", "noLongerAuthorized": "Esta aplicación ya no está autorizada.", + "notGranted": "", + "notGrantedHint": "", "permissions": "Permisos", "requestAgain": "Puede solicitar acceso nuevamente al iniciar sesión.", "revoke": "Revocar", "revokeAccess": "Revocar acceso", "revokeAccessConfirmTitle": "¿Revocar el acceso?", + "revoked": "", + "revokedHint": "", + "revokeExplain": "", + "revokeSection": "", + "save": "", + "saveFailed": "", "service": "Servicio", + "signInOnly": "", + "signInScopes": "", "signOutBefore": "cerrará sesión. El nuevo acceso se bloquea de inmediato; cualquier sesión ya en curso finaliza dentro de", - "title": "Aplicación conectada" + "title": "Aplicación conectada", + "willAdd": "" }, "connectedApps": { "empty": "Aún no has autorizado ninguna aplicación.", "loading": "Cargando…", + "reauth": "", + "reauthAction": "", "title": "Aplicaciones y agentes de IA" }, "connectedAppsPage": { @@ -906,6 +981,7 @@ "dismissAll": "descartar todo" }, "header": { + "aiAgent": "", "back": "Atrás", "create": "", "deviceSearch": "Búsqueda de dispositivos", @@ -1082,6 +1158,32 @@ "logsPage": { "title": "Registros" }, + "mfa": { + "choose": "", + "codesTitle": "", + "confirmPassword": "", + "disable": "", + "enable": "", + "failed": "", + "federated": "", + "method": "", + "off": "", + "on": "", + "phone": "", + "prefer": "", + "preferred": "", + "protects": "", + "relay": "", + "relayHint": "", + "scan": "", + "secret": "", + "setPassword": "", + "smsSent": "", + "suggest": "", + "title": "", + "wrongCode": "", + "wrongPassword": "" + }, "mfaConfigureApp": { "code": "Código: {{code}}", "generateNewCode": "Generar nuevo código QR", @@ -1159,11 +1261,12 @@ "quitConfirm": "Salir no cerrará tus conexiones.", "scripting": "Scripts", "settings": "Configuración", + "signedInOnBrowser": "Con sesión iniciada en este navegador", "signOut": "Cerrar sesión", "signOutConfirm": "Al cerrar sesión, este dispositivo podrá transferirse o permitir que otro usuario inicie sesión. Esto detendrá todas las conexiones.", - "signedInOnBrowser": "Con sesión iniciada en este navegador", "support": "Soporte", "supportForum": "Foro de soporte", + "switchAccount": "", "testUI": "UI de prueba", "testUIConfirm": "Activar las funciones alfa puede provocar inestabilidad. Está pensado únicamente para pruebas y desarrollo.", "thisDevice": "Este dispositivo", @@ -1518,6 +1621,15 @@ "noPartnersFound": "No se encontraron socios", "searchPlaceholder": "Buscar socios..." }, + "passkeys": { + "add": "", + "added": "", + "defaultName": "", + "explainer": "", + "failed": "", + "needFactor": "", + "title": "" + }, "passwordStrengthInput": { "confirmNewPassword": "Confirmar nueva contraseña", "enterNewPassword": "Introduce la nueva contraseña", @@ -2089,6 +2201,25 @@ "chooseServices": "Elige los servicios a los que deseas dar acceso.", "services": "Servicios" }, + "signIn": { + "autoStopped": "", + "button": "", + "cancel": "", + "errorExpired": "", + "errorRateLimited": "", + "errorRateLimitedWait_one": "", + "errorRateLimitedWait_many": "", + "errorRateLimitedWait_other": "", + "errorRefused": "", + "errorUnavailable": "", + "errorUnknown": "", + "errorUnreachable": "", + "redirecting": "", + "retry": "", + "subtitle": "", + "title": "", + "waiting": "" + }, "statusChip": { "connected": "Conectado", "idle": "Inactivo", @@ -2141,6 +2272,9 @@ "testPage": { "addQueryHeader": "Agregar encabezado de consulta", "addQueryHeaderPlaceholder": "Ejemplo: \"key:value\"", + "agentURL": "", + "agentURLInvalid": "", + "aiAgent": "", "apiTarget": "Destino de la API", "clearViewedAnnouncements": "Borrar anuncios vistos", "clearViewedAnnouncementsHint": "Marca todos los anuncios cargados como no leídos para esta cuenta.", @@ -2151,6 +2285,7 @@ "disableTestUI": "Deshabilitar la interfaz de prueba", "disableTestUIHint": "Para volver a habilitar la interfaz alfa, debes seleccionar el menú de avatar mientras mantienes presionado alt-shift.", "featureLabel": "{{name}} (predeterminado {{state}})", + "featurePending": "", "features": "Funciones", "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 31b9c198b..766f40fe6 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -277,6 +277,8 @@ }, "changePassword": { "currentPassword": "現在のパスワード", + "mfaCode": "", + "mfaPrompt": "", "noticeAfter": "他のセッションからサインアウトされることはありません。", "noticeBefore": "パスワードの変更では、", "noticeEmphasis": "自動的に", @@ -284,6 +286,53 @@ "noticeTitle": "注意", "title": "パスワードを変更" }, + "chat": { + "approve": "", + "close": "", + "deleteConfirmAction": "", + "deleteConfirmTitle": "", + "deleteConversation": "", + "deny": "", + "guideAgentBody": "", + "guideAgentTitle": "", + "guideComposeBody": "", + "guideComposeTitle": "", + "guideHistoryBody": "", + "guideHistoryTitle": "", + "history": "", + "historyEmpty": "", + "inputPlaceholder": "", + "interrupted": "", + "introBody": "", + "introTitle": "", + "newChat": "", + "newPrompt1": "", + "newPrompt2": "", + "newPrompt3": "", + "newSession": "", + "popIn": "", + "popOut": "", + "prompt1": "", + "prompt2": "", + "prompt3": "", + "send": "", + "signIn": "", + "signInFromMain": "", + "signInNeeded": "", + "stop": "", + "toolRequest": "", + "toolsUsed_other": "", + "unavailable": "", + "untitled": "", + "usage": "", + "usageResets": "", + "usageSession": "", + "usageTitle": "", + "usageUnlimited": "", + "usageWeekly": "", + "waitingApproval": "", + "windowTitle": "" + }, "claimDevice": { "claim": "登録", "claimCode": "登録コード", @@ -401,6 +450,7 @@ "close": "閉じる", "confirm": "確認", "confirmingEllipsis": "確認中...", + "continue": "", "delete": "削除", "disable": "無効にする", "done": "完了", @@ -417,6 +467,7 @@ "saved": "保存しました", "saving": "保存中", "savingEllipsis": "保存中...", + "verify": "", "yes": "はい" }, "connect": { @@ -442,26 +493,48 @@ "resetConnection": "接続をリセット" }, "connectedAppDetailPage": { + "accounts": "", + "addAccount": "", + "allAccounts": "", + "allAccountsAdd": "", + "allAccountsPlain": "", "authorized": "承認済み", + "confirmExtend": "", + "delayedReach": "", "details": "詳細", "deviceAccess": "デバイスアクセス", + "editHint": "", "grantedWhenSignedIn": "{{name}} がサインインしたときに付与されました。変更するには、アクセスを取り消してから再度サインインさせてください。", "lastActive": "最終アクティブ", + "lastUsed": "", "noActivityYet": "アクティビティはまだありません", "noDeviceAccess": "デバイスアクセスなし — 本人確認は可能ですが、デバイスの表示や制御はできません。", "noLongerAuthorized": "このアプリは承認が取り消されています。", + "notGranted": "", + "notGrantedHint": "", "permissions": "権限", "requestAgain": "サインインすることで、再度アクセスを要求できます。", "revoke": "取り消す", "revokeAccess": "アクセスを取り消す", "revokeAccessConfirmTitle": "アクセスを取り消しますか?", + "revoked": "", + "revokedHint": "", + "revokeExplain": "", + "revokeSection": "", + "save": "", + "saveFailed": "", "service": "サービス", + "signInOnly": "", + "signInScopes": "", "signOutBefore": "はサインアウトされます。新しいアクセスは即座にブロックされ、進行中のセッションはこの時間内に終了します:", - "title": "連携アプリ" + "title": "連携アプリ", + "willAdd": "" }, "connectedApps": { "empty": "まだアプリを承認していません。", "loading": "読み込み中…", + "reauth": "", + "reauthAction": "", "title": "アプリとAIエージェント" }, "connectedAppsPage": { @@ -888,6 +961,7 @@ "dismissAll": "すべて閉じる" }, "header": { + "aiAgent": "", "back": "戻る", "create": "", "deviceSearch": "デバイス検索", @@ -1058,6 +1132,32 @@ "logsPage": { "title": "ログ" }, + "mfa": { + "choose": "", + "codesTitle": "", + "confirmPassword": "", + "disable": "", + "enable": "", + "failed": "", + "federated": "", + "method": "", + "off": "", + "on": "", + "phone": "", + "prefer": "", + "preferred": "", + "protects": "", + "relay": "", + "relayHint": "", + "scan": "", + "secret": "", + "setPassword": "", + "smsSent": "", + "suggest": "", + "title": "", + "wrongCode": "", + "wrongPassword": "" + }, "mfaConfigureApp": { "code": "コード: {{code}}", "generateNewCode": "新しいQRコードを生成", @@ -1135,11 +1235,12 @@ "quitConfirm": "終了しても接続は閉じられません。", "scripting": "スクリプト", "settings": "設定", + "signedInOnBrowser": "このブラウザでサインイン済み", "signOut": "サインアウト", "signOutConfirm": "サインアウトすると、このデバイスの譲渡や他のユーザーのサインインが可能になります。すべての接続が停止します。", - "signedInOnBrowser": "このブラウザでサインイン済み", "support": "サポート", "supportForum": "サポートフォーラム", + "switchAccount": "", "testUI": "テストUI", "testUIConfirm": "アルファ機能を有効にすると、動作が不安定になることがあります。テストおよび開発目的のみを想定しています。", "thisDevice": "このデバイス", @@ -1480,6 +1581,15 @@ "noPartnersFound": "パートナーが見つかりません", "searchPlaceholder": "パートナーを検索..." }, + "passkeys": { + "add": "", + "added": "", + "defaultName": "", + "explainer": "", + "failed": "", + "needFactor": "", + "title": "" + }, "passwordStrengthInput": { "confirmNewPassword": "新しいパスワードを確認", "enterNewPassword": "新しいパスワードを入力", @@ -2023,6 +2133,23 @@ "chooseServices": "アクセスを許可するサービスを選択してください。", "services": "サービス" }, + "signIn": { + "autoStopped": "", + "button": "", + "cancel": "", + "errorExpired": "", + "errorRateLimited": "", + "errorRateLimitedWait_other": "", + "errorRefused": "", + "errorUnavailable": "", + "errorUnknown": "", + "errorUnreachable": "", + "redirecting": "", + "retry": "", + "subtitle": "", + "title": "", + "waiting": "" + }, "statusChip": { "connected": "接続済み", "idle": "アイドル", @@ -2071,6 +2198,9 @@ "testPage": { "addQueryHeader": "クエリヘッダーを追加", "addQueryHeaderPlaceholder": "例: \"key:value\"", + "agentURL": "", + "agentURLInvalid": "", + "aiAgent": "", "apiTarget": "APIターゲット", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", @@ -2081,6 +2211,7 @@ "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", "featureLabel": "{{name}}(デフォルト: {{state}})", + "featurePending": "", "features": "機能", "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index add79e2a6..b171e1edd 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -290,6 +290,15 @@ export default createModel()({ abortController = null dispatch.chat.set({ streaming: false, pendingConfirmation: null }) }, + /* Discard the current conversation AND any in-flight turn together. clearConversation is a + reducer, so it cannot abort the streamChat request on its own: a turn left running would + keep appending events to the freshly cleared transcript, and the next send would orphan its + AbortController (Stop then targets only the newer turn, mixing two conversations). New Chat, + an identity change, and deleting the open conversation all route through here. */ + async newConversation() { + await dispatch.chat.stop() + dispatch.chat.clearConversation() + }, /* Move the conversation to its own window; the dock hides when the popout says hello. A blocked popup is surfaced instead of silently ignored. */ async popOut() { @@ -354,7 +363,7 @@ export default createModel()({ over (posting to it 404s, and its history isn't yours). Same identity → no-op. */ async syncIdentity(userId: string, state) { if (!userId || state.chat.ownerId === userId) return - dispatch.chat.clearConversation() + await dispatch.chat.newConversation() dispatch.chat.set({ ownerId: userId, conversations: [], usage: null }) dispatch.chat.loadConversations() dispatch.chat.loadUsage() @@ -376,9 +385,17 @@ export default createModel()({ live turn state so nothing from the previous thread bleeds across. */ async openConversation(id: string, state) { if (state.chat.streaming) dispatch.chat.stop() - const remote = await fetchConversation(id) + let remote + try { + remote = await fetchConversation(id) + } catch (error) { + // A service or auth failure is not a deletion: keep the transcript on screen and report, + // rather than clearing to a new chat as if the conversation had vanished. + dispatch.chat.set({ error: (error as Error).message }) + return + } if (!remote) { - // Vanished (deleted elsewhere) — drop it from the list and start fresh. + // Vanished — a genuine 404 (deleted elsewhere). Drop it from the list and start fresh. dispatch.chat.clearConversation() await dispatch.chat.loadConversations() return @@ -400,7 +417,7 @@ export default createModel()({ /* Delete a conversation for real (D9). If it's the one on screen, clear to a new chat. */ async removeConversation(id: string, state) { await deleteConversation(id) - if (state.chat.conversationId === id) dispatch.chat.clearConversation() + if (state.chat.conversationId === id) await dispatch.chat.newConversation() await dispatch.chat.loadConversations() }, /* App sign-out: nothing agent-specific to revoke — the session's end IS the diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index 47e906933..11c016dcd 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Dispatch, State } from '../store' import { useHistory } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' @@ -38,14 +38,20 @@ export const DevicesPage: React.FC = ({ restore, select }) => { /* An empty list means "add your first device" only once it has actually loaded — so arm on the way down and redirect on the way up, never both in one pass. Keyed to the account and re-run when the list empties, so every switch re-decides instead - of inheriting the last account's answer. */ + of inheriting the last account's answer. The trigger must include the inputs the + decision reads — the empty-list and default-account signals — because memberships + arriving late, or switching to an already-loaded empty account, change those + without touching `initialized`. A ref keyed to the account we acted for keeps that + from re-selecting or re-pushing /add on every re-run. */ + const actedFor = useRef(undefined) useEffect(() => { if (!initialized) setInitLoad(true) - if (shouldRedirect && !devices.length) { + if (shouldRedirect && !devices.length && actedFor.current !== defaultAccountId) { + actedFor.current = defaultAccountId if (defaultAccountId) accounts.select(defaultAccountId) else history.push('/add') } - }, [initialized, history]) + }, [initialized, shouldRedirect, devices.length, defaultAccountId, history, accounts]) return ( diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 10490a809..2909b5f63 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -160,7 +160,10 @@ export async function fetchConversation( ): Promise<{ title: string | null; messages: Array<{ role: string; content: string }> } | null> { const path = `/api/conversations/${encodeURIComponent(conversationId)}` const response = await fetch(`${agentURL()}${path}`, { headers: await agentHeaders('GET', path, false) }) - if (!response.ok) return null + // null means GONE (callers clear the local copy). An auth/service failure is NOT a deletion — + // throw it so callers preserve the transcript and report, instead of discarding a live chat. + if (response.status === 404) return null + if (!response.ok) throw new Error(`fetchConversation: ${response.status}`) return (await response.json()) as { title: string | null; messages: Array<{ role: string; content: string }> } } diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 63b96dde3..4b803a9ce 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -290,7 +290,11 @@ async function refreshMcpDetailType(): Promise { try { const r = new URL(OAUTH_MCP_RESOURCE) const prm = `${r.origin}/.well-known/oauth-protected-resource${r.pathname}` - const doc = (await (await fetch(prm)).json()) as { + // BOUND it: this optional agent-metadata lookup sits on the sign-in / account-switch / + // grant-heal path, so a slow or half-open MCP endpoint must not block authentication. On + // timeout the fetch aborts, the catch fires, and the cached/fallback name (mcpDetailType()) + // stands — the AS being healthy is enough to sign in. + const doc = (await (await fetch(prm, { signal: AbortSignal.timeout(4000) })).json()) as { authorization_details_types_supported?: string[] authorization_details_types?: Array<{ type?: string; risk_class?: string }> } diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 9961ba53e..53e3d0968 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -15,12 +15,15 @@ const loggerConfig: ReduxLoggerOptions = { } // Persist only the durable chat fields — streaming/pendingConfirmation/error/ -// health are runtime-only and must never survive a reload +// health are runtime-only and must never survive a reload. ownerId IS durable: it is +// what syncIdentity compares against the signed-in user, so without it a reload resets +// ownerId to '' and the guard clears the transcript as if a different person had signed in. const chatTransform = createTransform( (inbound: IChatState) => ({ messages: inbound.messages, conversationId: inbound.conversationId, orgId: inbound.orgId, + ownerId: inbound.ownerId, open: inbound.open, width: inbound.width, poppedOut: inbound.poppedOut, @@ -29,13 +32,22 @@ const chatTransform = createTransform( { whitelist: ['chat'] } ) +// The chat popout is a SECOND full app instance on the same 'app' storage key. redux-persist +// with whitelist:[] does NOT disable writes — it still persists its _persist metadata (and an +// otherwise-empty state) to that shared key, clobbering the main window's cached accounts, +// devices, chat, etc. So the popout gets a storage adapter that reads/writes NOTHING: it adopts +// its transcript over the BroadcastChannel handoff and owns no durable state of its own. +const noopStorage = { + getItem: () => Promise.resolve(null), + setItem: () => Promise.resolve(), + removeItem: () => Promise.resolve(), +} + const persistConfig: PersistConfig = { key: 'app', version: numericVersion(), - storage: localForage, - // The chat popout window is a second full app instance on the same storage - // key; it adopts its transcript over the BroadcastChannel handoff and must - // never write, or the two windows clobber each other (last-writer-wins) + // The popout persists nothing (noopStorage) so it cannot clobber the main window's 'app' key. + storage: isChatPopout ? noopStorage : localForage, whitelist: isChatPopout ? [] : [ From 58cf2204807dfbcbf0fce7980094d4310f7864ae Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 21:21:48 -0700 Subject: [PATCH 210/238] Codex review fixes (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sign-out background revocation (P1): auth.signedOut now AWAITS chat.signOut, which awaits a BOUNDED backgroundDisable — the unawaited revoke raced oidcClearLocal(), minted no token, and left background AI access alive after sign-out. - Test header: restore getTestHeader() in get/post/jobLogs/cloudController (WS) — the Test Settings "Add query header" control was visible but never sent. - listConversations: throw on non-OK (was returning [] and overwriting cached history on a transient 401/503); loadConversations' catch keeps the last-known list. - removeConversation: honor a failed DELETE — keep the conversation + report instead of clearing it as if it had been deleted. - syncTranscript: adopt the server copy when it DIFFERS (last-message text), not only when it is longer — so a same-length completed reply replaces the popout's partial — and apply the title. - store.ts: persist chat `title` too, so a reload restores the header instead of "New chat". - DevicesPage: key the empty-list guard by the ACTIVE account, not defaultAccountId (undefined on a membership-less personal account never redirected to /add). Frontend + electron suites green; typecheck, i18n:check, vite build clean. Co-Authored-By: Claude Opus 4.8 --- frontend/src/models/auth.ts | 5 ++- frontend/src/models/chat.ts | 47 +++++++++++++++++------- frontend/src/pages/DevicesPage.tsx | 11 ++++-- frontend/src/services/agent.ts | 4 +- frontend/src/services/cloudController.ts | 3 +- frontend/src/services/get.ts | 6 +-- frontend/src/services/jobLogs.ts | 4 +- frontend/src/services/post.ts | 4 +- frontend/src/store.ts | 1 + 9 files changed, 59 insertions(+), 26 deletions(-) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 7851b50ae..cfed5bd3f 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -471,7 +471,10 @@ export default createModel()({ // re-save the pre-signout state for the next user of the machine. // (The DCR agent session retires with the permitteer chat lane — // remoteit-ai-agent.md Phase 4; until then both sign-outs run.) - dispatch.chat.signOut() + // AWAIT the chat sign-out: it revokes the background-agent grant, whose authenticated DELETE + // needs a live token — letting it run unawaited raced the oidcClearLocal() below and left + // background AI access alive. chat.signOut bounds itself so this never hangs the sign-out. + await dispatch.chat.signOut() await persistor.purge() // LOCAL-ONLY: drop this app's tokens. The AS session is never ended from here — // signing out of the app must not sign the user out of login.* (their browser diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index b171e1edd..32279b911 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -331,15 +331,22 @@ export default createModel()({ if (!id || state.chat.streaming) return try { const remote = await fetchConversation(id) - if (remote && remote.messages.length > state.chat.messages.length) { - dispatch.chat.set({ - messages: remote.messages.map(m => - m.role === 'assistant' - ? { role: 'assistant' as const, text: m.content, toolCalls: [] } - : { role: 'user' as const, text: m.content } - ), - }) - } + if (!remote) return + const messages = remote.messages.map(m => + m.role === 'assistant' + ? { role: 'assistant' as const, text: m.content, toolCalls: [] } + : { role: 'user' as const, text: m.content } + ) + // Adopt the server copy when it DIFFERS, not only when it is longer: a popout hands back a + // partially rendered reply the server then completes to the SAME message count, so a + // length-only test leaves the partial on screen. Compare the last message's text too. Also + // apply the server title — a reload restores conversationId but the title defaults to ''. + const last = messages[messages.length - 1]?.text ?? '' + const localLast = state.chat.messages[state.chat.messages.length - 1]?.text ?? '' + const differs = messages.length !== state.chat.messages.length || last !== localLast + const title = remote.title || state.chat.title + if (differs) dispatch.chat.set({ messages, title }) + else if (title !== state.chat.title) dispatch.chat.set({ title }) } catch { /* offline or deleted — the local display cache stands */ } @@ -416,7 +423,15 @@ export default createModel()({ }, /* Delete a conversation for real (D9). If it's the one on screen, clear to a new chat. */ async removeConversation(id: string, state) { - await deleteConversation(id) + // A failed DELETE (401/403/5xx) is NOT a deletion — the row survives on the server and would + // reappear on the next refresh. Report it and keep the local copy, rather than clearing the + // open transcript as though it succeeded. + if (!(await deleteConversation(id))) { + dispatch.chat.set({ + error: i18n.t('notices:chat.deleteFailed', { defaultValue: 'Could not delete the conversation — try again.' }), + }) + return + } if (state.chat.conversationId === id) await dispatch.chat.newConversation() await dispatch.chat.loadConversations() }, @@ -428,9 +443,15 @@ export default createModel()({ broadcastChatSignout() abortController?.abort() abortController = null - // Explicit sign-out ends the background relationship too (plan D8): best-effort - // revoke of the agent's stored grant, before the session tokens vanish. - void backgroundDisable() + // Explicit sign-out ends the background relationship (plan D8): revoke the agent's stored + // grant BEFORE the session tokens vanish. AWAITED but BOUNDED — an unawaited revoke raced + // oidcClearLocal(), so its authenticated DELETE minted no token and background AI access + // survived sign-out. Awaiting lets the revoke finish while the tokens are still valid; the + // timeout keeps a slow agent from blocking sign-out. + await Promise.race([ + backgroundDisable().catch(() => {}), + new Promise(resolve => setTimeout(resolve, 3000)), + ]) }, }), reducers: { diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index 11c016dcd..951fa27cf 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -43,15 +43,20 @@ export const DevicesPage: React.FC = ({ restore, select }) => { arriving late, or switching to an already-loaded empty account, change those without touching `initialized`. A ref keyed to the account we acted for keeps that from re-selecting or re-pushing /add on every re-run. */ + // Guard by the ACTIVE account, not defaultAccountId: on a personal account with no memberships + // defaultAccountId is undefined, so keying on it would make the guard `undefined !== undefined` + // (never redirect to /add) and could not tell one chosen account from the next. activeAccountId + // is always set and changes on every switch, so each account decides exactly once. + const activeAccountId = useSelector((state: State) => state.accounts.activeId || state.user.id) const actedFor = useRef(undefined) useEffect(() => { if (!initialized) setInitLoad(true) - if (shouldRedirect && !devices.length && actedFor.current !== defaultAccountId) { - actedFor.current = defaultAccountId + if (shouldRedirect && !devices.length && actedFor.current !== activeAccountId) { + actedFor.current = activeAccountId if (defaultAccountId) accounts.select(defaultAccountId) else history.push('/add') } - }, [initialized, shouldRedirect, devices.length, defaultAccountId, history, accounts]) + }, [initialized, shouldRedirect, devices.length, defaultAccountId, activeAccountId, history, accounts]) return ( diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 2909b5f63..e0e005239 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -150,7 +150,9 @@ export type ConversationSummary = { id: string; title: string | null; createdAt: /* The user's conversations, newest first (D11) — the history picker's source. */ export async function listConversations(): Promise { const response = await fetch(`${agentURL()}/api/conversations`, { headers: await agentHeaders('GET', '/api/conversations', false) }) - if (!response.ok) return [] + // Don't turn an auth/service failure (401/403/5xx) into an empty list — loadConversations would + // overwrite the last-known history as though the user had none. Throw so its catch keeps it. + if (!response.ok) throw new Error(`listConversations: ${response.status}`) return ((await response.json()) as { conversations: ConversationSummary[] }).conversations } diff --git a/frontend/src/services/cloudController.ts b/frontend/src/services/cloudController.ts index 77db7eda0..13322df7e 100644 --- a/frontend/src/services/cloudController.ts +++ b/frontend/src/services/cloudController.ts @@ -6,7 +6,7 @@ import { selectJob } from '../selectors/scripting' import { AxiosResponse } from 'axios' import { isReverseProxy } from '../models/applicationTypes' import { getAccountIds, accountFromDevice } from '../models/accounts' -import { getWebSocketURL } from '../helpers/apiHelper' +import { getWebSocketURL, getTestHeader } from '../helpers/apiHelper' import { DEVICE_TYPE } from '@common/applications' import { getToken } from './remoteit' import { oidcAccessToken } from './oidc' @@ -174,6 +174,7 @@ class CloudController { headers: { authorization: await wsAuthorization(), 'User-Agent': `remoteit/${version} ${agent()}`, + ...getTestHeader(), }, query: ` { diff --git a/frontend/src/services/get.ts b/frontend/src/services/get.ts index 05e315e9b..42906e21b 100644 --- a/frontend/src/services/get.ts +++ b/frontend/src/services/get.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { getApiURL } from '../helpers/apiHelper' +import { getApiURL, getTestHeader } from '../helpers/apiHelper' import { apiAuthHeaders } from './remoteit' import { apiError } from './post' import { store } from '../store' @@ -14,8 +14,8 @@ export async function get(path: string = '') { return } - const headers: any = { ...auth } - + const headers: any = { ...auth, ...getTestHeader() } + // Add x-r3-user header if in view-as mode const viewAsUser = store.getState().ui.viewAsUser if (viewAsUser) { diff --git a/frontend/src/services/jobLogs.ts b/frontend/src/services/jobLogs.ts index 9b4242989..a05b33d82 100644 --- a/frontend/src/services/jobLogs.ts +++ b/frontend/src/services/jobLogs.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { getApiURL } from '../helpers/apiHelper' +import { getApiURL, getTestHeader } from '../helpers/apiHelper' import { apiAuthHeaders } from './remoteit' import { store } from '../store' @@ -42,7 +42,7 @@ export async function getJobLogs(jobId: string): Promise { return { kind: 'error', status: 401, message: 'Not signed in' } } - const headers: any = { ...auth } + const headers: any = { ...auth, ...getTestHeader() } const viewAsUser = store.getState().ui.viewAsUser if (viewAsUser) headers['X-R3-User'] = viewAsUser.id diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index 760669423..24f8b13d3 100644 --- a/frontend/src/services/post.ts +++ b/frontend/src/services/post.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { getApiURL } from '../helpers/apiHelper' +import { getApiURL, getTestHeader } from '../helpers/apiHelper' import { apiAuthHeaders } from './remoteit' import { store } from '../store' import network from './Network' @@ -21,7 +21,7 @@ export async function post(data: ILookup = {}, path: string = '') { return } - const headers: any = { ...auth } + const headers: any = { ...auth, ...getTestHeader() } // Add x-r3-user header if in view-as mode const viewAsUser = store.getState().ui.viewAsUser diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 53e3d0968..ded41d2d3 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -22,6 +22,7 @@ const chatTransform = createTransform( (inbound: IChatState) => ({ messages: inbound.messages, conversationId: inbound.conversationId, + title: inbound.title, orgId: inbound.orgId, ownerId: inbound.ownerId, open: inbound.open, From 418639c7ed4419c811715caf15f0d78ab81cfcff Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 21:54:00 -0700 Subject: [PATCH 211/238] =?UTF-8?q?Codex=20review=20fixes=20(round=203)=20?= =?UTF-8?q?=E2=80=94=20popout=20entitlement,=20sign-in=20failures,=20trans?= =?UTF-8?q?cript=20races?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four round-3 findings on 58cf2204, plus the rest of the class two of them belong to. P1 App.tsx — enforce the chat entitlement in popout mode. ?chatPopout is user-controlled: an authenticated user without the ai-agent license could land on /?chatPopout=x and mount ChatWindow unconditionally, whose sync hook fires agent health/conversation/usage requests and exposes the composer — bypassing the gate the dock and header obey. ChatWindow now mounts only behind chatEnabled. The not-yet-enabled state is deliberately NOT the else-branch (that flashes the full app into the popup, the original comment's concern): the window waits on a spinner until organization.initialized — which flips exactly when the license limits are parsed — and once resolved-and-absent shows "Remote.It AI is not available for this account" (new chat.notLicensed key; chat.unavailable is a transient-outage message and would imply the feature is coming back). Both web (appReady via signedIn) and Electron (own Controller socket → backendAuthenticated → appReady) popouts run cloudSync.all → organization.fetch, so the gate resolves for entitled users in either shell. P2 auth.ts — preserve local-backend sign-in failures across sign-out. signedOut() deliberately clears signInFailed/signInError, and SignInApp renders its message ONLY while signInFailed is true — so backendSignInError's set-then-signedOut left Electron users on a bare sign-in screen with no word of the rejection. Fixed the CLASS, not the instance: every writer now records through signInFailure (signInFailed=true) and on the far side of the teardown — • backendSignInError: teardown first, then signInFailure. • disconnect: same defect (bare signInError after signedOut, and it read the STALE invocation snapshot). It fires right behind backendSignInError when the rejected socket drops, so it now reads the LIVE store and carries an already-recorded failure through the teardown instead of wiping it, falling back to the generic message otherwise. • signInError effect: dead (no callers) but carried the same defect; routed through signInFailure so it is correct if ever wired. This makes signInFailure's own doc comment ("every sign-in failure lands here") true again. P2 chat.ts — syncTranscript discards responses for conversations no longer active. A slow fetch outlived by a New Chat or history pick was applied against the invocation-time snapshot, landing the OLD transcript in the new conversation under its newer conversationId (or repopulating one just cleared). Re-reads the live store after the await, drops the response once the id moved on or a turn started, and compares against what is actually current — the same generation check logs.ts keys on requestId. P2 chatPopout.ts — distinct popout window name per owning tab. A constant WINDOW_NAME let a second main tab's window.open reuse and navigate the first tab's popup, orphaning the first tab's handle (dock never restored, ID ping never sent). Name is now `${WINDOW_NAME}-${id}`. Also repairs a CI i18n drift: 58cf2204 introduced notices:chat.deleteFailed but its extraction was never committed, so the typecheck workflow's `i18n:extract && git diff --exit-code` step would have failed on the current head. Both new keys are extracted into all four locales. Tests: 7 new behavioral tests (4 auth, 3 chat) pinning every changed path — all 7 fail against the pre-fix models (verified by stashing auth.ts/chat.ts) and pass with the fixes. Frontend 14/14, Electron 44/44, typecheck, i18n:check, extract-idempotency and the vite production build all green. Co-Authored-By: Claude Opus 5 --- frontend/src/components/App.tsx | 32 ++++++-- frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/de/notices.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/en/notices.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/es/notices.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + frontend/src/i18n/locales/ja/notices.json | 1 + frontend/src/models/auth.test.ts | 78 +++++++++++++++++-- frontend/src/models/auth.ts | 30 ++++++- frontend/src/models/chat.test.ts | 95 +++++++++++++++++++++++ frontend/src/models/chat.ts | 17 +++- frontend/src/services/chatPopout.ts | 5 +- 14 files changed, 242 insertions(+), 23 deletions(-) create mode 100644 frontend/src/models/chat.test.ts diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 6ce8d2596..5268ca36a 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -38,7 +38,7 @@ export const App: React.FC = () => { // render-time translations resolved outside React (Attribute label getters, // value functions, date/duration helpers) re-render when the language switches // or a non-English catalog chunk finishes loading. - useTranslation() + const { t } = useTranslation() const { insets } = useSafeArea() const location = useLocation() const hideSplashScreen = useCapacitor() @@ -49,6 +49,9 @@ export const App: React.FC = () => { const waitMessage = useSelector((state: State) => state.ui.waitMessage) const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) const chatEnabled = useChatEnabled() + // organization.initialized flips exactly when the account's license limits have been parsed, + // so it is the one signal that chatEnabled has been RESOLVED rather than merely not yet loaded + const chatEntitlementResolved = useSelector((state: State) => state.organization.initialized) const sidebarWidth = useSidebarWidth() const reseller = useSelector(selectResellerRef) const dispatch = useDispatch() @@ -128,13 +131,28 @@ export const App: React.FC = () => { }> - {/* isChatPopout is a boot constant — the window only exists because - chat opened it, so no feature-flag gate (chatEnabled depends on - async-restored testUI and would flash the full app in the popup) */} + {/* isChatPopout is a boot constant, but ?chatPopout is USER-CONTROLLED: an authenticated + user without the ai-agent license can land here directly, so the entitlement gate the + dock and the header obey applies here too — ChatWindow, and the agent requests its sync + hook fires on mount, exist only behind it. Not-yet-enabled is NOT the else-branch: that + would flash the full app into the popup. Until the license is known the window waits; + once the limits have loaded and the feature is still absent it says so, rather than + spinning forever or assuming every flagged URL came from the gated Pop out action. */} {isChatPopout ? ( - - - + chatEnabled ? ( + + + + ) : ( + + ) ) : ( <> ({ +// the hoisted vi.mock factory runs. `browser` and the live `store` state are hoisted MUTABLE +// objects so individual tests can steer the electron/backend branch and what the effects +// re-read from the store after a teardown. +const { oidcStart, oidcEndSessionSilently, browser, storeState } = vi.hoisted(() => ({ oidcStart: vi.fn(), oidcEndSessionSilently: vi.fn(), + browser: { isElectron: false, hasBackend: false }, + storeState: { auth: {} as Record }, })) -vi.mock('../services/oidc', () => ({ oidcStart, oidcEndSessionSilently })) +// signInFailure() tests `error instanceof OidcError`, so the mock must export a real class +// (an undefined right-hand side of instanceof throws rather than returning false). +vi.mock('../services/oidc', () => ({ oidcStart, oidcEndSessionSilently, OidcError: class OidcError extends Error {} })) vi.mock('../services/Controller', () => ({ default: {}, emit: vi.fn(() => false) })) vi.mock('../services/CloudSync', () => ({ default: {} })) vi.mock('../services/cloudController', () => ({ default: {} })) vi.mock('../services/Network', () => ({ default: {} })) -vi.mock('../services/browser', () => ({ default: { isElectron: false, hasBackend: false } })) +vi.mock('../services/browser', () => ({ default: browser })) vi.mock('../services/analytics', () => ({ default: {} })) vi.mock('../services/zendesk', () => ({ default: {} })) vi.mock('../services/graphQLRequest', () => ({ graphQLLogin: vi.fn() })) vi.mock('../services/remoteit', () => ({ getToken: vi.fn(), apiAuthHeaders: vi.fn() })) vi.mock('../selectors/devices', () => ({ selectDeviceModelAttributes: vi.fn() })) -vi.mock('../store', () => ({ persistor: { purge: vi.fn() } })) +vi.mock('../store', () => ({ persistor: { purge: vi.fn() }, store: { getState: () => storeState } })) vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) vi.mock('../constants', () => ({ API_URL: '', DEVELOPER_KEY: '', SIGN_OUT_BACKEND_TIMEOUT: 1000 })) vi.mock('axios', () => ({ default: {} })) @@ -28,9 +34,13 @@ vi.mock('axios', () => ({ default: {} })) // The effects are `dispatch => ({...})`; build them against a fake dispatch so each auth.* // call is an observable spy rather than a real reducer/effect. function makeDispatch() { - return { auth: { set: vi.fn(), signedOut: vi.fn(), signOut: vi.fn() } } + return { auth: { set: vi.fn(), signedOut: vi.fn(), signOut: vi.fn() }, ui: { set: vi.fn() } } } +// The only shape SignInApp renders: it shows a message ONLY while signInFailed is true, and +// signInFailed is also the brake on auto sign-in — so every failure writer must produce it. +const aFailureShowing = (signInError: string) => expect.objectContaining({ signInFailed: true, signInError }) + // eslint-disable-next-line @typescript-eslint/no-var-requires import authModel from './auth' @@ -72,3 +82,57 @@ describe('auth model — "Sign out everywhere" stays AS-wide', () => { ) }) }) + +/* signedOut() deliberately clears signInFailed/signInError so a failure logged while signed in + cannot leak onto the signed-out screen. The cost: any writer that records a failure BEFORE + calling it loses the message, and SignInApp then shows a bare sign-in screen with no word of + why. These pin that every failure writer lands its message in the signInFailure shape, and + on the far side of the teardown. */ +describe('auth model — a backend rejection survives the sign-out teardown', () => { + it('backendSignInError records the failure AFTER signedOut(), with signInFailed set', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const dispatch = makeDispatch() + await effectsFor(dispatch).backendSignInError('backend said no') + expect(dispatch.auth.signedOut).toHaveBeenCalledTimes(1) + expect(dispatch.auth.set).toHaveBeenCalledWith(aFailureShowing('backend said no')) + // The failure must land AFTER the teardown that clears it, or it never reaches the screen. + expect(dispatch.auth.set.mock.invocationCallOrder[0]).toBeGreaterThan( + dispatch.auth.signedOut.mock.invocationCallOrder[0] + ) + }) + + it('signInError writes the signInFailure shape, never a bare signInError string', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).signInError('locked') + expect(dispatch.auth.set).toHaveBeenCalledWith(aFailureShowing('locked')) + }) +}) + +describe('auth model — a dropped, unauthenticated backend socket still explains itself', () => { + const unauthenticated = { auth: { authenticated: false, backendAuthenticated: false } } + beforeEach(() => { + browser.hasBackend = true // the disconnect teardown is the electron/backend branch + }) + afterEach(() => { + browser.hasBackend = false + storeState.auth = {} + }) + + it('records the generic failure (signInFailed) after teardown when nothing more specific is on screen', async () => { + storeState.auth = { signInFailed: false } + const dispatch = makeDispatch() + await effectsFor(dispatch).disconnect(undefined, unauthenticated) + expect(dispatch.auth.signedOut).toHaveBeenCalledTimes(1) + expect(dispatch.auth.set).toHaveBeenCalledWith(aFailureShowing('Sign in failed, please try again.')) + }) + + it("carries a specific failure already recorded (backendSignInError's) through the teardown instead of wiping it", async () => { + // disconnect fires right behind backendSignInError when the rejected socket drops; the + // invocation-time snapshot predates that message, so it must read the LIVE store. + storeState.auth = { signInFailed: true, signInError: 'backend said no' } + const dispatch = makeDispatch() + await effectsFor(dispatch).disconnect(undefined, unauthenticated) + expect(dispatch.auth.set).toHaveBeenCalledWith(aFailureShowing('backend said no')) + expect(dispatch.auth.set).not.toHaveBeenCalledWith(aFailureShowing('Sign in failed, please try again.')) + }) +}) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index cfed5bd3f..627485928 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -6,7 +6,7 @@ import browser from '../services/browser' import analytics from '../services/analytics' import { selectDeviceModelAttributes } from '../selectors/devices' import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' -import { persistor } from '../store' +import { persistor, store } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken, apiAuthHeaders } from '../services/remoteit' import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcDeclaration, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, oidcRefreshBrowserAccounts, oidcSelectKnownAccount, oidcClearAutoStarts, OidcClaims, OidcError, OidcErrorCode } from '../services/oidc' @@ -386,21 +386,43 @@ export default createModel()({ }, async disconnect(_: void, state) { if (!state.auth.authenticated && !state.auth.backendAuthenticated && browser.hasBackend) { + // Read the LIVE store, not the invocation-time snapshot: backendSignInError records its + // failure after its own teardown and this handler fires right behind it when the + // rejected socket drops, so the snapshot predates that message. Carry an existing + // failure through this teardown (signedOut()'s signInCleared would wipe it) and only + // otherwise fall back to the generic one — either way through the signInFailure shape, + // so signInFailed is set and SignInApp actually renders the message. + const live = store.getState().auth + const failure: Partial = live.signInFailed + ? { + signInFailed: true, + signInError: live.signInError, + signInErrorCode: live.signInErrorCode, + signInRetryAfter: live.signInRetryAfter, + } + : signInFailure(new Error('Sign in failed, please try again.')) await dispatch.auth.signedOut() - if (!state.auth.signInError) dispatch.auth.set({ signInError: 'Sign in failed, please try again.' }) + dispatch.auth.set(failure) } dispatch.ui.set({ connected: false }) dispatch.auth.set({ backendAuthenticated: false }) }, async signInError(signInError: string) { - dispatch.auth.set({ signInError }) + // Through signInFailure, not a bare signInError set: SignInApp renders the message only + // while signInFailed is true, so a raw string here would never reach the screen. + dispatch.auth.set(signInFailure(new Error(signInError))) //send message to backend to sign out emit('user/lock') }, async backendSignInError(signInError: string) { console.error(signInError) - await dispatch.auth.set({ signInError }) + // Tear down FIRST, then record the failure: signedOut() deliberately clears + // signInFailed/signInError (a failure logged while signed in must not survive into the + // signed-out screen), so a set() before it was wiped and SignInApp — which renders its + // message only while signInFailed is true — showed a bare sign-in screen with no word of + // the backend's rejection. signInFailure is the one shape every failure takes. await dispatch.auth.signedOut() + dispatch.auth.set(signInFailure(new Error(signInError))) }, async appReady(_: void, state) { // Temp migration of state diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts new file mode 100644 index 000000000..29c3134da --- /dev/null +++ b/frontend/src/models/chat.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// The chat model pulls in the agent service, the popout protocol and the store at import time; +// stub them so the syncTranscript EFFECT runs in isolation. fetchConversation is the one real +// spy — each test scripts what the server returns and, crucially, what the user does to the +// live store WHILE that fetch is in flight. The store is a hoisted MUTABLE object for that. +const { fetchConversation, storeState } = vi.hoisted(() => ({ + fetchConversation: vi.fn(), + storeState: { chat: {} as Record }, +})) + +vi.mock('../services/agent', () => ({ + fetchConversation, + streamChat: vi.fn(), + confirmTool: vi.fn(), + backgroundDisable: vi.fn(), + listConversations: vi.fn(), + deleteConversation: vi.fn(), + fetchUsage: vi.fn(), + agentHealth: vi.fn(), + UsageLimitError: class UsageLimitError extends Error {}, + AgentAuthError: class AgentAuthError extends Error {}, +})) +vi.mock('../services/chatPopout', () => ({ + broadcastChatSignout: vi.fn(), + openChatPopout: vi.fn(), + popIn: vi.fn(), +})) +vi.mock('../store', () => ({ store: { getState: () => storeState } })) +vi.mock('../constants', () => ({ CHAT_PANEL_WIDTH: 400 })) +vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) + +import chatModel from './chat' + +const effectsFor = (dispatch: any) => (chatModel as any).effects(dispatch) +const makeDispatch = () => ({ chat: { set: vi.fn() } }) + +// A conversation as the effect sees it at invocation time (the rematch `state` snapshot). +const current = (over: Record = {}) => ({ + chat: { conversationId: 'a', streaming: false, messages: [], title: '', ...over }, +}) +const remote = { messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'yo' }], title: 'T' } +const remoteAsLocal = [ + { role: 'user', text: 'hi' }, + { role: 'assistant', text: 'yo', toolCalls: [] }, +] + +beforeEach(() => { + fetchConversation.mockReset() + storeState.chat = { conversationId: 'a', streaming: false, messages: [], title: '' } +}) + +/* The fetch can outlive the conversation it was for. Applying its result against the + invocation-time snapshot would drop the OLD transcript into whatever conversation is + on screen now — under that conversation's newer id — or repopulate one just cleared. */ +describe('chat model — syncTranscript discards a response for a conversation no longer active', () => { + it('drops the response when the user moved to another conversation mid-fetch', async () => { + fetchConversation.mockImplementation(async () => { + storeState.chat.conversationId = 'b' // a history pick / New Chat while the fetch was in flight + return remote + }) + const dispatch = makeDispatch() + await effectsFor(dispatch).syncTranscript(undefined, current()) + expect(fetchConversation).toHaveBeenCalledWith('a') + expect(dispatch.chat.set).not.toHaveBeenCalled() + }) + + it('drops the response when a turn started mid-fetch', async () => { + fetchConversation.mockImplementation(async () => { + storeState.chat.streaming = true + return remote + }) + const dispatch = makeDispatch() + await effectsFor(dispatch).syncTranscript(undefined, current()) + expect(dispatch.chat.set).not.toHaveBeenCalled() + }) + + it('applies the server copy against the LIVE store, not the invocation snapshot', async () => { + // The snapshot already matches the server (a snapshot-based compare would find nothing to + // do), while the live store has been cleared underneath it — the live compare must win. + fetchConversation.mockResolvedValue(remote) + const dispatch = makeDispatch() + await effectsFor(dispatch).syncTranscript(undefined, current({ messages: remoteAsLocal, title: 'T' })) + expect(dispatch.chat.set).toHaveBeenCalledTimes(1) + expect(dispatch.chat.set).toHaveBeenCalledWith({ messages: remoteAsLocal, title: 'T' }) + }) + + it('leaves an already-current transcript alone (no redundant set)', async () => { + storeState.chat = { conversationId: 'a', streaming: false, messages: remoteAsLocal, title: 'T' } + fetchConversation.mockResolvedValue(remote) + const dispatch = makeDispatch() + await effectsFor(dispatch).syncTranscript(undefined, current({ messages: remoteAsLocal, title: 'T' })) + expect(dispatch.chat.set).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 32279b911..6f7682054 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -332,6 +332,15 @@ export default createModel()({ try { const remote = await fetchConversation(id) if (!remote) return + // The fetch may have outlived the conversation: a New Chat or a history pick while it + // was in flight leaves `state` describing a conversation no longer on screen, and + // applying against that snapshot would land the OLD transcript in the new conversation + // under its newer conversationId (or repopulate one just cleared). Re-read the LIVE + // store, drop the response once the active id has moved on or a turn has started, and + // compare against what is actually current — the generation check logs.ts keys on + // requestId. + const current = store.getState().chat + if (current.conversationId !== id || current.streaming) return const messages = remote.messages.map(m => m.role === 'assistant' ? { role: 'assistant' as const, text: m.content, toolCalls: [] } @@ -342,11 +351,11 @@ export default createModel()({ // length-only test leaves the partial on screen. Compare the last message's text too. Also // apply the server title — a reload restores conversationId but the title defaults to ''. const last = messages[messages.length - 1]?.text ?? '' - const localLast = state.chat.messages[state.chat.messages.length - 1]?.text ?? '' - const differs = messages.length !== state.chat.messages.length || last !== localLast - const title = remote.title || state.chat.title + const localLast = current.messages[current.messages.length - 1]?.text ?? '' + const differs = messages.length !== current.messages.length || last !== localLast + const title = remote.title || current.title if (differs) dispatch.chat.set({ messages, title }) - else if (title !== state.chat.title) dispatch.chat.set({ title }) + else if (title !== current.title) dispatch.chat.set({ title }) } catch { /* offline or deleted — the local display cache stands */ } diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index af1fb47a3..a6bfecbf2 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -95,7 +95,10 @@ export function openChatPopout(): boolean { // Reuse the stored id so re-clicking Pop out re-targets the same named // window instead of orphaning it under a new identity const id = ownerId() || crypto.randomUUID().slice(0, 8) - const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_PARAM}=${id}`, WINDOW_NAME, WINDOW_FEATURES) + // Name the window PER OWNER so a re-click from THIS tab reuses only its own popup. A single + // constant name let a second main tab's window.open reuse and navigate the first tab's popup, + // orphaning the first tab's handle (its dock never restored, its ping never sent). + const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_PARAM}=${id}`, `${WINDOW_NAME}-${id}`, WINDOW_FEATURES) if (!opened) return false // popup blocked — dock stays; hello never arrives window.sessionStorage.setItem(OWNER_KEY, id) popoutWindow = opened From aa724b85f90033c334b09343901d8501345c03a3 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 22:15:55 -0700 Subject: [PATCH 212/238] =?UTF-8?q?Codex=20review=20fixes=20(round=204)=20?= =?UTF-8?q?=E2=80=94=20popout=20account=20scope,=20entitlement-loss=20stop?= =?UTF-8?q?,=20chat=20request=20races?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings on 418639c7. One is a regression from round 3's own popout gate; three are the remaining instances of the stale-snapshot class that round closed for syncTranscript. P2 App.tsx — gate popouts using the handed-off account. Round 3's entitlement gate reads useChatEnabled → selectOrganization → accounts.activeId, which the popout's no-op persistence leaves unset — so it evaluated the PERSONAL account's license, and a chat licensed only for an organization would be refused in its own popout (every Pop out stuck on "not licensed"). The handoff does carry orgId, but only after ChatWindow mounts — behind that very gate. Fixed by bootstrapping the owning account scope through the popout URL (Codex's first suggestion): • chatPopout.ts: openChatPopout(scope) adds &chatPopoutScope=; popoutScopeId exposes it at boot. Frontend-only param (Electron's window-open handler keys on CHAT_POPOUT_PARAM). • chat.ts popOut: hands over state.chat.orgId. • useChatSync.ts useChatPopoutScope(): sets accounts.activeId from it on mount; App calls it UNCONDITIONALLY, outside the gate. No-op in the main window. Precise and safe: the gate now evaluates the org the popout is actually for, and a hand-edited scope the user is no member of is cleared again by accounts.parse's stale check — landing on the personal account the gate would have read anyway. Rejected the alternative ("entitled on ANY account" selector): it would re-implement PENDING_FEATURES/override handling that useChatEnabled's own comment says nothing may bypass. P1 App.tsx:194 — stop active turns when entitlement removes the panel. ChatPanel unmounts ONLY when chatEnabled drops (closing it renders null but stays mounted; popping out stops explicitly). A turn left streaming behind that ran headless: the remount's resetTransient() cleared streaming/pendingConfirmation while the old request was live, so the next send orphaned its AbortController and two turns' events interleaved, and a pending write approval was stranded with no card to answer it. useChatMainSync's mount effect now stops the turn in its cleanup — which, given the above, fires on exactly the entitlement-loss path. Same cleanup on useChatPopoutSync (its only unmount is the same gate; window close runs no React cleanup) so a turn never outlives its surface. P2 chat.ts openConversation — discard out-of-order loads. Pick A then B, A lands last → A replaced B. A module-level selection ticket (the requestId pattern logs.ts uses) is taken per pick and by newConversation; a load applies only while it holds the latest AND no turn has started meanwhile (the composer stays enabled). A superseded failure is dropped as noise; a superseded 404 still refreshes the list but no longer clears the conversation now on screen. P2 chat.ts removeConversation — handle transport failures. deleteConversation returns response.ok but fetch/agentHeaders can REJECT (network, DNS, CORS): the effect then rejected unhandled after the confirm dialog had closed, and the user heard nothing. Caught and routed through the same deleteFailed path. P2 chat.ts removeConversation — recheck the active conversation after deletion. Re-reads the LIVE id: a slow delete of open A followed by opening B no longer clears B; deleting A from the picker then opening A before it lands now does clear the deleted transcript. Audited every chat effect for the stale-snapshot pattern (read `state.chat` after an await): openConversation and removeConversation were the last two — `confirm` is a false positive (its read sits inside the confirmTool argument, evaluated before suspension). Class closed. Tests: 9 new behavioral tests (8 chat model, 1 chatPopout service — the latter also pins round 3's per-owner window name) plus 4 controls; all 9 fail against the pre-fix chat.ts/chatPopout.ts (verified by stashing) and pass with the fixes. The unmount-stop hook wiring is two lines calling existing stop() and is covered by typecheck/build — RTL is not a dependency here and was not worth adding for it. Frontend 27/27, Electron 44/44, typecheck, i18n:check, extract-idempotency and the vite production build all green. Co-Authored-By: Claude Opus 5 --- frontend/src/components/App.tsx | 4 + frontend/src/hooks/useChatSync.ts | 42 ++++++- frontend/src/models/chat.test.ts | 143 ++++++++++++++++++++++- frontend/src/models/chat.ts | 47 ++++++-- frontend/src/services/chatPopout.test.ts | 47 ++++++++ frontend/src/services/chatPopout.ts | 18 ++- 6 files changed, 282 insertions(+), 19 deletions(-) create mode 100644 frontend/src/services/chatPopout.test.ts diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 5268ca36a..2e70ed072 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -20,6 +20,7 @@ import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' import { useChatEnabled, useSidebarWidth, useEffectiveWidth, useHideSidebar } from '../hooks/useChatEnabled' +import { useChatPopoutScope } from '../hooks/useChatSync' import { Router } from '../routers/Router' import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' @@ -85,6 +86,9 @@ export const App: React.FC = () => { } useViewAsUser() + // Before the popout's entitlement gate below can be read, the window must run under the + // account scope that opened it (otherwise it reads the personal account's license) + useChatPopoutScope() useEffect(() => { hideSplashScreen() diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index 56f7cae44..c9ed6184b 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -3,7 +3,14 @@ import { useTranslation } from 'react-i18next' import { useSelector, useDispatch } from 'react-redux' import { store, State, Dispatch } from '../store' import { toChatHandoff } from '../models/chat' -import { initChatPopoutMain, initChatPopoutWindow, checkPopoutPresence, PopoutMainHandlers } from '../services/chatPopout' +import { + initChatPopoutMain, + initChatPopoutWindow, + checkPopoutPresence, + PopoutMainHandlers, + isChatPopout, + popoutScopeId, +} from '../services/chatPopout' import network from '../services/Network' /* Re-probe the agent when the app's own detector says connectivity is back — the same @@ -21,6 +28,20 @@ const useAgentHealthOnReconnect = (check: () => void): void => { const currentHandoff = () => toChatHandoff(store.getState().chat) +/* Popout boot: run under the account scope of the window that opened it. Everything org-scoped + — App's chat entitlement gate above all — resolves through accounts.activeId, which the + popout's no-op persistence leaves unset, i.e. the PERSONAL account: a chat licensed only for + an organization would be refused in its own popout. This must run OUTSIDE that gate + (ChatWindow's own hooks sit behind it), so App calls it unconditionally; it is a no-op in + the main window. accounts.parse clears a scope the user is no member of, so a hand-edited + URL can only ever land back on the personal account. */ +export const useChatPopoutScope = (): void => { + const dispatch = useDispatch() + useEffect(() => { + if (isChatPopout && popoutScopeId) dispatch.accounts.set({ activeId: popoutScopeId }) + }, []) +} + /* Main-window chat lifecycle — everything ChatPanel needs to happen but that isn't display: adopting the server's transcript on mount, wiring the popout handoff protocol, re-checking agent health when the dock opens, and @@ -73,7 +94,17 @@ export const useChatMainSync = (): void => { } const unsubscribe = initChatPopoutMain(handlers) checkPopoutPresence(handlers) - return unsubscribe + return () => { + unsubscribe() + // This panel unmounts ONLY when the entitlement goes away — an org switch to an unlicensed + // account, or the Test feature turned off (closing it merely renders null; popping out + // stops explicitly). A turn left streaming behind that runs on headless: the remount's + // resetTransient() then clears streaming/pendingConfirmation while the old request is + // still live, so the next send orphans its AbortController and two turns' events + // interleave — and a pending write approval is stranded with no card left to answer it. + // The turn goes with the panel. + dispatch.chat.stop() + } }, []) useEffect(() => { @@ -116,7 +147,12 @@ export const useChatPopoutSync = (): void => { getHandoff: currentHandoff, onSignout: () => window.close(), }) - return unsubscribe + return () => { + unsubscribe() + // Same invariant as the dock: the window's only unmount is App's entitlement gate + // closing (a window close runs no React cleanup), and a turn must not outlive its surface + dispatch.chat.stop() + } }, []) // The popout is its own app instance, so it has its own Network to listen to diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index 29c3134da..f3a60a7d4 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -4,18 +4,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // stub them so the syncTranscript EFFECT runs in isolation. fetchConversation is the one real // spy — each test scripts what the server returns and, crucially, what the user does to the // live store WHILE that fetch is in flight. The store is a hoisted MUTABLE object for that. -const { fetchConversation, storeState } = vi.hoisted(() => ({ +const { fetchConversation, deleteConversation, openChatPopout, storeState } = vi.hoisted(() => ({ fetchConversation: vi.fn(), + deleteConversation: vi.fn(), + openChatPopout: vi.fn(), storeState: { chat: {} as Record }, })) vi.mock('../services/agent', () => ({ fetchConversation, + deleteConversation, streamChat: vi.fn(), confirmTool: vi.fn(), backgroundDisable: vi.fn(), listConversations: vi.fn(), - deleteConversation: vi.fn(), fetchUsage: vi.fn(), agentHealth: vi.fn(), UsageLimitError: class UsageLimitError extends Error {}, @@ -23,7 +25,7 @@ vi.mock('../services/agent', () => ({ })) vi.mock('../services/chatPopout', () => ({ broadcastChatSignout: vi.fn(), - openChatPopout: vi.fn(), + openChatPopout, popIn: vi.fn(), })) vi.mock('../store', () => ({ store: { getState: () => storeState } })) @@ -33,7 +35,22 @@ vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) import chatModel from './chat' const effectsFor = (dispatch: any) => (chatModel as any).effects(dispatch) -const makeDispatch = () => ({ chat: { set: vi.fn() } }) +const makeDispatch = () => ({ + chat: { + set: vi.fn(), + stop: vi.fn(), + clearConversation: vi.fn(), + loadConversations: vi.fn(), + newConversation: vi.fn(), + }, +}) + +// A fetch the test resolves by hand, to interleave user actions with an in-flight request. +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise(r => (resolve = r)) + return { promise, resolve } +} // A conversation as the effect sees it at invocation time (the rematch `state` snapshot). const current = (over: Record = {}) => ({ @@ -47,9 +64,127 @@ const remoteAsLocal = [ beforeEach(() => { fetchConversation.mockReset() + deleteConversation.mockReset() + openChatPopout.mockReset() storeState.chat = { conversationId: 'a', streaming: false, messages: [], title: '' } }) +// What openConversation writes for a loaded conversation — the shape the out-of-order tests +// look for, so they can tell WHICH load landed. +const opened = (id: string) => expect.objectContaining({ conversationId: id }) + +/* A pick from the history is a request that may lose the race with the next pick. Without a + generation check, "A then B, A lands last" leaves A on screen under B's selection. */ +describe('chat model — openConversation applies only the latest selection', () => { + it('a slower earlier pick (A) does not replace the later one (B)', async () => { + const a = deferred() + const b = deferred() + fetchConversation.mockImplementationOnce(() => a.promise).mockImplementationOnce(() => b.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const openA = fx.openConversation('A', current()) + const openB = fx.openConversation('B', current()) + b.resolve({ ...remote, title: 'B' }) + await openB + a.resolve({ ...remote, title: 'A' }) // A finishes last + await openA + expect(dispatch.chat.set).toHaveBeenCalledWith(opened('B')) + expect(dispatch.chat.set).not.toHaveBeenCalledWith(opened('A')) + }) + + it('a New Chat during a slow pick is not undone when that pick lands', async () => { + const a = deferred() + fetchConversation.mockImplementationOnce(() => a.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const openA = fx.openConversation('A', current()) + await fx.newConversation() // takes the next ticket + a.resolve(remote) + await openA + expect(dispatch.chat.set).not.toHaveBeenCalledWith(opened('A')) + }) + + it('a turn the user started meanwhile is not clobbered by the landing pick', async () => { + fetchConversation.mockImplementation(async () => { + storeState.chat.streaming = true // the composer stays enabled during a pick + return remote + }) + const dispatch = makeDispatch() + await effectsFor(dispatch).openConversation('A', current()) + expect(dispatch.chat.set).not.toHaveBeenCalledWith(opened('A')) + }) + + it('a stale pick that 404s still refreshes the list but does not clear the conversation now on screen', async () => { + const a = deferred() + fetchConversation.mockImplementationOnce(() => a.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const openA = fx.openConversation('A', current()) + await fx.newConversation() // clears once, itself + a.resolve(null) // A was deleted elsewhere + await openA + expect(dispatch.chat.loadConversations).toHaveBeenCalled() + // Only New Chat's own clear — the stale 404 must not clear the fresh conversation again + expect(dispatch.chat.clearConversation).toHaveBeenCalledTimes(1) + }) + + it('a current pick still applies (control)', async () => { + fetchConversation.mockResolvedValue(remote) + const dispatch = makeDispatch() + await effectsFor(dispatch).openConversation('A', current()) + expect(dispatch.chat.set).toHaveBeenCalledWith(opened('A')) + }) +}) + +/* Deleting is a request too: it can fail without an HTTP response, and it can be slow enough + for the user to have moved to another conversation before it lands. */ +describe('chat model — removeConversation', () => { + const deleteFailed = expect.objectContaining({ error: 'notices:chat.deleteFailed' }) + + it('reports a REJECTED delete (no HTTP response) exactly like a failed one, and keeps the transcript', async () => { + deleteConversation.mockRejectedValue(new TypeError('Failed to fetch')) + const dispatch = makeDispatch() + await expect(effectsFor(dispatch).removeConversation('a')).resolves.toBeUndefined() + expect(dispatch.chat.set).toHaveBeenCalledWith(deleteFailed) + expect(dispatch.chat.newConversation).not.toHaveBeenCalled() + expect(dispatch.chat.loadConversations).not.toHaveBeenCalled() + }) + + it('clears the conversation only if it is STILL the one on screen when the delete lands', async () => { + deleteConversation.mockImplementation(async () => { + storeState.chat.conversationId = 'b' // the user opened B while A was being deleted + return true + }) + const dispatch = makeDispatch() + await effectsFor(dispatch).removeConversation('a') + expect(dispatch.chat.newConversation).not.toHaveBeenCalled() + expect(dispatch.chat.loadConversations).toHaveBeenCalled() + }) + + it('clears a conversation the user opened DURING its own deletion', async () => { + storeState.chat.conversationId = 'c' + deleteConversation.mockImplementation(async () => { + storeState.chat.conversationId = 'a' // deleted A from the picker, then opened A before it landed + return true + }) + const dispatch = makeDispatch() + await effectsFor(dispatch).removeConversation('a') + expect(dispatch.chat.newConversation).toHaveBeenCalledTimes(1) + }) +}) + +/* The popout persists nothing, so it boots on the PERSONAL account unless told otherwise — + and a chat licensed only for an organization would then be refused in its own popout. */ +describe('chat model — popOut hands over the account scope', () => { + it('passes the current org to openChatPopout', async () => { + openChatPopout.mockReturnValue(true) + const dispatch = makeDispatch() + await effectsFor(dispatch).popOut(undefined, { chat: { orgId: 'org-1' } }) + expect(openChatPopout).toHaveBeenCalledWith('org-1') + expect(dispatch.chat.set).not.toHaveBeenCalled() // no popup-blocked error + }) +}) + /* The fetch can outlive the conversation it was for. Applying its result against the invocation-time snapshot would drop the OLD transcript into whatever conversation is on screen now — under that conversation's newer id — or repopulate one just cleared. */ diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 6f7682054..13958755f 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -183,6 +183,11 @@ const usageLimitMessage = (e: UsageLimitError): string => { } let abortController: AbortController | null = null +/* Which conversation SELECTION is current. openConversation takes a ticket and applies its fetch + only while it still holds the latest; newConversation takes one too, so a New Chat during a slow + open is not undone when that open finally lands. (The same generation check logs.ts keys on + requestId.) */ +let selection = 0 export default createModel()({ state: { ...defaultChatState }, @@ -296,13 +301,16 @@ export default createModel()({ AbortController (Stop then targets only the newer turn, mixing two conversations). New Chat, an identity change, and deleting the open conversation all route through here. */ async newConversation() { + selection++ // a New Chat outranks any conversation open still in flight await dispatch.chat.stop() dispatch.chat.clearConversation() }, /* Move the conversation to its own window; the dock hides when the popout says hello. A blocked popup is surfaced instead of silently ignored. */ - async popOut() { - if (!openChatPopout()) + async popOut(_: void, state) { + // Hand over this window's account scope so the popout boots under it, not the personal + // account its unset activeId would default to (popoutScopeId explains the stakes) + if (!openChatPopout(state.chat.orgId || undefined)) dispatch.chat.set({ error: i18n.t('notices:chat.popupBlocked', { defaultValue: 'Pop out was blocked — allow popups for this site and try again.', @@ -401,21 +409,29 @@ export default createModel()({ live turn state so nothing from the previous thread bleeds across. */ async openConversation(id: string, state) { if (state.chat.streaming) dispatch.chat.stop() + // Out-of-order guard: pick A, then B, and A's fetch lands last — A must not replace B. Nor + // may a New Chat (which also takes a ticket) or a turn the user started meanwhile (the + // composer stays enabled) be clobbered by a load that is no longer wanted. + const ticket = ++selection + const superseded = () => ticket !== selection || store.getState().chat.streaming let remote try { remote = await fetchConversation(id) } catch (error) { // A service or auth failure is not a deletion: keep the transcript on screen and report, - // rather than clearing to a new chat as if the conversation had vanished. - dispatch.chat.set({ error: (error as Error).message }) + // rather than clearing to a new chat as if the conversation had vanished. Unless the + // user has already moved on — then it is only noise about a thread they left. + if (!superseded()) dispatch.chat.set({ error: (error as Error).message }) return } if (!remote) { - // Vanished — a genuine 404 (deleted elsewhere). Drop it from the list and start fresh. - dispatch.chat.clearConversation() + // Vanished — a genuine 404 (deleted elsewhere). Drop it from the list, and unless the + // user has already moved on, start fresh. await dispatch.chat.loadConversations() + if (!superseded()) dispatch.chat.clearConversation() return } + if (superseded()) return dispatch.chat.set({ conversationId: id, turnId: '', @@ -431,17 +447,28 @@ export default createModel()({ }) }, /* Delete a conversation for real (D9). If it's the one on screen, clear to a new chat. */ - async removeConversation(id: string, state) { + async removeConversation(id: string) { // A failed DELETE (401/403/5xx) is NOT a deletion — the row survives on the server and would // reappear on the next refresh. Report it and keep the local copy, rather than clearing the - // open transcript as though it succeeded. - if (!(await deleteConversation(id))) { + // open transcript as though it succeeded. A REJECTED request (network, DNS, CORS — no HTTP + // response at all) is the same failure and takes the same path: the confirm dialog has + // already closed, so an unhandled rejection here left the user with no feedback whatsoever. + let deleted = false + try { + deleted = await deleteConversation(id) + } catch { + deleted = false + } + if (!deleted) { dispatch.chat.set({ error: i18n.t('notices:chat.deleteFailed', { defaultValue: 'Could not delete the conversation — try again.' }), }) return } - if (state.chat.conversationId === id) await dispatch.chat.newConversation() + // The LIVE id, not the invocation snapshot: a slow delete of the open conversation A followed + // by opening B must not clear B; deleting A from the picker and then opening A before the + // delete lands must still clear the now-deleted transcript. + if (store.getState().chat.conversationId === id) await dispatch.chat.newConversation() await dispatch.chat.loadConversations() }, /* App sign-out: nothing agent-specific to revoke — the session's end IS the diff --git a/frontend/src/services/chatPopout.test.ts b/frontend/src/services/chatPopout.test.ts new file mode 100644 index 000000000..39799b932 --- /dev/null +++ b/frontend/src/services/chatPopout.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { openChatPopout } from './chatPopout' + +/* The popout is a second window of the same bundle, told who it is — and, since round 4, + which account it is for — entirely through its URL. These pin that contract at the source. */ +describe('chatPopout — openChatPopout', () => { + beforeEach(() => window.sessionStorage.clear()) + afterEach(() => vi.restoreAllMocks()) + + it('names the window per owning tab and hands the account scope over in the URL', () => { + const open = vi.spyOn(window, 'open').mockReturnValue({} as Window) + expect(openChatPopout('org-1')).toBe(true) + const [url, name] = open.mock.calls[0] + const query = new URL(url as string).searchParams + const id = query.get('chatPopout') + expect(id).toBeTruthy() + // The opener's scope rides along so the popout can boot under it instead of the personal + // account its unset activeId would default to. + expect(query.get('chatPopoutScope')).toBe('org-1') + // Per-OWNER name: a constant name let a second main tab's window.open reuse and navigate + // the first tab's popup, orphaning that tab's handle. + expect(name).toBe(`remoteit-chat-${id}`) + // Remembered so a re-click from this tab re-targets the same window. + expect(window.sessionStorage.getItem('chatPopoutOwner')).toBe(id) + }) + + it('re-targets the same id (and window name) on a second click from the same tab', () => { + const open = vi.spyOn(window, 'open').mockReturnValue({} as Window) + openChatPopout('org-1') + openChatPopout('org-1') + const ids = open.mock.calls.map(([url]) => new URL(url as string).searchParams.get('chatPopout')) + expect(ids[0]).toBe(ids[1]) + expect(open.mock.calls[0][1]).toBe(open.mock.calls[1][1]) + }) + + it('omits the scope param when there is none', () => { + const open = vi.spyOn(window, 'open').mockReturnValue({} as Window) + openChatPopout() + expect(new URL(open.mock.calls[0][0] as string).searchParams.has('chatPopoutScope')).toBe(false) + }) + + it('reports a blocked popup and remembers no owner', () => { + vi.spyOn(window, 'open').mockReturnValue(null) + expect(openChatPopout('org-1')).toBe(false) + expect(window.sessionStorage.getItem('chatPopoutOwner')).toBeNull() + }) +}) diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index a6bfecbf2..2eee43e55 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -11,6 +11,9 @@ import { CHAT_POPOUT_PARAM, CHAT_POPOUT_SIZE } from '@common/constants' * callers inject handlers (avoids store/model import cycles). */ const OWNER_KEY = 'chatPopoutOwner' +// Rides beside CHAT_POPOUT_PARAM: the ACCOUNT SCOPE the opening window ran under. Frontend-only — +// Electron's window-open handler keys on CHAT_POPOUT_PARAM alone — so it lives here, not in common. +const SCOPE_PARAM = 'chatPopoutScope' // Captured at module-evaluation time, before any routing can touch the URL @@ -20,6 +23,13 @@ export const isChatPopout = bootQuery.has(CHAT_POPOUT_PARAM) // it. Every main tab hears the shared channel, so directed messages carry // this id and non-owner tabs ignore them. const popoutId = bootQuery.get(CHAT_POPOUT_PARAM) || '' +/* The account scope of the window that opened this popout, for the boot to adopt + (useChatPopoutScope). The popout persists nothing, so accounts.activeId starts unset and + every org-scoped read — the chat entitlement gate above all — falls back to the PERSONAL + account, refusing a chat that is licensed only for an organization. User-controlled like + the rest of the URL, and safe that way: a scope the user is no member of is cleared again + by accounts.parse, back to the personal account the gate would have read anyway. */ +export const popoutScopeId = bootQuery.get(SCOPE_PARAM) || '' // Per-tab (sessionStorage survives a reload of the owning tab, but no other // tab has it): the id of the popout this tab opened, if any @@ -91,14 +101,18 @@ const pingPopout = (id: string): Promise => /* ---------- main-window side ---------- */ -export function openChatPopout(): boolean { +/* `scope` is the opener's account scope (accounts.activeId, or the user for the personal + account) — see popoutScopeId for why the popout needs it handed over at boot. */ +export function openChatPopout(scope?: string): boolean { // Reuse the stored id so re-clicking Pop out re-targets the same named // window instead of orphaning it under a new identity const id = ownerId() || crypto.randomUUID().slice(0, 8) + const query = new URLSearchParams({ [CHAT_POPOUT_PARAM]: id }) + if (scope) query.set(SCOPE_PARAM, scope) // Name the window PER OWNER so a re-click from THIS tab reuses only its own popup. A single // constant name let a second main tab's window.open reuse and navigate the first tab's popup, // orphaning the first tab's handle (its dock never restored, its ping never sent). - const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_PARAM}=${id}`, `${WINDOW_NAME}-${id}`, WINDOW_FEATURES) + const opened = window.open(`${window.location.origin}/?${query}`, `${WINDOW_NAME}-${id}`, WINDOW_FEATURES) if (!opened) return false // popup blocked — dock stays; hello never arrives window.sessionStorage.setItem(OWNER_KEY, id) popoutWindow = opened From 843b9b507e80339385ed3f3a13b02de3ff93b917 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 22:39:23 -0700 Subject: [PATCH 213/238] =?UTF-8?q?Codex=20review=20fixes=20(round=205)=20?= =?UTF-8?q?=E2=80=94=20Pop=20back=20in=20gate,=20send=20invalidates=20pick?= =?UTF-8?q?s,=20SSE=20line=20endings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings on aa724b85. Three fixed; the fourth is pre-existing base-branch behaviour that the code's own comment misdescribed, so the comment is corrected and the behaviour is deliberately kept (see below). P1 chat.ts popIn — preserve pending approvals when popping back in. popIn() stop()s the window before handing back, and the handoff carries neither turnId nor the pending approval, so mid-turn it aborted the stream and stranded a confirmation_required turn on the server with no window left able to answer it. This is the exact mirror of round 1's Pop OUT gate, and takes the same product decision: ChatWindow's Pop back in is disabled while a turn is streaming or an approval is pending (turnActive), rather than growing the handoff protocol to carry a resumable turn. P2 chat.ts openConversation — invalidate loads when a send starts. Round 4's superseded() checked the streaming flag, which is a snapshot: a turn that starts AND finishes before a slow history pick lands leaves streaming false again, and the stale load replaced the completed turn. send() now takes a selection ticket — a send commits the user to the conversation on screen, so any pick still in flight is no longer wanted. P2 agent.ts — parse standard SSE line endings. The parser recognised only '\n\n'; a CRLF server would never produce it and every turn would finish silently empty. Line endings are normalised to LF before the blank-line search (a trailing CR is held back — it may be half of a CRLF torn across reads), and the parser is split into deliver()/drain() so EOF can flush an event the server closed on without a trailing blank line. EventSource discards such a tail because it cannot know whether it is complete; our payloads are JSON, so a successful parse IS that check, and a torn tail is dropped rather than surfaced as a parse error over a turn the user already watched finish. Hardening — the agent emits LF today — but cheap and spec-correct. P2 DevicesPage — "arm redirects for already-initialized account models": comment corrected, behaviour intentionally unchanged. The initLoad latch is on the base branch verbatim, and its suppression is by design: `devices` is persisted, so a page mounting already-initialized from storage must not bounce a reload to /add on STALE emptiness, and (with #1209's default-org selection) a membership landing mid-session must not yank the user to that org. Round 1's dep expansion serves the fresh-sign-in path — where the latch IS armed and memberships arrive after the list. The old comment claimed the deps also handled "switching to an already-loaded empty account", which the latch correctly prevents (and which base never did either); it now states the real contract. Arming on reload would be a UX change (zero-device users bounced to /add on every reload) that belongs to a product decision, not a review fix. Tests: 4 new behavior-changing cases (3 SSE framing in agent.test.ts, 1 send-invalidation in chat.test.ts) plus 2 controls; all 4 fail against the pre-fix agent.ts/chat.ts (verified by stashing) and pass with the fixes. Frontend 33/33, Electron 44/44, typecheck, i18n:check, extract-idempotency and the vite production build all green. Co-Authored-By: Claude Opus 5 --- frontend/src/components/Chat/ChatWindow.tsx | 10 +++- frontend/src/models/chat.test.ts | 21 ++++++++ frontend/src/models/chat.ts | 11 ++-- frontend/src/pages/DevicesPage.tsx | 17 +++--- frontend/src/services/agent.test.ts | 59 ++++++++++++++++++++- frontend/src/services/agent.ts | 47 ++++++++++++---- 6 files changed, 140 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 79236eff7..2e85af771 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -1,8 +1,8 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Box } from '@mui/material' -import { Dispatch } from '../../store' +import { Dispatch, State } from '../../store' import { IconButton } from '../../buttons/IconButton' import { useChatPopoutSync } from '../../hooks/useChatSync' import { ChatHeader, NewChatButton } from './ChatHeader' @@ -14,6 +14,11 @@ import { ChatBody } from './ChatBody' export const ChatWindow: React.FC = () => { const { t } = useTranslation() const dispatch = useDispatch() + // The mirror of ChatPanel's Pop out gate. popIn() stop()s this window before handing back, and + // the handoff carries neither turnId nor the pending approval — so mid-turn it would abort the + // stream and strand a confirmation_required turn on the server with no window left able to + // answer it. Block it until the turn is idle, exactly as the dock blocks Pop out. + const turnActive = useSelector((state: State) => state.chat.streaming || !!state.chat.pendingConfirmation) useChatPopoutSync() @@ -34,6 +39,7 @@ export const ChatWindow: React.FC = () => { icon="arrow-up-right-from-square" flip="both" title={t('chat.popIn', 'Pop back in')} + disabled={turnActive} onClick={() => dispatch.chat.popIn()} /> diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index f3a60a7d4..c728a01c0 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -42,8 +42,14 @@ const makeDispatch = () => ({ clearConversation: vi.fn(), loadConversations: vi.fn(), newConversation: vi.fn(), + // what send() touches around its (mocked, instantly-resolving) streamChat + addUserMessage: vi.fn(), + applyEvent: vi.fn(), + loadUsage: vi.fn(), }, }) +// The wider snapshot send() reads (resolveChatOrg looks at the user and memberships) +const sendable = (chat: Record = {}) => ({ ...current(chat), user: { id: 'u' }, accounts: { membership: [] } }) // A fetch the test resolves by hand, to interleave user actions with an in-flight request. const deferred = () => { @@ -114,6 +120,21 @@ describe('chat model — openConversation applies only the latest selection', () expect(dispatch.chat.set).not.toHaveBeenCalledWith(opened('A')) }) + it('a turn that starts AND finishes during a slow pick still invalidates it', async () => { + const a = deferred() + fetchConversation.mockImplementationOnce(() => a.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const openA = fx.openConversation('A', current()) + // The whole turn runs to completion while A is still loading — streaming is false again by + // the time A lands, so only the ticket send() took can tell the load is stale. + await fx.send('hello', sendable()) + expect(storeState.chat.streaming).toBe(false) + a.resolve(remote) + await openA + expect(dispatch.chat.set).not.toHaveBeenCalledWith(opened('A')) + }) + it('a stale pick that 404s still refreshes the list but does not clear the conversation now on screen', async () => { const a = deferred() fetchConversation.mockImplementationOnce(() => a.promise) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 13958755f..40fd3c001 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -184,9 +184,9 @@ const usageLimitMessage = (e: UsageLimitError): string => { let abortController: AbortController | null = null /* Which conversation SELECTION is current. openConversation takes a ticket and applies its fetch - only while it still holds the latest; newConversation takes one too, so a New Chat during a slow - open is not undone when that open finally lands. (The same generation check logs.ts keys on - requestId.) */ + only while it still holds the latest. newConversation and send take one too: a New Chat, or a + message sent to the conversation on screen, during a slow open must not be undone when that open + finally lands. (The same generation check logs.ts keys on requestId.) */ let selection = 0 export default createModel()({ @@ -194,6 +194,11 @@ export default createModel()({ effects: dispatch => ({ async send(text: string, state) { if (state.chat.streaming || state.chat.pendingConfirmation) return + // A send commits the user to the conversation on screen: any history pick still in flight + // is no longer wanted. Take the ticket HERE, not only via the streaming flag — a turn that + // starts and finishes before a slow pick lands leaves streaming false again, and the stale + // load would otherwise replace the completed turn. + selection++ const conversationId = state.chat.conversationId || crypto.randomUUID() dispatch.chat.addUserMessage(text) dispatch.chat.set({ diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index 951fa27cf..627542255 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -36,13 +36,16 @@ export const DevicesPage: React.FC = ({ restore, select }) => { const shouldRedirect = initLoad && initialized && canRegister /* An empty list means "add your first device" only once it has actually loaded — so - arm on the way down and redirect on the way up, never both in one pass. Keyed to - the account and re-run when the list empties, so every switch re-decides instead - of inheriting the last account's answer. The trigger must include the inputs the - decision reads — the empty-list and default-account signals — because memberships - arriving late, or switching to an already-loaded empty account, change those - without touching `initialized`. A ref keyed to the account we acted for keeps that - from re-selecting or re-pushing /add on every re-run. */ + arm on the way down and redirect on the way up, never both in one pass. The latch is + deliberate and STICKY: `devices` is persisted, so a page that mounts already + `initialized` from storage never arms it — stale persisted emptiness must not bounce a + reload to /add, and a membership that lands mid-session must not yank the user to that + org. Only a load observed during this mount (a fresh sign-in, an expired or unloaded + account's fetch) arms it, and from then on every re-run re-decides. The trigger must + include the inputs the decision reads — the empty-list and default-account signals — + because on a fresh sign-in the memberships arrive after the list does, changing the + answer without touching `initialized`. A ref keyed to the account we acted for keeps + that from re-selecting or re-pushing /add on every re-run. */ // Guard by the ACTIVE account, not defaultAccountId: on a personal account with no memberships // defaultAccountId is undefined, so keying on it would make the guard `undefined !== undefined` // (never redirect to /add) and could not tell one chosen account from the next. activeAccountId diff --git a/frontend/src/services/agent.test.ts b/frontend/src/services/agent.test.ts index 6e57d882b..b0f0278ac 100644 --- a/frontend/src/services/agent.test.ts +++ b/frontend/src/services/agent.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // agentURL() resolves the agent service base per request; isolate it from the store and the // heavy oidc/constants modules it pulls in at import time. @@ -7,7 +7,7 @@ vi.mock('../store', () => ({ store: { getState: () => state } })) vi.mock('./oidc', () => ({ oidcAuthHeaders: vi.fn() })) vi.mock('../constants', () => ({ OAUTH_AGENT_RESOURCE: 'https://agent.remote.it' })) -import { agentURL, isSecureAgentURL } from './agent' +import { agentURL, isSecureAgentURL, streamChat } from './agent' beforeEach(() => { state.ui.apis = {} @@ -35,3 +35,58 @@ describe('agentURL', () => { expect(fallback).not.toContain('insecure') // the fallback is the built-in, never the override }) }) + +/* The turn arrives as SSE. The spec allows CRLF, LF or CR line endings; the parser used to + recognise only '\n\n', so a CRLF server delivered nothing and every turn finished empty. */ +describe('streamChat — SSE framing', () => { + // A 200 whose body streams the given chunks, one read each + const sseResponse = (chunks: string[]) => { + const encoder = new TextEncoder() + const body = new ReadableStream({ + start(controller) { + chunks.forEach(chunk => controller.enqueue(encoder.encode(chunk))) + controller.close() + }, + }) + return new Response(body, { status: 200 }) + } + const collect = async (chunks: string[]) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse(chunks))) + const events: unknown[] = [] + await streamChat({ conversationId: 'c', text: 'hi', onEvent: event => events.push(event) }) + return events + } + const turn = { type: 'turn', turnId: 't1' } + const done = { type: 'done', stopReason: null } + afterEach(() => vi.unstubAllGlobals()) + + it('parses LF-delimited events (the baseline)', async () => { + const events = await collect(['event: turn\ndata: {"turnId":"t1"}\n\nevent: done\ndata: {"stopReason":null}\n\n']) + expect(events).toEqual([turn, done]) + }) + + it('parses CRLF-delimited events identically', async () => { + const events = await collect([ + 'event: turn\r\ndata: {"turnId":"t1"}\r\n\r\nevent: done\r\ndata: {"stopReason":null}\r\n\r\n', + ]) + expect(events).toEqual([turn, done]) + }) + + it('handles a CRLF torn across reads — CR ending one chunk, LF opening the next', async () => { + const events = await collect([ + 'event: turn\r\ndata: {"turnId":"t1"}\r', + '\n\r\nevent: done\r\ndata: {"stopReason":null}\r\n\r\n', + ]) + expect(events).toEqual([turn, done]) + }) + + it('delivers a final event the server closed on without a trailing blank line', async () => { + const events = await collect(['event: turn\ndata: {"turnId":"t1"}\n\nevent: done\ndata: {"stopReason":null}']) + expect(events).toEqual([turn, done]) + }) + + it('drops a torn tail rather than surfacing a parse error over a finished turn', async () => { + const events = await collect(['event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"tru']) + expect(events).toEqual([turn]) + }) +}) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index e0e005239..d8765730b 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -96,23 +96,48 @@ export async function streamChat(options: { const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) + const deliver = (block: string) => { + let event = 'message' + const dataLines: string[] = [] + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart()) + } + if (dataLines.length) onEvent({ type: event, ...JSON.parse(dataLines.join('\n')) } as AgentEvent) + } + /* An event ends at a blank line. SSE permits CRLF, LF or CR line endings, so normalise to LF + before looking for it — a CRLF server would otherwise never produce the '\n\n' we search + for, and every turn would finish silently empty. A CR at the very end of the buffer may be + the first half of a CRLF split across reads, so it is held back for the next read. */ + const drain = (final = false) => { + const hold = !final && buffer.endsWith('\r') ? '\r' : '' + buffer = buffer.slice(0, buffer.length - hold.length).replace(/\r\n?/g, '\n') + hold let index: number while ((index = buffer.indexOf('\n\n')) !== -1) { - const block = buffer.slice(0, index) + deliver(buffer.slice(0, index)) buffer = buffer.slice(index + 2) - let event = 'message' - const dataLines: string[] = [] - for (const line of block.split('\n')) { - if (line.startsWith('event:')) event = line.slice(6).trim() - else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart()) + } + // EOF: an event the server closed on without a trailing blank line is still an event. + // EventSource discards it because it cannot know whether it is complete; our payloads are + // JSON, so a successful parse IS that check — and a torn tail is dropped, not surfaced as + // a parse error over a turn the user already watched finish. + if (final && buffer.trim()) { + try { + deliver(buffer) + } catch { + /* truncated mid-event */ } - if (dataLines.length) onEvent({ type: event, ...JSON.parse(dataLines.join('\n')) } as AgentEvent) + buffer = '' } } + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + drain() + } + buffer += decoder.decode() // flush a multi-byte sequence still pending in the decoder + drain(true) } /* Approve or deny a write tool the agent paused on — addressed to the TURN */ From f574f2cf5439ebb647587c79c1313e283d81e807 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 23:09:48 -0700 Subject: [PATCH 214/238] =?UTF-8?q?Codex=20review=20fixes=20(round=206)=20?= =?UTF-8?q?=E2=80=94=20chat=20identity,=20background-work=20control,=20den?= =?UTF-8?q?y-on-abandon,=20stream=20cut-offs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings on 843b9b50. Two are primary-path defects — one a privacy issue, one a regression this branch's own reconciliation introduced — the rest robustness. P1 useChatSync.ts — compare chat ownership with the AUTHENTICATED user. The identity guard read the persisted `user` model, which is restored from storage and only catches up when the cloud sync lands; auth.user is fetched for the CURRENT tokens at sign-in. Activating a saved account swaps tokens and reloads WITHOUT purging persisted models, so for that interval (indefinitely, if the sync stalled) syncIdentity() accepted the previous owner's transcript and the newly activated user saw another account's chat. Both sync hooks now read auth.user.id via one useChatIdentity() ('' when signed out, which syncIdentity treats as a no-op). Same id space as before, so existing persisted ownerIds still match. P2 agent.ts — expose the background-work enrollment control. A REGRESSION from the merge reconciliation (53746ec9): the pre-merge agent TestPage carried an "AI background work" toggle (enroll ceremony via backgroundConnectUrl, backgroundStatus polling, backgroundDisable); taking permitteer-login's TestPage and re-adding only the Agent URL field dropped it, leaving backgroundConnectUrl/backgroundStatus with no caller and the workflow impossible to enable. Restored in the AI Agent section, in permitteer-login's ListItemSetting style; the extraction re-adding its four testPage.backgroundWork* keys confirms round 1 had removed them. P1 ChatHeader.tsx — New Chat stranded a pending approval: newConversation() → stop() cleared the card without a decision, leaving the server-side turn waiting on a card no window showed. Stop has the identical strand (the stream stays open while an approval is pending, so the Stop button is shown). Fixed at the ONE place every abandonment path runs through: stop() now sends an explicit DENY for a pending tool (best-effort, not awaited) before clearing — the safe answer for a write the user never approved, and the one that lets the server resolve. Covers Stop, New Chat, deleting the open conversation, an identity change and the unmount paths. Pop out / Pop back in stay GATED rather than routed here: a handoff means to continue the turn. P2 agent.ts — reject SSE EOF before a terminal event. A clean close after partial text but before done/error (a proxy idle timeout on a long turn, say) resolved streamChat() normally; the model's finally then marked the turn idle and a truncated answer looked complete with the composer open. streamChat tracks whether a terminal event arrived and throws AgentStreamEndedError otherwise (a Stop never lands there — aborting rejects reader.read() with an AbortError); send() maps it to an error event (new notices:chat.streamEnded), which marks the reply Interrupted and ends the turn. P2 constants.ts — derive the socket fallback from the EFFECTIVE GraphQL URL. This fallback is agent-branch code (base had none): it branched on the tree parsed from the OAuth RESOURCE, so a legacy-stage VITE_GRAPHQL_API beside a cloud resource paired the API with the cloud tree's socket, splitting API and event traffic across stages. Both shapes are now read off GRAPHQL_API itself; the default case is unchanged. P2 oidc.ts — AbortSignal.timeout feature-detected for the PRM fetch (missing on older mobile WebViews; the refresh-lock code already detects it). The bound is not optional here — dropping it would let a half-open MCP endpoint block sign-in — so where the static is missing the same signal is built from an AbortController, rather than throwing and skipping the very lookup that discovers a renamed detail type. P2 chatPopout.ts — refuse the popout where BroadcastChannel is missing. Without the channel a popout opened, never said hello, was never adopted, and neither window could hand back. chatPopoutSupported hides the Pop out control; openChatPopout() returns false as the backstop. Tests: 7 new behavior-changing cases (2 SSE cut-off, 2 WS pairing via vi.stubEnv + re-import, 1 no-channel via stubbed BroadcastChannel + re-import, 1 stop()-denies, 1 send() cut-off mapping) plus 4 controls; all 7 fail against the pre-fix sources (verified by stashing) and pass with the fixes. The identity-guard selector swap and the restored TestPage control are hook/component wiring covered by typecheck and the build (no RTL in this repo). Frontend 44/44, Electron 44/44, typecheck, i18n:check, extract-idempotency and the vite production build all green. Co-Authored-By: Claude Opus 5 --- frontend/src/components/Chat/ChatPanel.tsx | 3 +- frontend/src/constants.test.ts | 53 +++++++++++++++++ frontend/src/constants.ts | 9 ++- frontend/src/hooks/useChatSync.ts | 12 +++- frontend/src/i18n/locales/de/app.json | 4 ++ frontend/src/i18n/locales/de/notices.json | 1 + frontend/src/i18n/locales/en/app.json | 4 ++ frontend/src/i18n/locales/en/notices.json | 1 + frontend/src/i18n/locales/es/app.json | 4 ++ frontend/src/i18n/locales/es/notices.json | 1 + frontend/src/i18n/locales/ja/app.json | 4 ++ frontend/src/i18n/locales/ja/notices.json | 1 + frontend/src/models/chat.test.ts | 69 +++++++++++++++++++--- frontend/src/models/chat.ts | 21 ++++++- frontend/src/pages/TestPage.tsx | 38 +++++++++++- frontend/src/services/agent.test.ts | 33 +++++++++-- frontend/src/services/agent.ts | 18 +++++- frontend/src/services/chatPopout.test.ts | 13 ++++ frontend/src/services/chatPopout.ts | 4 ++ frontend/src/services/oidc.ts | 12 +++- 20 files changed, 283 insertions(+), 22 deletions(-) create mode 100644 frontend/src/constants.test.ts diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 1e91f0bde..31ab6d2d6 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -20,6 +20,7 @@ import { IconButton } from '../../buttons/IconButton' import { ChatHeader, NewChatButton } from './ChatHeader' import { ChatBody } from './ChatBody' import browser from '../../services/browser' +import { chatPopoutSupported } from '../../services/chatPopout' /* How far the docked column floats off the window edges, in theme spacing units. One knob: the margins and the size subtractions below both derive from it, so a @@ -140,7 +141,7 @@ export const ChatPanel: React.FC = () => { > {docked && } - {!browser.isMobile && !layout.mobile && ( + {!browser.isMobile && !layout.mobile && chatPopoutSupported && ( ) => { + vi.resetModules() + Object.entries(env).forEach(([key, value]) => vi.stubEnv(key, value)) + return await import('./constants') +} + +afterEach(() => vi.unstubAllEnvs()) + +/* The socket fallback must pair with the EFFECTIVE graphql URL. Pairing it with the OAuth + resource split API and event traffic across stages whenever VITE_GRAPHQL_API pointed at a + legacy stage while the resource stayed a cloud tree. */ +describe('constants — WEBSOCKET_URL fallback pairs with the effective GraphQL URL', () => { + it("a cloud-tree resource with no overrides → the tree's own graphql and /ws", async () => { + const c = await load({ + VITE_OAUTH_GRAPHQL_RESOURCE: 'https://cloud.dev.remote.it/api', + VITE_GRAPHQL_API: '', + VITE_WEBSOCKET_URL: '', + }) + expect(c.GRAPHQL_API).toBe('https://cloud.dev.remote.it/api/graphql') + expect(c.WEBSOCKET_URL).toBe('wss://cloud.dev.remote.it/api/ws') + }) + + it("a legacy-stage VITE_GRAPHQL_API beside a cloud resource → that stage's socket, not the tree's", async () => { + const c = await load({ + VITE_OAUTH_GRAPHQL_RESOURCE: 'https://cloud.remote.it/api', + VITE_GRAPHQL_API: 'https://graphql.dev.remote.it/graphql', + VITE_WEBSOCKET_URL: '', + }) + expect(c.GRAPHQL_API).toBe('https://graphql.dev.remote.it/graphql') + expect(c.WEBSOCKET_URL).toBe('wss://ws.dev.remote.it/v1') + }) + + it("a cloud-tree VITE_GRAPHQL_API beside a legacy resource → that tree's /ws", async () => { + const c = await load({ + VITE_OAUTH_GRAPHQL_RESOURCE: 'https://graphql.remote.it/graphql', + VITE_GRAPHQL_API: 'https://cloud.dev.remote.it/api/graphql', + VITE_WEBSOCKET_URL: '', + }) + expect(c.WEBSOCKET_URL).toBe('wss://cloud.dev.remote.it/api/ws') + }) + + it('an explicit VITE_WEBSOCKET_URL always wins', async () => { + const c = await load({ + VITE_GRAPHQL_API: 'https://graphql.dev.remote.it/graphql', + VITE_WEBSOCKET_URL: 'wss://custom.example.com/ws', + }) + expect(c.WEBSOCKET_URL).toBe('wss://custom.example.com/ws') + }) +}) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 444b9d232..949cbea2a 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -82,11 +82,16 @@ export const PROTOCOL = env.PROTOCOL || `${brand.name}://` // The cloud branch is not a nicety. The legacy regex CANNOT match a tree identifier, and its miss // falls through to the unlabelled `wss://ws.remote.it/v1` — PRODUCTION's socket. A dev build that // merely stopped setting VITE_WEBSOCKET_URL would have connected there silently. +// +// Both shapes are read off the EFFECTIVE graphql URL, not off the OAuth resource: VITE_GRAPHQL_API +// may point at a legacy stage while the resource stays a cloud tree, and pairing the socket with +// the resource there would split API and event traffic across stages. +const graphqlTree = GRAPHQL_API.match(/^(https:\/\/cloud(?:\.[a-z0-9-]+)?\.remote\.it\/api)\/graphql$/)?.[1] const graphqlStage = GRAPHQL_API.match(/^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/)?.[1] export const WEBSOCKET_URL = env.VITE_WEBSOCKET_URL || - (cloudTree - ? `${cloudTree.replace(/^https:/, 'wss:')}/ws` + (graphqlTree + ? `${graphqlTree.replace(/^https:/, 'wss:')}/ws` : `wss://ws${graphqlStage ? `.${graphqlStage}` : ''}.remote.it/v1`) export const WEBSOCKET_BETA_URL = env.VITE_WEBSOCKET_BETA_URL || WEBSOCKET_URL export const PORT = env.VITE_PORT || 29999 diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index c9ed6184b..439fe687a 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -46,10 +46,18 @@ export const useChatPopoutScope = (): void => { isn't display: adopting the server's transcript on mount, wiring the popout handoff protocol, re-checking agent health when the dock opens, and mirroring the app's active org. */ +/* The identity the chat is scoped by. auth.user, NOT the persisted `user` model: auth.user is + fetched for the CURRENT tokens at sign-in (it is what lets App mount), while the user model + is restored from storage and only catches up when the cloud sync lands. Activating a saved + account swaps tokens and reloads without purging persisted models, so for that interval + (indefinitely, if the sync stalls) the user model still names the PREVIOUS account — and an + ownership check against it would keep that account's transcript on the new account's screen. */ +const useChatIdentity = (): string => useSelector((state: State) => state.auth.user?.id ?? '') // '' = not signed in: syncIdentity no-ops + export const useChatMainSync = (): void => { const open = useSelector((state: State) => state.chat.open) const activeId = useSelector((state: State) => state.accounts.activeId) - const userId = useSelector((state: State) => state.user.id) + const userId = useChatIdentity() const dispatch = useDispatch() // Reset the chat when the signed-in identity changes (a different account) — declared @@ -124,7 +132,7 @@ export const useChatMainSync = (): void => { display-only. */ export const useChatPopoutSync = (): void => { const { t } = useTranslation() - const userId = useSelector((state: State) => state.user.id) + const userId = useChatIdentity() const dispatch = useDispatch() useEffect(() => { diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 09b596bd3..f4507c529 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -2240,6 +2240,10 @@ "agentURLInvalid": "", "aiAgent": "", "apiTarget": "API-Ziel", + "backgroundWork": "", + "backgroundWorkOff": "", + "backgroundWorkOn": "", + "backgroundWorkUnknown": "", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", "clearViewedAnnouncementsHint": "Markiert alle geladenen Ankündigungen für dieses Konto als ungelesen.", "customAPITarget": "Benutzerdefiniert", diff --git a/frontend/src/i18n/locales/de/notices.json b/frontend/src/i18n/locales/de/notices.json index 261038324..99b54e67a 100644 --- a/frontend/src/i18n/locales/de/notices.json +++ b/frontend/src/i18n/locales/de/notices.json @@ -15,6 +15,7 @@ "popupBlocked": "Pop-out blockiert — erlauben Sie Pop-ups für diese Seite und versuchen Sie es erneut.", "sessionExpired": "Agentensitzung abgelaufen — melden Sie sich erneut an, um fortzufahren.", "signInFailed": "Agentenanmeldung fehlgeschlagen — {{error}}", + "streamEnded": "", "usageReset": "{{msg}} Zurücksetzung {{when}}." }, "connection": { diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 19eb90dea..a8283ce78 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -2240,6 +2240,10 @@ "agentURLInvalid": "Agent service URL must start with https://", "aiAgent": "AI Agent", "apiTarget": "API Target", + "backgroundWork": "AI background work", + "backgroundWorkOff": "The agent only works while you are here.", + "backgroundWorkOn": "The agent can read and watch while you are away.", + "backgroundWorkUnknown": "Checking…", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", "customAPITarget": "Custom", diff --git a/frontend/src/i18n/locales/en/notices.json b/frontend/src/i18n/locales/en/notices.json index c9bb8b848..04cf9fd63 100644 --- a/frontend/src/i18n/locales/en/notices.json +++ b/frontend/src/i18n/locales/en/notices.json @@ -15,6 +15,7 @@ "popupBlocked": "Pop out was blocked — allow popups for this site and try again.", "sessionExpired": "The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.", "signInFailed": "Agent sign-in failed — {{error}}", + "streamEnded": "The connection to the agent closed before it finished — the answer may be incomplete. Try again.", "usageReset": "{{msg}} Resets {{when}}." }, "connection": { diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 5a3a5a8a5..df14eb6dd 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -2277,6 +2277,10 @@ "agentURLInvalid": "", "aiAgent": "", "apiTarget": "Destino de la API", + "backgroundWork": "", + "backgroundWorkOff": "", + "backgroundWorkOn": "", + "backgroundWorkUnknown": "", "clearViewedAnnouncements": "Borrar anuncios vistos", "clearViewedAnnouncementsHint": "Marca todos los anuncios cargados como no leídos para esta cuenta.", "customAPITarget": "Personalizado", diff --git a/frontend/src/i18n/locales/es/notices.json b/frontend/src/i18n/locales/es/notices.json index 8f639b3e5..470f0bdb3 100644 --- a/frontend/src/i18n/locales/es/notices.json +++ b/frontend/src/i18n/locales/es/notices.json @@ -15,6 +15,7 @@ "popupBlocked": "Ventana emergente bloqueada — permita las ventanas emergentes para este sitio e inténtelo de nuevo.", "sessionExpired": "La sesión del agente ha expirado — inicie sesión de nuevo para continuar.", "signInFailed": "Error al iniciar sesión en el agente — {{error}}", + "streamEnded": "", "usageReset": "{{msg}} Se restablece {{when}}." }, "connection": { diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index e099a0656..2146d74f6 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -2203,6 +2203,10 @@ "agentURLInvalid": "", "aiAgent": "", "apiTarget": "APIターゲット", + "backgroundWork": "", + "backgroundWorkOff": "", + "backgroundWorkOn": "", + "backgroundWorkUnknown": "", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", "customAPITarget": "カスタム", diff --git a/frontend/src/i18n/locales/ja/notices.json b/frontend/src/i18n/locales/ja/notices.json index 211958401..e55f9711e 100644 --- a/frontend/src/i18n/locales/ja/notices.json +++ b/frontend/src/i18n/locales/ja/notices.json @@ -15,6 +15,7 @@ "popupBlocked": "ポップアウトがブロックされました — このサイトのポップアップを許可してから、もう一度お試しください。", "sessionExpired": "エージェントのセッションの期限が切れました — もう一度サインインして続行してください。", "signInFailed": "エージェントのサインインに失敗しました — {{error}}", + "streamEnded": "", "usageReset": "{{msg}} {{when}}にリセットされます。" }, "connection": { diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index c728a01c0..cf216f14e 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -4,24 +4,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // stub them so the syncTranscript EFFECT runs in isolation. fetchConversation is the one real // spy — each test scripts what the server returns and, crucially, what the user does to the // live store WHILE that fetch is in flight. The store is a hoisted MUTABLE object for that. -const { fetchConversation, deleteConversation, openChatPopout, storeState } = vi.hoisted(() => ({ - fetchConversation: vi.fn(), - deleteConversation: vi.fn(), - openChatPopout: vi.fn(), - storeState: { chat: {} as Record }, -})) +const { fetchConversation, deleteConversation, streamChat, confirmTool, openChatPopout, storeState } = vi.hoisted( + () => ({ + fetchConversation: vi.fn(), + deleteConversation: vi.fn(), + streamChat: vi.fn(), + confirmTool: vi.fn(), + openChatPopout: vi.fn(), + storeState: { chat: {} as Record }, + }) +) vi.mock('../services/agent', () => ({ fetchConversation, deleteConversation, - streamChat: vi.fn(), - confirmTool: vi.fn(), + streamChat, + confirmTool, backgroundDisable: vi.fn(), listConversations: vi.fn(), fetchUsage: vi.fn(), agentHealth: vi.fn(), UsageLimitError: class UsageLimitError extends Error {}, AgentAuthError: class AgentAuthError extends Error {}, + AgentStreamEndedError: class AgentStreamEndedError extends Error {}, })) vi.mock('../services/chatPopout', () => ({ broadcastChatSignout: vi.fn(), @@ -33,6 +38,8 @@ vi.mock('../constants', () => ({ CHAT_PANEL_WIDTH: 400 })) vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) import chatModel from './chat' +// The mocked module's class — the same one chat.ts's instanceof sees +import { AgentStreamEndedError } from '../services/agent' const effectsFor = (dispatch: any) => (chatModel as any).effects(dispatch) const makeDispatch = () => ({ @@ -71,6 +78,8 @@ const remoteAsLocal = [ beforeEach(() => { fetchConversation.mockReset() deleteConversation.mockReset() + streamChat.mockReset() + confirmTool.mockReset() openChatPopout.mockReset() storeState.chat = { conversationId: 'a', streaming: false, messages: [], title: '' } }) @@ -194,6 +203,50 @@ describe('chat model — removeConversation', () => { }) }) +/* A pending approval is part of the turn: abandoning the turn must DENY it, or the server-side + turn waits on a card no window shows any more. stop() is the one place every abandonment path + (Stop, New Chat, delete, identity change, unmount) runs through. */ +describe('chat model — stop() denies a pending approval', () => { + const pending = { toolUseId: 'tool-9', name: 'update_device', input: {} } + + it('sends an explicit deny for the pending tool before clearing it', async () => { + confirmTool.mockResolvedValue(undefined) + const dispatch = makeDispatch() + await effectsFor(dispatch).stop(undefined, current({ turnId: 'turn-1', pendingConfirmation: pending })) + expect(confirmTool).toHaveBeenCalledWith({ turnId: 'turn-1', toolUseId: 'tool-9', approved: false }) + expect(dispatch.chat.set).toHaveBeenCalledWith({ streaming: false, pendingConfirmation: null }) + }) + + it('sends nothing when no approval is pending', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).stop(undefined, current({ turnId: 'turn-1', pendingConfirmation: null })) + expect(confirmTool).not.toHaveBeenCalled() + }) + + it('never waits on, or fails from, the deny (best-effort)', async () => { + confirmTool.mockRejectedValue(new Error('offline')) + const dispatch = makeDispatch() + await expect( + effectsFor(dispatch).stop(undefined, current({ turnId: 'turn-1', pendingConfirmation: pending })) + ).resolves.toBeUndefined() + expect(dispatch.chat.set).toHaveBeenCalledWith({ streaming: false, pendingConfirmation: null }) + }) +}) + +/* A stream the server closed cleanly mid-answer must end the turn as an interruption — not + resolve like a completion with a truncated reply on screen and the composer open. */ +describe('chat model — send() treats a cut-off stream as an interrupted turn', () => { + it('maps AgentStreamEndedError to an error event (which marks the reply Interrupted)', async () => { + streamChat.mockRejectedValue(new AgentStreamEndedError()) + const dispatch = makeDispatch() + await effectsFor(dispatch).send('hello', sendable()) + expect(dispatch.chat.applyEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', message: 'notices:chat.streamEnded' }) + ) + expect(dispatch.chat.set).toHaveBeenCalledWith({ streaming: false }) + }) +}) + /* The popout persists nothing, so it boots on the PERSONAL account unless told otherwise — and a chat licensed only for an organization would then be refused in its own popout. */ describe('chat model — popOut hands over the account scope', () => { diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 40fd3c001..52d7042bb 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -9,6 +9,7 @@ import { deleteConversation, fetchUsage, UsageLimitError, + AgentStreamEndedError, type ConversationSummary, type Usage, agentHealth, @@ -258,6 +259,15 @@ export default createModel()({ } else if (error instanceof UsageLimitError) dispatch.chat.applyEvent({ type: 'error', message: usageLimitMessage(error) }) + else if (error instanceof AgentStreamEndedError) + // The error event marks the answer Interrupted and ends the turn — a cut-off must not + // leave a truncated reply looking complete with the composer open for another send. + dispatch.chat.applyEvent({ + type: 'error', + message: i18n.t('notices:chat.streamEnded', { + defaultValue: 'The connection to the agent closed before it finished — the answer may be incomplete. Try again.', + }), + }) else if ((error as Error).name !== 'AbortError') dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message }) } finally { @@ -295,7 +305,16 @@ export default createModel()({ else dispatch.chat.set({ pendingConfirmation: pending, error: (error as Error).message }) } }, - async stop() { + async stop(_: void, state) { + // A pending approval is part of the turn. Abandoning the turn — Stop, New Chat, deleting the + // open conversation, an identity change, the panel unmounting — DENIES it: the safe answer + // for a write the user never approved, and the one that lets the server-side turn resolve + // instead of waiting on a card no window shows any more. (Pop out / Pop back in are GATED + // while an approval is pending rather than routed here: a handoff means to continue the + // turn, not abandon it.) Best-effort and not awaited — stopping never waits on the network. + const { pendingConfirmation, turnId } = state.chat + if (pendingConfirmation && turnId) + confirmTool({ turnId, toolUseId: pendingConfirmation.toolUseId, approved: false }).catch(() => {}) abortController?.abort() abortController = null dispatch.chat.set({ streaming: false, pendingConfirmation: null }) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 6d54b29ef..cc00720d8 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -7,7 +7,8 @@ import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL, resourceForApiURL } from '../helpers/apiHelper' import { bindableResources } from '../services/permitteerAccount' import { oidcAccessToken } from '../services/oidc' -import { isSecureAgentURL } from '../services/agent' +import { isSecureAgentURL, backgroundConnectUrl, backgroundStatus, backgroundDisable } from '../services/agent' +import { windowOpen } from '../services/browser' import { selectLimitsLookup, selectFeatures } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' @@ -44,6 +45,28 @@ export const TestPage: React.FC = () => { const [targets, setTargets] = useState>([]) const [mintError, setMintError] = useState('') const [agentError, setAgentError] = useState('') + + // Background work (permitteer docs/remoteit-ai-agent.md D6): the agent's own, narrower + // grant — enrollment is a browser ceremony at the AS; this page only reads/ends it. (The + // one UI entry point for it: without this control backgroundConnectUrl/backgroundStatus + // have no caller and the workflow cannot be enabled.) + const [backgroundEnrolled, setBackgroundEnrolled] = useState(undefined) + useEffect(() => { + backgroundStatus().then(setBackgroundEnrolled) + }, []) + async function connectBackground() { + await windowOpen(backgroundConnectUrl(), '_blank', true) + // The ceremony finishes in the browser — poll briefly for the verdict. + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 2000)) + if (await backgroundStatus()) break + } + setBackgroundEnrolled(await backgroundStatus()) + } + async function disableBackground() { + await backgroundDisable() + setBackgroundEnrolled(await backgroundStatus()) + } useEffect(() => { bindableResources().then(setTargets) }, []) @@ -267,6 +290,19 @@ export const TestPage: React.FC = () => { {t('testPage.aiAgent', 'AI Agent')} + (backgroundEnrolled ? disableBackground() : connectBackground())} + /> diff --git a/frontend/src/services/agent.test.ts b/frontend/src/services/agent.test.ts index b0f0278ac..4a61c2095 100644 --- a/frontend/src/services/agent.test.ts +++ b/frontend/src/services/agent.test.ts @@ -7,7 +7,7 @@ vi.mock('../store', () => ({ store: { getState: () => state } })) vi.mock('./oidc', () => ({ oidcAuthHeaders: vi.fn() })) vi.mock('../constants', () => ({ OAUTH_AGENT_RESOURCE: 'https://agent.remote.it' })) -import { agentURL, isSecureAgentURL, streamChat } from './agent' +import { agentURL, isSecureAgentURL, streamChat, AgentStreamEndedError } from './agent' beforeEach(() => { state.ui.apis = {} @@ -50,10 +50,18 @@ describe('streamChat — SSE framing', () => { }) return new Response(body, { status: 200 }) } - const collect = async (chunks: string[]) => { + // Runs a stream to the end, returning the delivered events and the terminal outcome + const run = async (chunks: string[]) => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse(chunks))) const events: unknown[] = [] - await streamChat({ conversationId: 'c', text: 'hi', onEvent: event => events.push(event) }) + const outcome = await streamChat({ conversationId: 'c', text: 'hi', onEvent: event => events.push(event) }) + .then(() => 'completed' as const) + .catch((error: unknown) => error) + return { events, outcome } + } + const collect = async (chunks: string[]) => { + const { events, outcome } = await run(chunks) + expect(outcome).toBe('completed') return events } const turn = { type: 'turn', turnId: 't1' } @@ -85,8 +93,23 @@ describe('streamChat — SSE framing', () => { expect(events).toEqual([turn, done]) }) - it('drops a torn tail rather than surfacing a parse error over a finished turn', async () => { - const events = await collect(['event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"tru']) + /* A clean close with no done/error is a cut-off — a proxy idle timeout on a long turn, say. + It used to resolve like a completion, leaving a truncated answer looking finished with + the composer open for another send. */ + it('reports a clean EOF with no terminal event as a cut-off, after delivering what arrived', async () => { + const { events, outcome } = await run(['event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"half an"}\n\n']) + expect(events).toEqual([turn, { type: 'text_delta', text: 'half an' }]) + expect(outcome).toBeInstanceOf(AgentStreamEndedError) + }) + + it('drops a torn tail rather than surfacing a parse error — and reports the cut-off', async () => { + const { events, outcome } = await run(['event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"tru']) expect(events).toEqual([turn]) + expect(outcome).toBeInstanceOf(AgentStreamEndedError) + }) + + it('an error event is terminal too (no cut-off on top of a reported failure)', async () => { + const events = await collect(['event: turn\ndata: {"turnId":"t1"}\n\nevent: error\ndata: {"message":"boom"}\n\n']) + expect(events).toEqual([turn, { type: 'error', message: 'boom' }]) }) }) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index d8765730b..8e1836655 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -38,6 +38,16 @@ export class AgentAuthError extends Error { /* A usage window (session/weekly) or the fleet is spent — the turn was refused before it ran. Carries which window and when it resets so the UI can say "resets at 4:30pm". */ +/* The stream closed cleanly before a terminal event (done / error) — the server or an + intermediary (a proxy idle timeout on a long turn, say) ended it mid-answer. Without this + the turn resolved normally and a truncated answer looked complete. */ +export class AgentStreamEndedError extends Error { + constructor() { + super('Agent stream ended before the turn completed') + this.name = 'AgentStreamEndedError' + } +} + export class UsageLimitError extends Error { constructor( message: string, @@ -96,6 +106,7 @@ export async function streamChat(options: { const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' + let terminal = false // a done or error event closed the turn — anything else at EOF is a cut-off const deliver = (block: string) => { let event = 'message' const dataLines: string[] = [] @@ -103,7 +114,9 @@ export async function streamChat(options: { if (line.startsWith('event:')) event = line.slice(6).trim() else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart()) } - if (dataLines.length) onEvent({ type: event, ...JSON.parse(dataLines.join('\n')) } as AgentEvent) + if (!dataLines.length) return + if (event === 'done' || event === 'error') terminal = true + onEvent({ type: event, ...JSON.parse(dataLines.join('\n')) } as AgentEvent) } /* An event ends at a blank line. SSE permits CRLF, LF or CR line endings, so normalise to LF before looking for it — a CRLF server would otherwise never produce the '\n\n' we search @@ -138,6 +151,9 @@ export async function streamChat(options: { } buffer += decoder.decode() // flush a multi-byte sequence still pending in the decoder drain(true) + // A clean close with no terminal event is a cut-off, not a completion. (A Stop never lands + // here: aborting rejects reader.read() with an AbortError, which the caller ignores.) + if (!terminal) throw new AgentStreamEndedError() } /* Approve or deny a write tool the agent paused on — addressed to the TURN */ diff --git a/frontend/src/services/chatPopout.test.ts b/frontend/src/services/chatPopout.test.ts index 39799b932..cb5964c03 100644 --- a/frontend/src/services/chatPopout.test.ts +++ b/frontend/src/services/chatPopout.test.ts @@ -44,4 +44,17 @@ describe('chatPopout — openChatPopout', () => { expect(openChatPopout('org-1')).toBe(false) expect(window.sessionStorage.getItem('chatPopoutOwner')).toBeNull() }) + + /* The whole handoff rides a BroadcastChannel. Without one a popout would open, never say + hello, never be adopted, and neither window could hand the transcript back. */ + it('is unsupported, and refuses to open, where BroadcastChannel is missing', async () => { + vi.stubGlobal('BroadcastChannel', undefined) + vi.resetModules() + const open = vi.spyOn(window, 'open').mockReturnValue({} as Window) + const fresh = await import('./chatPopout') + expect(fresh.chatPopoutSupported).toBe(false) + expect(fresh.openChatPopout('org-1')).toBe(false) + expect(open).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) }) diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index 2eee43e55..b7b9bfdf6 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -78,6 +78,9 @@ const PRESENCE_TIMEOUT = 500 const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL) : null const post = (message: PopoutMessage) => channel?.postMessage(message) +/* The whole handoff rides the channel. Without it a popout would open, never say hello, never be + adopted, and neither window could hand the transcript back — so the action is not offered. */ +export const chatPopoutSupported = channel !== null let popoutWindow: Window | null = null let pollTimer: number | undefined @@ -104,6 +107,7 @@ const pingPopout = (id: string): Promise => /* `scope` is the opener's account scope (accounts.activeId, or the user for the personal account) — see popoutScopeId for why the popout needs it handed over at boot. */ export function openChatPopout(scope?: string): boolean { + if (!channel) return false // no handoff possible (the button is hidden; this is the backstop) // Reuse the stored id so re-clicking Pop out re-targets the same named // window instead of orphaning it under a new identity const id = ownerId() || crypto.randomUUID().slice(0, 8) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 4b803a9ce..1aeb8f8e8 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -286,6 +286,16 @@ function mcpDetailType(): string { try { const stored = tokenStore().getItem(MCP_TYPE_KEY); if (stored) return (mcpTypeMemo = stored) } catch { /* fall through */ } return OAUTH_MCP_DETAIL } +/* AbortSignal.timeout, by hand where the static is missing (older mobile WebViews). The bound + below is not optional — dropping it would let a half-open endpoint block sign-in — and a + throw from the missing static would skip the fetch altogether, leaving a renamed detail type + undiscovered exactly where this lookup exists to discover it. */ +const timeoutSignal = (ms: number): AbortSignal => { + if (typeof AbortSignal?.timeout === 'function') return AbortSignal.timeout(ms) + const controller = new AbortController() + setTimeout(() => controller.abort(), ms) + return controller.signal +} async function refreshMcpDetailType(): Promise { try { const r = new URL(OAUTH_MCP_RESOURCE) @@ -294,7 +304,7 @@ async function refreshMcpDetailType(): Promise { // grant-heal path, so a slow or half-open MCP endpoint must not block authentication. On // timeout the fetch aborts, the catch fires, and the cached/fallback name (mcpDetailType()) // stands — the AS being healthy is enough to sign in. - const doc = (await (await fetch(prm, { signal: AbortSignal.timeout(4000) })).json()) as { + const doc = (await (await fetch(prm, { signal: timeoutSignal(4000) })).json()) as { authorization_details_types_supported?: string[] authorization_details_types?: Array<{ type?: string; risk_class?: string }> } From 5fd120b380214561ba6861e4b9005109de889ee5 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Sun, 13 Sep 2026 23:28:50 -0700 Subject: [PATCH 215/238] =?UTF-8?q?Codex=20review=20fixes=20(round=207)=20?= =?UTF-8?q?=E2=80=94=20one=20generation=20guard=20for=20every=20chat=20loa?= =?UTF-8?q?d;=20boot=20heal=20waits=20for=20MCP=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings on f574f2cf. Three are the event-invalidation sibling of the stale-snapshot class closed in round 4 — a load in flight not invalidated by a later EVENT (a send, a sign-out, a newer probe) — which had produced a finding every round since. Closed as a class this time. P1 chat.ts — invalidate pending loads on sign-out. chat.signOut aborted only the STREAM; a slow history pick started under one account passed its own guard after the reset (its ticket unchanged, nothing streaming) and wrote that account's transcript into the store the NEXT account boots from — persisted, and on the next account's screen if it landed late (same family as round 6's identity finding). Sign-out now advances the generation. P2 chat.ts — syncTranscript invalidated when a turn starts. A sync outliving a turn that started AND finished meanwhile saw the same id and streaming false again, applied the older server snapshot, and removed the just-completed turn from view. A sync now reads the generation (without advancing it — a background reconcile must not out-rank a pick in flight) and drops its result if anything advanced it meanwhile. P2 chat.ts — stale agent health responses. Overlapping probes (a slow one started while connectivity failed, then the reconnect-triggered one) let the older land last and flip a fresh `ok` back to `unreachable`, disabling the composer with the agent reachable. Latest-wins ticket. The design, in one place: `generation` advances on every event that makes an in-flight load unwanted — a pick (which takes the new ticket), New Chat, send, sign-out — and every effect that writes fetched conversation content (openConversation, syncTranscript) applies only while its ticket is current; the `streaming` flag alone was never enough, since a turn can start and finish inside a fetch. Probes that are not conversation-scoped (health, history list, usage meter) get their own latest-wins tickets rather than cross-invalidation. `selection` is renamed `generation` to say what it now is. Audit: every await-then-write effect in chat.ts is guarded. P2 oidc.ts — await MCP metadata before the boot grant-freshness check. The boot refreshMcpDetailType() was fire-and-forget while healGrant() called the synchronous oidcGrantStale(): on the first load after a detail-type rename the cached name was still the OLD one, the fingerprint matched, the grant was called current, and the discovery that followed updated only the cache — nothing re-ran the heal, so agent authorization stayed broken until a reload. The boot refresh is now captured as a promise (oidcMcpDetailReady; bounded by the fetch timeout, never rejects) and healGrant awaits it before the check — at most one bound, once, and instant thereafter. No extra fetch. Tests: 5 new behavior-changing cases (sign-out invalidates a pick, send invalidates a sync, latest-wins health and list, healGrant ordering); all 5 fail against the pre-fix chat.ts/auth.ts (verified by stashing) and pass with the fixes. Frontend 49/49, Electron 44/44, typecheck, i18n:check, extract-idempotency and the vite production build all green. Co-Authored-By: Claude Opus 5 --- frontend/src/models/auth.test.ts | 32 +++++++++- frontend/src/models/auth.ts | 6 +- frontend/src/models/chat.test.ts | 105 +++++++++++++++++++++++++++---- frontend/src/models/chat.ts | 73 ++++++++++++++------- frontend/src/services/oidc.ts | 10 ++- 5 files changed, 185 insertions(+), 41 deletions(-) diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index 42c068ff1..defe88d81 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -6,16 +6,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // the hoisted vi.mock factory runs. `browser` and the live `store` state are hoisted MUTABLE // objects so individual tests can steer the electron/backend branch and what the effects // re-read from the store after a teardown. -const { oidcStart, oidcEndSessionSilently, browser, storeState } = vi.hoisted(() => ({ +const { oidcStart, oidcEndSessionSilently, oidcGrantStale, oidcMcpDetailReady, browser, storeState } = vi.hoisted(() => ({ oidcStart: vi.fn(), oidcEndSessionSilently: vi.fn(), + oidcGrantStale: vi.fn(), + oidcMcpDetailReady: vi.fn(), browser: { isElectron: false, hasBackend: false }, storeState: { auth: {} as Record }, })) // signInFailure() tests `error instanceof OidcError`, so the mock must export a real class // (an undefined right-hand side of instanceof throws rather than returning false). -vi.mock('../services/oidc', () => ({ oidcStart, oidcEndSessionSilently, OidcError: class OidcError extends Error {} })) +vi.mock('../services/oidc', () => ({ + oidcStart, + oidcEndSessionSilently, + oidcGrantStale, + oidcMcpDetailReady, + OidcError: class OidcError extends Error {}, +})) vi.mock('../services/Controller', () => ({ default: {}, emit: vi.fn(() => false) })) vi.mock('../services/CloudSync', () => ({ default: {} })) vi.mock('../services/cloudController', () => ({ default: {} })) @@ -49,6 +57,8 @@ const effectsFor = (dispatch: any) => (authModel as any).effects(dispatch) beforeEach(() => { oidcStart.mockReset() oidcEndSessionSilently.mockReset() + oidcGrantStale.mockReset() + oidcMcpDetailReady.mockReset().mockResolvedValue('mcp_type') }) describe('auth model — sign-in always offers the chooser', () => { @@ -108,6 +118,24 @@ describe('auth model — a backend rejection survives the sign-out teardown', () }) }) +/* oidcGrantStale() compares against the MCP detail type. On the first load after a rename the + cached name is the OLD one until the boot metadata refresh lands; a check that ran before it + called a renamed-away grant current, and the discovery that followed re-ran nothing. */ +describe('auth model — the grant freshness check waits for the boot MCP metadata', () => { + it('healGrant does not consult oidcGrantStale until oidcMcpDetailReady resolves', async () => { + let ready!: (type: string) => void + oidcMcpDetailReady.mockReturnValue(new Promise(resolve => (ready = resolve))) + oidcGrantStale.mockReturnValue(false) + const dispatch = makeDispatch() + const healing = effectsFor(dispatch).healGrant() + await Promise.resolve() + expect(oidcGrantStale).not.toHaveBeenCalled() + ready('mcp_type_v2') + await healing + expect(oidcGrantStale).toHaveBeenCalledTimes(1) + }) +}) + describe('auth model — a dropped, unauthenticated backend socket still explains itself', () => { const unauthenticated = { auth: { authenticated: false, backendAuthenticated: false } } beforeEach(() => { diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 627485928..fbb31f193 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -9,7 +9,7 @@ import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' import { persistor, store } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken, apiAuthHeaders } from '../services/remoteit' -import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcDeclaration, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, oidcRefreshBrowserAccounts, oidcSelectKnownAccount, oidcClearAutoStarts, OidcClaims, OidcError, OidcErrorCode } from '../services/oidc' +import { oidcConfigured, oidcSignedIn, oidcClaims, oidcStart, oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, oidcTakeActivationHint, invalidateOidcToken, oidcGrantStale, oidcMcpDetailReady, oidcDeclaration, oidcActor, oidcTakeSupportTicket, oidcIsSupportTab, oidcRefreshBrowserAccounts, oidcSelectKnownAccount, oidcClearAutoStarts, OidcClaims, OidcError, OidcErrorCode } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' @@ -166,6 +166,10 @@ export default createModel()({ * renew marker uses. */ async healGrant(options?: { force?: boolean }) { try { + // The freshness check compares against the MCP detail type; on the first load after a + // rename the cached name is the OLD one until the boot metadata refresh lands. Wait for it + // (bounded, resolved instantly thereafter) so this cannot call a renamed-away grant current. + await oidcMcpDetailReady() if (!oidcGrantStale()) { window.sessionStorage.removeItem(GRANT_HEAL_KEY) return diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index cf216f14e..bc7bd6482 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -4,26 +4,37 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // stub them so the syncTranscript EFFECT runs in isolation. fetchConversation is the one real // spy — each test scripts what the server returns and, crucially, what the user does to the // live store WHILE that fetch is in flight. The store is a hoisted MUTABLE object for that. -const { fetchConversation, deleteConversation, streamChat, confirmTool, openChatPopout, storeState } = vi.hoisted( - () => ({ - fetchConversation: vi.fn(), - deleteConversation: vi.fn(), - streamChat: vi.fn(), - confirmTool: vi.fn(), - openChatPopout: vi.fn(), - storeState: { chat: {} as Record }, - }) -) - -vi.mock('../services/agent', () => ({ +const { fetchConversation, deleteConversation, streamChat, confirmTool, + backgroundDisable, + listConversations, + agentHealth, + openChatPopout, + storeState, +} = vi.hoisted(() => ({ + fetchConversation: vi.fn(), + deleteConversation: vi.fn(), + streamChat: vi.fn(), + confirmTool: vi.fn(), backgroundDisable: vi.fn(), listConversations: vi.fn(), - fetchUsage: vi.fn(), agentHealth: vi.fn(), + openChatPopout: vi.fn(), + storeState: { chat: {} as Record }, +})) + +vi.mock('../services/agent', () => ({ + fetchConversation, + deleteConversation, + streamChat, + confirmTool, + backgroundDisable, + listConversations, + fetchUsage: vi.fn(), + agentHealth, UsageLimitError: class UsageLimitError extends Error {}, AgentAuthError: class AgentAuthError extends Error {}, AgentStreamEndedError: class AgentStreamEndedError extends Error {}, @@ -80,6 +91,9 @@ beforeEach(() => { deleteConversation.mockReset() streamChat.mockReset() confirmTool.mockReset() + backgroundDisable.mockReset().mockResolvedValue(undefined) + listConversations.mockReset() + agentHealth.mockReset() openChatPopout.mockReset() storeState.chat = { conversationId: 'a', streaming: false, messages: [], title: '' } }) @@ -164,6 +178,71 @@ describe('chat model — openConversation applies only the latest selection', () await effectsFor(dispatch).openConversation('A', current()) expect(dispatch.chat.set).toHaveBeenCalledWith(opened('A')) }) + + /* Aborting on sign-out covers only the STREAM. A slow pick started under one account passed + its own guard after the reset (ticket unchanged, nothing streaming) and wrote that account's + transcript into the store the next account boots from. */ + it('a pick still in flight at sign-out never lands — not even after the next account is in', async () => { + const a = deferred() + fetchConversation.mockImplementationOnce(() => a.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const openA = fx.openConversation('A', current()) + await fx.signOut() + storeState.chat = { conversationId: '', streaming: false, messages: [], title: '' } // reset, next user booting + a.resolve(remote) + await openA + expect(dispatch.chat.set).not.toHaveBeenCalledWith(opened('A')) + }) +}) + +/* A sync is a background reconcile against the server. One that outlives a turn the user + started AND finished meanwhile sees the same id and streaming false again — and without the + generation it applied the older server snapshot, removing the just-completed turn from view. */ +describe('chat model — syncTranscript is invalidated by a turn that completes during it', () => { + it('drops the stale server snapshot after a send', async () => { + const sync = deferred() + fetchConversation.mockImplementationOnce(() => sync.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const syncing = fx.syncTranscript(undefined, current()) + await fx.send('hello', sendable()) // whole turn completes; streaming false again, same id + sync.resolve(remote) // the older snapshot, without the new turn + await syncing + expect(dispatch.chat.set).not.toHaveBeenCalledWith(expect.objectContaining({ messages: expect.anything() })) + }) +}) + +/* Probes that are not conversation-scoped get latest-wins tickets instead: an older response + landing last must not overwrite a newer one. */ +describe('chat model — independent probes are latest-wins', () => { + const online = { ui: { offline: false }, chat: {} } + + it('an older, slower health probe cannot flip a fresh ok back to unreachable', async () => { + const slow = deferred() + agentHealth.mockImplementationOnce(() => slow.promise).mockResolvedValueOnce('ok') + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const first = fx.checkHealth(undefined, online) // started while connectivity was failing… + await fx.checkHealth(undefined, online) // …the reconnect-triggered probe lands first + slow.resolve('unreachable') + await first + expect(dispatch.chat.set).toHaveBeenCalledWith({ health: 'ok' }) + expect(dispatch.chat.set).not.toHaveBeenCalledWith({ health: 'unreachable' }) + }) + + it('an older, slower history-list load cannot overwrite a newer one', async () => { + const slow = deferred() + listConversations.mockImplementationOnce(() => slow.promise).mockResolvedValueOnce([{ id: 'new' }]) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const first = fx.loadConversations() + await fx.loadConversations() + slow.resolve([{ id: 'old' }]) + await first + expect(dispatch.chat.set).toHaveBeenCalledWith({ conversations: [{ id: 'new' }] }) + expect(dispatch.chat.set).not.toHaveBeenCalledWith({ conversations: [{ id: 'old' }] }) + }) }) /* Deleting is a request too: it can fail without an HTTP response, and it can be slow enough diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 52d7042bb..48091ed21 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -184,22 +184,32 @@ const usageLimitMessage = (e: UsageLimitError): string => { } let abortController: AbortController | null = null -/* Which conversation SELECTION is current. openConversation takes a ticket and applies its fetch - only while it still holds the latest. newConversation and send take one too: a New Chat, or a - message sent to the conversation on screen, during a slow open must not be undone when that open - finally lands. (The same generation check logs.ts keys on requestId.) */ -let selection = 0 +/* The GENERATION of the conversation on screen — the one guard for everything that writes fetched + chat content into the store. It advances on every event that makes a load already in flight + unwanted: a history pick (the pick itself takes the new ticket), New Chat, a send (the user has + committed to what is on screen), and sign-out (nothing this session started may land in the + next one's store — a slow pick under one account must not write that account's transcript onto + the next). A load applies only while the ticket it took is still current; the instantaneous + `streaming` flag is not enough on its own, since a turn can start AND finish while a fetch is + in flight. (The same check logs.ts keys on requestId.) */ +let generation = 0 +const nextGeneration = () => ++generation +/* Independent probes — health, the history list, the usage meter — are not scoped to the + conversation, so they get their own latest-wins tickets instead: an older response that + lands last must not overwrite a newer one. */ +let healthProbe = 0 +let listLoad = 0 +let usageLoad = 0 export default createModel()({ state: { ...defaultChatState }, effects: dispatch => ({ async send(text: string, state) { if (state.chat.streaming || state.chat.pendingConfirmation) return - // A send commits the user to the conversation on screen: any history pick still in flight - // is no longer wanted. Take the ticket HERE, not only via the streaming flag — a turn that - // starts and finishes before a slow pick lands leaves streaming false again, and the stale - // load would otherwise replace the completed turn. - selection++ + // A send commits the user to the conversation on screen: any pick or sync still in flight is + // no longer wanted — a turn that starts and finishes before it lands would otherwise be + // replaced (a pick) or removed (a sync) by the stale load. + nextGeneration() const conversationId = state.chat.conversationId || crypto.randomUUID() dispatch.chat.addUserMessage(text) dispatch.chat.set({ @@ -325,7 +335,7 @@ export default createModel()({ AbortController (Stop then targets only the newer turn, mixing two conversations). New Chat, an identity change, and deleting the open conversation all route through here. */ async newConversation() { - selection++ // a New Chat outranks any conversation open still in flight + nextGeneration() // a New Chat outranks any pick or sync still in flight await dispatch.chat.stop() dispatch.chat.clearConversation() }, @@ -354,25 +364,34 @@ export default createModel()({ outage as an agent outage, and the panel would say so on top of the global message. Network's `connect` event re-runs this (see useChatSync). */ if (state.ui.offline) return - dispatch.chat.set({ health: await agentHealth() }) + // Latest probe wins: a slow probe started while connectivity was failing must not land after + // the reconnect-triggered one and flip a fresh `ok` back to `unreachable` — which disabled the + // composer until the next reopen or network event, with the agent perfectly reachable. + const probe = ++healthProbe + const health = await agentHealth() + if (probe === healthProbe) dispatch.chat.set({ health }) }, /* The server owns the transcript now (D11) — adopt its copy when it knows more than we do, which is exactly how a background turn's result appears after a reopen. */ async syncTranscript(_: void, state) { const id = state.chat.conversationId if (!id || state.chat.streaming) return + // Reads the generation without advancing it: a sync is a background reconcile, not a user + // action, so it must not out-rank a pick already in flight — but any pick, New Chat, send + // or sign-out that happens while it waits makes ITS result the stale one. + const ticket = generation try { const remote = await fetchConversation(id) if (!remote) return // The fetch may have outlived the conversation: a New Chat or a history pick while it // was in flight leaves `state` describing a conversation no longer on screen, and // applying against that snapshot would land the OLD transcript in the new conversation - // under its newer conversationId (or repopulate one just cleared). Re-read the LIVE - // store, drop the response once the active id has moved on or a turn has started, and - // compare against what is actually current — the generation check logs.ts keys on - // requestId. + // under its newer conversationId (or repopulate one just cleared). And a turn that + // started and FINISHED meanwhile leaves `streaming` false again with the same id — only + // the generation sees that, and without it the stale server snapshot removed the newly + // completed turn from view. Re-read the LIVE store and compare against what is current. const current = store.getState().chat - if (current.conversationId !== id || current.streaming) return + if (ticket !== generation || current.conversationId !== id || current.streaming) return const messages = remote.messages.map(m => m.role === 'assistant' ? { role: 'assistant' as const, text: m.content, toolCalls: [] } @@ -419,12 +438,15 @@ export default createModel()({ /* The usage meter (docs/usage-limits.md D6) — refreshed on mount, after each turn, and on open. Silent on failure; the last-known meter stands. */ async loadUsage() { + const load = ++usageLoad const usage = await fetchUsage() - if (usage) dispatch.chat.set({ usage }) + if (usage && load === usageLoad) dispatch.chat.set({ usage }) // latest wins }, async loadConversations() { + const load = ++listLoad try { - dispatch.chat.set({ conversations: await listConversations() }) + const conversations = await listConversations() + if (load === listLoad) dispatch.chat.set({ conversations }) // latest wins } catch { /* offline — leave the last-known list */ } @@ -434,10 +456,10 @@ export default createModel()({ async openConversation(id: string, state) { if (state.chat.streaming) dispatch.chat.stop() // Out-of-order guard: pick A, then B, and A's fetch lands last — A must not replace B. Nor - // may a New Chat (which also takes a ticket) or a turn the user started meanwhile (the - // composer stays enabled) be clobbered by a load that is no longer wanted. - const ticket = ++selection - const superseded = () => ticket !== selection || store.getState().chat.streaming + // may a New Chat, a send, a sign-out (all of which advance the generation) or a turn still + // running meanwhile (the composer stays enabled) be clobbered by a load no longer wanted. + const ticket = nextGeneration() + const superseded = () => ticket !== generation || store.getState().chat.streaming let remote try { remote = await fetchConversation(id) @@ -501,6 +523,11 @@ export default createModel()({ purge-to-reload window and re-persist the pre-signout state. */ async signOut() { broadcastChatSignout() + // Aborting covers the STREAM; the generation covers every other load in flight. Without it a + // slow history pick started under this account passed its own guard after the reset (its + // ticket unchanged, nothing streaming) and wrote this account's transcript into the store the + // NEXT account boots from — persisted, and on the next account's screen if it landed late. + nextGeneration() abortController?.abort() abortController = null // Explicit sign-out ends the background relationship (plan D8): revoke the agent's stored diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 1aeb8f8e8..39b759c4a 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -321,8 +321,14 @@ async function refreshMcpDetailType(): Promise { } catch { /* offline or blocked — the last-known (or fallback) name stands */ } return mcpDetailType() } -// Warm the cache off the boot path so oidcGrantStale() compares against fresh truth early. -void refreshMcpDetailType() +// Warm the cache off the boot path so oidcGrantStale() compares against fresh truth early — and +// let the boot freshness check WAIT for it (oidcMcpDetailReady). Fire-and-forget alone had a hole +// on the first load after a rename: healGrant() ran before this resolved, compared against the +// cached (renamed-away) type, called the grant current, and the discovery that followed updated +// only the cache — nothing re-ran the heal, so agent authorization stayed broken until a reload. +// Bounded (the fetch times out) and never rejects, so awaiting it costs at most that bound once. +const mcpDetailReady: Promise = refreshMcpDetailType() +export const oidcMcpDetailReady = (): Promise => mcpDetailReady const declared = (): Array<{ resource: string; type: string; actions: string[]; actor?: string; locations?: string[] }> => [ { resource: OAUTH_PASSPORT_RESOURCE, type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, From 9c6388e8eef9626468d978f019970fd6ecaea694 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 14 Sep 2026 08:53:47 -0700 Subject: [PATCH 216/238] =?UTF-8?q?feat(platforms):=20platform=20catalogue?= =?UTF-8?q?=20from=20the=20API=20=E2=80=94=20desktop=20keeps=20only=20code?= =?UTF-8?q?=20(#1203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 6 + RELEASE.md | 23 ++ frontend/scripts/i18n-check.mjs | 4 +- frontend/src/components/AddDevice.tsx | 22 +- frontend/src/components/AddDownload.tsx | 11 +- .../DeviceSetupItem/DeviceSetupItem.tsx | 4 +- frontend/src/components/FilterDrawer.tsx | 22 +- frontend/src/components/PlatformIcon.tsx | 2 +- .../TargetPlatform/TargetPlatform.tsx | 3 +- frontend/src/constants.ts | 9 +- frontend/src/i18n/index.ts | 5 +- frontend/src/i18n/locales/de/app.json | 3 +- frontend/src/i18n/locales/de/platforms.json | 130 ++++++ frontend/src/i18n/locales/en/app.json | 3 +- frontend/src/i18n/locales/en/platforms.json | 130 ++++++ frontend/src/i18n/locales/es/app.json | 3 +- frontend/src/i18n/locales/es/platforms.json | 130 ++++++ frontend/src/i18n/locales/ja/app.json | 3 +- frontend/src/i18n/locales/ja/platforms.json | 130 ++++++ frontend/src/pages/AddPage.tsx | 5 +- frontend/src/platforms/README.md | 72 ++++ frontend/src/platforms/advantech/index.tsx | 7 - frontend/src/platforms/alpine/index.tsx | 7 - frontend/src/platforms/amnimo/index.tsx | 2 - .../platforms/android-screenview/index.tsx | 2 +- frontend/src/platforms/android/index.tsx | 8 - frontend/src/platforms/arm/index.tsx | 5 - frontend/src/platforms/aws/index.tsx | 7 - frontend/src/platforms/axis/index.tsx | 7 - frontend/src/platforms/azure/index.tsx | 7 - frontend/src/platforms/cachengo/index.tsx | 3 - .../src/platforms/catalogue.generated.json | 376 ++++++++++++++++++ frontend/src/platforms/catalogue.ts | 37 ++ .../src/platforms/docker-extension/index.tsx | 6 - .../src/platforms/docker-jumpbox/index.tsx | 12 - frontend/src/platforms/docker/index.tsx | 7 - .../src/platforms/embedded-works/index.tsx | 2 - frontend/src/platforms/firewalla/index.tsx | 7 - frontend/src/platforms/gcp/index.tsx | 7 - frontend/src/platforms/idy/index.tsx | 8 - frontend/src/platforms/index.ts | 80 +++- frontend/src/platforms/ios/index.tsx | 2 - frontend/src/platforms/linux/index.tsx | 10 +- frontend/src/platforms/liverock/index.tsx | 2 - frontend/src/platforms/mac/index.tsx | 7 +- frontend/src/platforms/nas/index.tsx | 7 - frontend/src/platforms/nvidia/index.tsx | 7 - frontend/src/platforms/openwrt/index.tsx | 7 - frontend/src/platforms/raspberrypi/index.tsx | 10 +- frontend/src/platforms/teltonika/index.tsx | 7 - frontend/src/platforms/text.ts | 38 ++ frontend/src/platforms/tinkerboard/index.tsx | 7 - frontend/src/platforms/toa/index.tsx | 2 - frontend/src/platforms/ubiquiti/index.tsx | 7 - frontend/src/platforms/ubuntu/index.tsx | 4 - frontend/src/platforms/unknown/index.tsx | 9 +- frontend/src/platforms/windows/index.tsx | 7 +- frontend/src/services/Notifications.ts | 4 +- package-lock.json | 1 + package.json | 8 +- scripts/platforms-generate.mjs | 279 +++++++++++++ 61 files changed, 1506 insertions(+), 236 deletions(-) create mode 100644 .gitattributes create mode 100644 frontend/src/i18n/locales/de/platforms.json create mode 100644 frontend/src/i18n/locales/en/platforms.json create mode 100644 frontend/src/i18n/locales/es/platforms.json create mode 100644 frontend/src/i18n/locales/ja/platforms.json create mode 100644 frontend/src/platforms/README.md create mode 100644 frontend/src/platforms/catalogue.generated.json create mode 100644 frontend/src/platforms/catalogue.ts create mode 100644 frontend/src/platforms/text.ts create mode 100644 scripts/platforms-generate.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..348950ce2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Vendored snapshot of the API's platform catalogue, written by scripts/platforms-generate.mjs. +# It is committed on purpose — the app reads it at module load, so generating it at build time +# would need API credentials in every build and would make builds non-reproducible. Marking it +# generated collapses it in review, where the meaningful diff is the migration it came from. +# JSON takes no comments, so this is the only place the marker can live. +frontend/src/platforms/catalogue.generated.json linguist-generated=true diff --git a/RELEASE.md b/RELEASE.md index b0ca45536..063ddecbd 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -29,6 +29,29 @@ A release marked **Pre-release** is only offered to users who have opted in That is the whole difference between a beta and a public release — a pre-release that is never promoted is invisible to everyone else. +## Before you bump: refresh the platform catalogue + +`frontend/src/platforms/catalogue.generated.json` is a committed snapshot of the API's platform +catalogue — the names, install commands and `/add` page copy that used to be hard-coded here. It +does not refresh itself, so a release can ship stale platform copy if the catalogue changed in the +database and nobody propagated it. + +```bash +npm run platforms:generate +``` + +No diff is the normal case and means the snapshot is current. A diff means the database moved +since the last regeneration: review it as a content change, and note that English copy changes +also need the translations in `frontend/src/i18n/locales/*/platforms.json` updated. Land it as its +own pull request rather than folding it into the version bump. + +The primary control is upstream of this — whoever edits the catalogue should regenerate and open +that pull request at the time — so this step is a backstop for when that did not happen. + +Until the platform catalogue is deployed to the stage the CLI points at, this reports that the +schema has no catalogue fields. That is expected, not a failure: skip the step and leave the +committed snapshot as it is. + ## 1. Bump the version From `main`, with everything merged and CI green: diff --git a/frontend/scripts/i18n-check.mjs b/frontend/scripts/i18n-check.mjs index d9d14eabd..d8e037703 100644 --- a/frontend/scripts/i18n-check.mjs +++ b/frontend/scripts/i18n-check.mjs @@ -4,7 +4,9 @@ // - a non-English catalog is missing a key that English has // - a non-English catalog has a key English no longer has (dead key) // - an English value is empty (extracted but no source text supplied) -// Run: npm run i18n:check (also used in CI) +// Run: npm run i18n:check (CI: .github/workflows/typecheck.yml) +// Does NOT detect a translation whose English changed after it was translated — dropped +// deliberately, see git log for scripts/translated-from.json. import { readFileSync, readdirSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/frontend/src/components/AddDevice.tsx b/frontend/src/components/AddDevice.tsx index 8d27ec197..250a1a185 100644 --- a/frontend/src/components/AddDevice.tsx +++ b/frontend/src/components/AddDevice.tsx @@ -1,5 +1,7 @@ import React from 'react' +import { OEM_GUIDE_LINK } from '../constants' import { IPlatform } from '../platforms' +import { usePlatformText } from '../platforms/text' import { useTranslation } from 'react-i18next' import { List, Typography } from '@mui/material' import { REGISTRATION_CODE_EXPIRATION_HOURS } from '../constants' @@ -27,6 +29,7 @@ export const AddDevice: React.FC = ({ platform, tags, serviceTypes, redir }) const { t } = useTranslation() const codeOnly = platform.installation?.command === '[CODE]' + const text = usePlatformText(platform) const codeBlock = ( @@ -36,7 +39,9 @@ export const AddDevice: React.FC = ({ platform, tags, serviceTypes, redir } code={registrationCode} link={redirectUrl} - label={platform.installation?.label} + label={ + platform.installation?.label ?? (codeOnly ? t('addDevice.registrationCode', 'Registration Code') : undefined) + } sx={{ textAlign: 'left' }} /> @@ -53,23 +58,26 @@ export const AddDevice: React.FC = ({ platform, tags, serviceTypes, redir <> - {platform.installation?.qualifier}, - {codeOnly ? <> copy the code below: : <> run this command on your device:} + {/* `description` is a complete sentence from the catalogue; the action line follows from + the platform kind and belongs here, not in the database (an MCP agent reads the same + row and has no "below"). */} + {text.description && <>{text.description} } + {codeOnly ? <>Copy the code below: : <>Run this command on your device:} {codeBlock} - {platform.installation?.instructions ? ( + {text.instructions ? ( <> - {platform.installation.instructions} {expiration} + {text.instructions} {expiration} ) : ( <> This page will automatically update when complete. {expiration} {platform.installation?.link && Instructions.} - {platform.installation?.altLink && ( + {platform.installation?.oemGuide && ( <> In production do not clone devices, please follow these - oem instructions. + oem instructions. )} diff --git a/frontend/src/components/AddDownload.tsx b/frontend/src/components/AddDownload.tsx index 63e03b72a..5c4a8cbfd 100644 --- a/frontend/src/components/AddDownload.tsx +++ b/frontend/src/components/AddDownload.tsx @@ -1,23 +1,26 @@ import React from 'react' +import { DEVICE_SETUP_PATH } from '../constants' import browser, { windowOpen } from '../services/browser' import { safeHostname } from '@common/nameHelper' import { useSelector } from 'react-redux' import { State } from '../store' import { Button, Typography } from '@mui/material' import { IPlatform } from '../platforms' +import { usePlatformText } from '../platforms/text' import { Link } from './Link' import { Icon } from './Icon' export const AddDownload: React.FC<{ platform: IPlatform }> = ({ platform }) => { + const text = usePlatformText(platform) const hostname = useSelector((state: State) => safeHostname(state.backend.environment.hostname, [])) const openDownloads = () => windowOpen(platform.installation?.link, '_blank', browser.isAndroid) return ( <> - {platform.installation?.qualifier} + {text.description} - {platform.installation?.instructions} + {text.instructions} - {platform.installation?.altLink && ( + {platform.installation?.addThisDevice && ( - or addthis device ({hostname}) + or addthis device ({hostname}) )} diff --git a/frontend/src/components/DeviceSetupItem/DeviceSetupItem.tsx b/frontend/src/components/DeviceSetupItem/DeviceSetupItem.tsx index e83ea51db..e4619e276 100644 --- a/frontend/src/components/DeviceSetupItem/DeviceSetupItem.tsx +++ b/frontend/src/components/DeviceSetupItem/DeviceSetupItem.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { GUIDE_START_DATE } from '../../constants' +import { DEVICE_SETUP_PATH, GUIDE_START_DATE } from '../../constants' import { useTranslation } from 'react-i18next' import browser, { getOs } from '../../services/browser' import { safeHostname } from '@common/nameHelper' @@ -72,7 +72,7 @@ export const DeviceSetupItem: React.FC = ({ className, onClick }) => { } } - let thisLink = '/devices/setup' + let thisLink = DEVICE_SETUP_PATH if (!browser.hasBackend) thisLink = `/add/${getOs()}` return ( diff --git a/frontend/src/components/FilterDrawer.tsx b/frontend/src/components/FilterDrawer.tsx index 96d00641d..19aaad803 100644 --- a/frontend/src/components/FilterDrawer.tsx +++ b/frontend/src/components/FilterDrawer.tsx @@ -70,13 +70,21 @@ export const FilterDrawer: React.FC = () => { const onOwner = value => update({ owner: value }) + const platformOptions = React.useMemo(() => { + const ids: Record = {} + for (const [id, name] of Object.entries(platforms.pageTypes)) (ids[name] ??= []).push(Number(id)) + return Object.entries(ids) + .map(([name, group]) => ({ value: group[0], group, name })) + .sort(byName) + }, []) + const onPlatform = value => { + const group = platformOptions.find(option => option.value === value)?.group ?? [value] let result = Array.isArray(state.platform) ? [...state.platform] : undefined - const index = result && result.indexOf(value) - if (index !== undefined && index >= 0) result?.splice(index, 1) - else if (value === -1) result = undefined - else result === undefined ? (result = [value]) : result.push(value) + if (value === -1) result = undefined + else if (result?.includes(value)) result = result.filter(v => !group.includes(v)) + else result = [...(result ?? []), ...group] if (!result?.length) result = undefined update({ platform: result }) @@ -133,11 +141,7 @@ export const FilterDrawer: React.FC = () => { icon="check" value={state.platform === undefined ? [-1] : state.platform} onSelect={onPlatform} - filterList={platformFilter.concat( - Object.keys(platforms.nameLookup) - .map(p => ({ value: parseInt(p), name: platforms.nameLookup[p] })) - .sort(byName) - )} + filterList={platformFilter.concat(platformOptions)} /> ), }, diff --git a/frontend/src/components/PlatformIcon.tsx b/frontend/src/components/PlatformIcon.tsx index 950082469..52d5a1195 100644 --- a/frontend/src/components/PlatformIcon.tsx +++ b/frontend/src/components/PlatformIcon.tsx @@ -12,5 +12,5 @@ type Props = React.SVGProps & { export const PlatformIcon: React.FC = ({ name, platform, ...originalProps }) => { const props = { ...originalProps, darkMode: useSelector((state: State) => state.ui.themeDark) } const Component = platform !== undefined ? platforms.componentByType(platform) : platforms.component(name) - return + return Component ? : null } diff --git a/frontend/src/components/TargetPlatform/TargetPlatform.tsx b/frontend/src/components/TargetPlatform/TargetPlatform.tsx index 93ae05f36..7a91a8b47 100644 --- a/frontend/src/components/TargetPlatform/TargetPlatform.tsx +++ b/frontend/src/components/TargetPlatform/TargetPlatform.tsx @@ -12,8 +12,7 @@ export const TargetPlatform: React.FC<{ inlineLeft?: boolean }> = ({ id = -1, size, tooltip, label, inlineLeft }) => { const icon = - const platform = platforms.type(id) - const typeName = platform.types?.[id] || platform.name + const typeName = platforms.name(id) if (tooltip) return ( diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 949cbea2a..92afefe2f 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,4 +1,5 @@ import brand from '@common/brand/config' +import { CATALOGUE } from './platforms/catalogue' const env = import.meta.env export const MODE = env.MODE || 'development' @@ -132,7 +133,13 @@ export const GOOGLE_TAG_MANAGER_IOS_KEY = env.VITE_GOOGLE_TAG_MANAGER_IOS_KEY export const CERTIFICATE_DOMAIN = 'at.remote.it' export const ANONYMOUS_MANUFACTURER_CODE = 34560 -export const SCREEN_VIEW_APP_LINK = 'https://play.google.com/store/apps/details?id=it.remote.screenview' +// The catalogue owns this URL (android.link); the fallback only covers a stale snapshot. +export const SCREEN_VIEW_APP_LINK = + CATALOGUE.installations.android?.link ?? 'https://play.google.com/store/apps/details?id=it.remote.screenview' + +// Client capabilities, not catalogue data — see platforms/README.md. +export const OEM_GUIDE_LINK = 'https://link.remote.it/docs/oem-overview' +export const DEVICE_SETUP_PATH = '/devices/setup' export const DEMO_SCRIPT_URL = 'https://raw.githubusercontent.com/remoteit/code_samples/refs/heads/main/scripts/linux/script_demo.sh' diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 67a91e7ac..b17c2eed5 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -3,6 +3,7 @@ import resourcesToBackend from 'i18next-resources-to-backend' import { initReactI18next } from 'react-i18next' import enApp from './locales/en/app.json' import enNotices from './locales/en/notices.json' +import enPlatforms from './locales/en/platforms.json' // Languages the app ships translations for. English is always the source/fallback. // Endonyms are shown untranslated in the language picker. @@ -13,7 +14,7 @@ export const SUPPORTED_LANGUAGES: { value: string; label: string }[] = [ { value: 'es', label: 'Español' }, ] -export const NAMESPACES = ['app', 'notices'] as const +export const NAMESPACES = ['app', 'notices', 'platforms'] as const export type LanguageMode = 'system' | 'en' | 'ja' | 'de' | 'es' @@ -58,7 +59,7 @@ i18n returnNull: false, partialBundledLanguages: true, resources: { - en: { app: enApp, notices: enNotices }, + en: { app: enApp, notices: enNotices, platforms: enPlatforms }, }, }) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index f4507c529..81f1330f3 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -50,7 +50,8 @@ "sendPromotionRequest": "Beförderungsanfrage senden" }, "addDevice": { - "codeExpiration": "Code läuft in {{hours}}\u00a0Stunden ab." + "codeExpiration": "Code läuft in {{hours}}\u00a0Stunden ab.", + "registrationCode": "Registrierungscode" }, "addFromNetwork": { "scanForServices": "Nach Diensten suchen", diff --git a/frontend/src/i18n/locales/de/platforms.json b/frontend/src/i18n/locales/de/platforms.json new file mode 100644 index 000000000..22804689a --- /dev/null +++ b/frontend/src/i18n/locales/de/platforms.json @@ -0,0 +1,130 @@ +{ + "advantech": { + "name": "Advantech", + "description": "Für Advantech-Systeme." + }, + "alpine": { + "name": "Alpine Linux", + "description": "Für Systeme auf Basis von Alpine Linux." + }, + "amnimo": { + "name": "Amnimo" + }, + "android": { + "name": "Android", + "description": "Für ein Android-Smartphone oder -Tablet." + }, + "arm": { + "name": "Arm Virtual Hardware", + "description": "Für die Arm Virtual Hardware-Plattform." + }, + "aws": { + "name": "AWS", + "description": "Für jede Linux-basierte virtuelle AWS-Maschine." + }, + "axis": { + "name": "AXIS", + "description": "Für AXIS-Kamerasysteme." + }, + "azure": { + "name": "Azure", + "description": "Für jede Linux-basierte virtuelle Maschine in der Azure Cloud." + }, + "cachengo": { + "name": "Cachengo" + }, + "docker": { + "name": "Docker", + "description": "Zum Testen auf jedem System, auf dem Docker läuft." + }, + "docker-extension": { + "name": "Docker Jumpbox-Erweiterung", + "description": "Für Docker Desktop." + }, + "docker-jumpbox": { + "name": "Docker Jumpbox", + "description": "Zum Testen auf jedem System, auf dem Docker läuft." + }, + "embedded-works": { + "name": "Embedded Works" + }, + "firewalla": { + "name": "Firewalla", + "description": "Für jedes Firewalla-System." + }, + "gcp": { + "name": "Google Cloud", + "description": "Für jede Linux-basierte Google Cloud-Instanz." + }, + "idy": { + "name": "IDY", + "description": "Für IDY-Router und -Gateways." + }, + "ios": { + "name": "iOS" + }, + "linux": { + "name": "Linux", + "description": "Für jedes Linux-basierte System." + }, + "liverock": { + "name": "Liverock Technologies" + }, + "mac": { + "name": "Mac", + "description": "Für macOS-Systeme.", + "instructions": "Installieren Sie die Desktop-App oder die CLI auf dem Mac, für den Sie den Remote-Zugriff aktivieren möchten." + }, + "nas": { + "name": "Synology", + "description": "Für Synology NAS-Systeme.", + "instructions": "Laden Sie die Paketdatei herunter und installieren Sie sie über die Weboberfläche Ihres NAS." + }, + "nvidia": { + "name": "NVIDIA Jetson", + "description": "Für NVIDIA Jetson-Systeme." + }, + "openwrt": { + "name": "OpenWrt", + "description": "Für OpenWrt-Router." + }, + "raspberrypi": { + "name": "Raspberry Pi", + "description": "Für jeden Raspberry Pi oder jedes Linux-basierte System." + }, + "remoteit": { + "name": "Remote.It" + }, + "teltonika": { + "name": "Teltonika", + "description": "Für Teltonika-Router und -Gateways." + }, + "this": { + "name": "Dieses System" + }, + "tinkerboard": { + "name": "Tinker Board", + "description": "Für das ASUS Tinker Board." + }, + "toa": { + "name": "TOA" + }, + "ubiquiti": { + "name": "Ubiquiti", + "description": "Für Ubiquiti-Router." + }, + "ubuntu": { + "name": "Ubuntu Desktop", + "description": "Für Ubuntu Desktop-Systeme." + }, + "unknown": { + "name": "Unbekannt", + "description": "Zum Registrieren eines beliebigen Geräts.", + "instructions": "Mit diesem eindeutigen Code kann sich jedes Gerät bei Ihrem Konto registrieren. Bewahren Sie ihn sicher auf." + }, + "windows": { + "name": "Windows", + "description": "Für Windows-Systeme.", + "instructions": "Installieren Sie die Desktop-App oder die CLI auf dem Windows-System, für das Sie den Remote-Zugriff aktivieren möchten." + } +} diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index a8283ce78..828806613 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -50,7 +50,8 @@ "sendPromotionRequest": "Send Promotion Request" }, "addDevice": { - "codeExpiration": "Code expires in {{hours}}\u00a0hours." + "codeExpiration": "Code expires in {{hours}}\u00a0hours.", + "registrationCode": "Registration Code" }, "addFromNetwork": { "scanForServices": "Scan for Services", diff --git a/frontend/src/i18n/locales/en/platforms.json b/frontend/src/i18n/locales/en/platforms.json new file mode 100644 index 000000000..8e54bb8b2 --- /dev/null +++ b/frontend/src/i18n/locales/en/platforms.json @@ -0,0 +1,130 @@ +{ + "advantech": { + "name": "Advantech", + "description": "For Advantech systems." + }, + "alpine": { + "name": "Alpine Linux", + "description": "For Alpine Linux based systems." + }, + "amnimo": { + "name": "Amnimo" + }, + "android": { + "name": "Android", + "description": "For an Android phone or tablet." + }, + "arm": { + "name": "Arm Virtual Hardware", + "description": "For the Arm Virtual Hardware platform." + }, + "aws": { + "name": "AWS", + "description": "For any Linux based AWS virtual machine." + }, + "axis": { + "name": "AXIS", + "description": "For AXIS camera systems." + }, + "azure": { + "name": "Azure", + "description": "For any Linux based Azure Cloud virtual machine." + }, + "cachengo": { + "name": "Cachengo" + }, + "docker": { + "name": "Docker", + "description": "For testing on any system running Docker." + }, + "docker-extension": { + "name": "Docker Jumpbox Extension", + "description": "For Docker Desktop." + }, + "docker-jumpbox": { + "name": "Docker Jumpbox", + "description": "For testing on any system running Docker." + }, + "embedded-works": { + "name": "Embedded Works" + }, + "firewalla": { + "name": "Firewalla", + "description": "For any Firewalla system." + }, + "gcp": { + "name": "Google Cloud", + "description": "For any Linux based Google Cloud instance." + }, + "idy": { + "name": "IDY", + "description": "For IDY routers and gateways." + }, + "ios": { + "name": "iOS" + }, + "linux": { + "name": "Linux", + "description": "For any Linux based system." + }, + "liverock": { + "name": "Liverock Technologies" + }, + "mac": { + "name": "Mac", + "description": "For macOS systems.", + "instructions": "Install the Desktop or CLI on the Mac you want to enable remote access to." + }, + "nas": { + "name": "Synology", + "description": "For Synology NAS systems.", + "instructions": "Download the package file and install it through your NAS web interface." + }, + "nvidia": { + "name": "NVIDIA Jetson", + "description": "For NVIDIA Jetson systems." + }, + "openwrt": { + "name": "OpenWrt", + "description": "For OpenWrt routers." + }, + "raspberrypi": { + "name": "Raspberry Pi", + "description": "For any Raspberry Pi or Linux based system." + }, + "remoteit": { + "name": "Remote.It" + }, + "teltonika": { + "name": "Teltonika", + "description": "For Teltonika routers and gateways." + }, + "this": { + "name": "This system" + }, + "tinkerboard": { + "name": "Tinker Board", + "description": "For the ASUS Tinker Board." + }, + "toa": { + "name": "TOA" + }, + "ubiquiti": { + "name": "Ubiquiti", + "description": "For Ubiquiti routers." + }, + "ubuntu": { + "name": "Ubuntu Desktop", + "description": "For Ubuntu Desktop systems." + }, + "unknown": { + "name": "Unknown", + "description": "For registering any device.", + "instructions": "This unique code allows any device to register with your account, keep it safe." + }, + "windows": { + "name": "Windows", + "description": "For Windows systems.", + "instructions": "Install the Desktop or CLI on the Windows system you want to enable remote access to." + } +} diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index df14eb6dd..ea99ea057 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -50,7 +50,8 @@ "sendPromotionRequest": "Enviar solicitud de promoción" }, "addDevice": { - "codeExpiration": "El código caduca en {{hours}}\u00a0horas." + "codeExpiration": "El código caduca en {{hours}}\u00a0horas.", + "registrationCode": "Código de registro" }, "addFromNetwork": { "scanForServices": "Buscar servicios", diff --git a/frontend/src/i18n/locales/es/platforms.json b/frontend/src/i18n/locales/es/platforms.json new file mode 100644 index 000000000..4dcad5c31 --- /dev/null +++ b/frontend/src/i18n/locales/es/platforms.json @@ -0,0 +1,130 @@ +{ + "advantech": { + "name": "Advantech", + "description": "Para sistemas Advantech." + }, + "alpine": { + "name": "Alpine Linux", + "description": "Para sistemas basados en Alpine Linux." + }, + "amnimo": { + "name": "Amnimo" + }, + "android": { + "name": "Android", + "description": "Para un teléfono o tableta Android." + }, + "arm": { + "name": "Arm Virtual Hardware", + "description": "Para la plataforma Arm Virtual Hardware." + }, + "aws": { + "name": "AWS", + "description": "Para cualquier máquina virtual de AWS basada en Linux." + }, + "axis": { + "name": "AXIS", + "description": "Para sistemas de cámaras AXIS." + }, + "azure": { + "name": "Azure", + "description": "Para cualquier máquina virtual de Azure Cloud basada en Linux." + }, + "cachengo": { + "name": "Cachengo" + }, + "docker": { + "name": "Docker", + "description": "Para pruebas en cualquier sistema que ejecute Docker." + }, + "docker-extension": { + "name": "Extensión Docker Jumpbox", + "description": "Para Docker Desktop." + }, + "docker-jumpbox": { + "name": "Docker Jumpbox", + "description": "Para pruebas en cualquier sistema que ejecute Docker." + }, + "embedded-works": { + "name": "Embedded Works" + }, + "firewalla": { + "name": "Firewalla", + "description": "Para cualquier sistema Firewalla." + }, + "gcp": { + "name": "Google Cloud", + "description": "Para cualquier instancia de Google Cloud basada en Linux." + }, + "idy": { + "name": "IDY", + "description": "Para routers y gateways IDY." + }, + "ios": { + "name": "iOS" + }, + "linux": { + "name": "Linux", + "description": "Para cualquier sistema basado en Linux." + }, + "liverock": { + "name": "Liverock Technologies" + }, + "mac": { + "name": "Mac", + "description": "Para sistemas macOS.", + "instructions": "Instala la aplicación de escritorio o la CLI en el Mac al que quieres habilitar el acceso remoto." + }, + "nas": { + "name": "Synology", + "description": "Para sistemas NAS de Synology.", + "instructions": "Descarga el archivo del paquete e instálalo desde la interfaz web de tu NAS." + }, + "nvidia": { + "name": "NVIDIA Jetson", + "description": "Para sistemas NVIDIA Jetson." + }, + "openwrt": { + "name": "OpenWrt", + "description": "Para routers OpenWrt." + }, + "raspberrypi": { + "name": "Raspberry Pi", + "description": "Para cualquier Raspberry Pi o sistema basado en Linux." + }, + "remoteit": { + "name": "Remote.It" + }, + "teltonika": { + "name": "Teltonika", + "description": "Para routers y gateways Teltonika." + }, + "this": { + "name": "Este sistema" + }, + "tinkerboard": { + "name": "Tinker Board", + "description": "Para la ASUS Tinker Board." + }, + "toa": { + "name": "TOA" + }, + "ubiquiti": { + "name": "Ubiquiti", + "description": "Para routers Ubiquiti." + }, + "ubuntu": { + "name": "Ubuntu Desktop", + "description": "Para sistemas Ubuntu Desktop." + }, + "unknown": { + "name": "Desconocido", + "description": "Para registrar cualquier dispositivo.", + "instructions": "Este código único permite que cualquier dispositivo se registre en tu cuenta. Guárdalo en un lugar seguro." + }, + "windows": { + "name": "Windows", + "description": "Para sistemas Windows.", + "instructions": "Instala la aplicación de escritorio o la CLI en el sistema Windows al que quieres habilitar el acceso remoto." + } +} diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 2146d74f6..42c25c5f8 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -50,7 +50,8 @@ "sendPromotionRequest": "昇格リクエストを送信" }, "addDevice": { - "codeExpiration": "コードは{{hours}}時間で失効します。" + "codeExpiration": "コードは{{hours}}時間で失効します。", + "registrationCode": "登録コード" }, "addFromNetwork": { "scanForServices": "サービスをスキャン", diff --git a/frontend/src/i18n/locales/ja/platforms.json b/frontend/src/i18n/locales/ja/platforms.json new file mode 100644 index 000000000..71ac41f1e --- /dev/null +++ b/frontend/src/i18n/locales/ja/platforms.json @@ -0,0 +1,130 @@ +{ + "advantech": { + "name": "Advantech", + "description": "Advantechシステム向けです。" + }, + "alpine": { + "name": "Alpine Linux", + "description": "Alpine Linuxベースのシステム向けです。" + }, + "amnimo": { + "name": "Amnimo" + }, + "android": { + "name": "Android", + "description": "Androidのスマートフォンまたはタブレット向けです。" + }, + "arm": { + "name": "Arm Virtual Hardware", + "description": "Arm Virtual Hardwareプラットフォーム向けです。" + }, + "aws": { + "name": "AWS", + "description": "LinuxベースのAWS仮想マシン向けです。" + }, + "axis": { + "name": "AXIS", + "description": "AXISカメラシステム向けです。" + }, + "azure": { + "name": "Azure", + "description": "LinuxベースのAzure Cloud仮想マシン向けです。" + }, + "cachengo": { + "name": "Cachengo" + }, + "docker": { + "name": "Docker", + "description": "Dockerを実行しているシステムでのテスト向けです。" + }, + "docker-extension": { + "name": "Docker Jumpbox拡張機能", + "description": "Docker Desktop向けです。" + }, + "docker-jumpbox": { + "name": "Docker Jumpbox", + "description": "Dockerを実行しているシステムでのテスト向けです。" + }, + "embedded-works": { + "name": "Embedded Works" + }, + "firewalla": { + "name": "Firewalla", + "description": "Firewallaシステム向けです。" + }, + "gcp": { + "name": "Google Cloud", + "description": "LinuxベースのGoogle Cloudインスタンス向けです。" + }, + "idy": { + "name": "IDY", + "description": "IDYのルーターおよびゲートウェイ向けです。" + }, + "ios": { + "name": "iOS" + }, + "linux": { + "name": "Linux", + "description": "Linuxベースのシステム向けです。" + }, + "liverock": { + "name": "Liverock Technologies" + }, + "mac": { + "name": "Mac", + "description": "macOSシステム向けです。", + "instructions": "リモートアクセスを有効にしたいMacに、デスクトップアプリまたはCLIをインストールしてください。" + }, + "nas": { + "name": "Synology", + "description": "Synology NASシステム向けです。", + "instructions": "パッケージファイルをダウンロードし、NASのウェブインターフェースからインストールしてください。" + }, + "nvidia": { + "name": "NVIDIA Jetson", + "description": "NVIDIA Jetsonシステム向けです。" + }, + "openwrt": { + "name": "OpenWrt", + "description": "OpenWrtルーター向けです。" + }, + "raspberrypi": { + "name": "Raspberry Pi", + "description": "Raspberry PiまたはLinuxベースのシステム向けです。" + }, + "remoteit": { + "name": "Remote.It" + }, + "teltonika": { + "name": "Teltonika", + "description": "Teltonikaのルーターおよびゲートウェイ向けです。" + }, + "this": { + "name": "このシステム" + }, + "tinkerboard": { + "name": "Tinker Board", + "description": "ASUS Tinker Board向けです。" + }, + "toa": { + "name": "TOA" + }, + "ubiquiti": { + "name": "Ubiquiti", + "description": "Ubiquitiルーター向けです。" + }, + "ubuntu": { + "name": "Ubuntu Desktop", + "description": "Ubuntu Desktopシステム向けです。" + }, + "unknown": { + "name": "不明", + "description": "任意のデバイスを登録するためのものです。", + "instructions": "この固有のコードを使うと、任意のデバイスをアカウントに登録できます。大切に保管してください。" + }, + "windows": { + "name": "Windows", + "description": "Windowsシステム向けです。", + "instructions": "リモートアクセスを有効にしたいWindowsシステムに、デスクトップアプリまたはCLIをインストールしてください。" + } +} diff --git a/frontend/src/pages/AddPage.tsx b/frontend/src/pages/AddPage.tsx index 1bc562b5a..1fff8e45f 100644 --- a/frontend/src/pages/AddPage.tsx +++ b/frontend/src/pages/AddPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { platformText } from '../platforms/text' import { useHistory } from 'react-router-dom' import { selectDevice } from '../selectors/devices' import { DEMO_DEVICE_CLAIM_CODE, DEMO_DEVICE_ID } from '../constants' @@ -119,7 +120,7 @@ export const AddPage: React.FC = () => { iconSize="xxl" icon={platform.id} to={`/add/${platform.id}`} - title={platform.name} + title={platformText(t, platform).name} subtitle={platform.subtitle} disableGutters /> @@ -156,7 +157,7 @@ export const AddPage: React.FC = () => { iconSize="xxl" icon={platform.id} to={platform.route || `/add/${platform.id}`} - title={<>{platform.listItemTitle || platform.name}} + title={<>{platform.listItemTitle || platformText(t, platform).name}} subtitle={platform.subtitle} disableGutters /> diff --git a/frontend/src/platforms/README.md b/frontend/src/platforms/README.md new file mode 100644 index 000000000..dd044d4ea --- /dev/null +++ b/frontend/src/platforms/README.md @@ -0,0 +1,72 @@ +# Platforms + +The platform catalogue — names, onboarding routes, install commands — lives in the graphql-api +database. Its design, and the rules a row implies, are documented in that repo: +`graphql-api/docs/PLATFORM-CATALOGUE.md`. This file covers only the desktop side. + +## The snapshot + +`catalogue.generated.json` is a **build-time snapshot** of the API's `platformTypes` + +`platformInstallations`, produced by `scripts/platforms-generate.mjs` and committed. The app reads +only that file — there is no runtime fetch — so a catalogue change reaches clients when the +snapshot is regenerated and shipped. It is committed rather than fetched at build time because +`catalogue.ts` imports it at module load: generating it during the build would need API +credentials in every build and would make the same commit produce different output depending on +the database. + +Regenerate with `npm run platforms:generate` (see RELEASE.md). It is deliberately **not** wired +into CI, for two reasons, of which the credential is the smaller: + +1. The snapshot is *meant* to lag the database until someone regenerates and ships, so "differs + from the API" is the normal state after any row edit, not a fault. As a per-PR gate it would + turn every open pull request red for a change none of them made. +2. Auth is enforced at the API gateway, not the resolver, so dropping `@Authorized()` would not + help — a public catalogue would need a new unauthenticated route. The Bearer path takes only + short-lived JWTs, so no static CI secret can satisfy it either. If this is ever automated, + `Authorization: Signature` (access key) is the mechanism that needs no new surface. + +## What a local platform file owns + +A `platforms//index.tsx` registers its **id and its code** — component, override, +`listItemTitle`, JSX instructions, and the client-capability flags. Everything that is data comes +from the catalogue. + +Any **defined** field a local file sets wins over the catalogue, so a hot-fix in a local file +takes effect; an undefined one (a capability flag that is off on this OS) falls through to the +catalogue value. A route the catalogue has no row for — the hidden `android-screenview` deep link +— supplies all of its own data. + +## Catalogue data vs client capability + +The catalogue holds facts about the **platform**; the desktop holds facts about the **client**. +`installation.link` is catalogue data: where the platform is installed from, which genuinely +differs per row. `oemGuide` and `addThisDevice` are client capabilities — the OEM provisioning +guide is one URL for every platform that shows it (a fact about remote.it), and registering the +machine the app is running on is a capability of the running client, gated on the OS. Both +resolve to constants (`OEM_GUIDE_LINK`, `DEVICE_SETUP_PATH`) rather than per-platform values. + +Picker membership and order also stay here: whether *this* client offers a platform, in which +section and in what order, is a client fact. Enumerating the catalogue on `/add` was built and +reverted — it surfaced routes that are not meant to appear, and lost the curated order. + +## Type lookups + +Three maps, and picking the wrong one is the usual bug: + +- `lookup` — type id → route slug, for types that have an `/add` page. +- `nameLookup` — type id → label, for **every** catalogue type, so a legacy device with no page + still resolves to a real name instead of "Unknown". +- `pageTypes` — the subset of `nameLookup` that has a page. This is what a picker a user chooses + from should list; `nameLookup` carries ids no page onboards and labels that repeat. + +`platforms.name(type)` is the display name for a device of that type. It prefers the type's own +label over the page name, because a page covers several types: type 10 is "Windows Server", not +"Windows", and 1120 is "Debian Linux", not "Linux". + +## Translations + +Platform copy is English in the database and translated in the `platforms` namespace, keyed by +route slug. The keys are built at render time, so i18next-parser cannot extract them — +`scripts/platforms-generate.mjs` maintains those catalogs instead, the same arrangement the parser +config documents for the `columns.` labels. The catalogue string is always the inline default, +so a platform whose row has not been through the generator still renders its English. diff --git a/frontend/src/platforms/advantech/index.tsx b/frontend/src/platforms/advantech/index.tsx index f7d46fe30..ac0ee15a7 100644 --- a/frontend/src/platforms/advantech/index.tsx +++ b/frontend/src/platforms/advantech/index.tsx @@ -8,12 +8,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'advantech', - name: 'Advantech', component: Component, - types: { 1206: 'Advantech' }, - installation: { - command: true, - qualifier: 'For Advantech systems', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/alpine/index.tsx b/frontend/src/platforms/alpine/index.tsx index b93540076..0c641f729 100644 --- a/frontend/src/platforms/alpine/index.tsx +++ b/frontend/src/platforms/alpine/index.tsx @@ -18,12 +18,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'alpine', - name: 'Alpine Linux', component: Component, - types: { 1122: 'Alpine Linux' }, - installation: { - command: true, - qualifier: 'For Alpine Linux based systems', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/amnimo/index.tsx b/frontend/src/platforms/amnimo/index.tsx index b76020a04..ff213aad0 100644 --- a/frontend/src/platforms/amnimo/index.tsx +++ b/frontend/src/platforms/amnimo/index.tsx @@ -20,7 +20,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'amnimo', - name: 'Amnimo', component: Component, - types: { 1063: 'Amnimo' }, }) diff --git a/frontend/src/platforms/android-screenview/index.tsx b/frontend/src/platforms/android-screenview/index.tsx index 68812e6ba..472998660 100644 --- a/frontend/src/platforms/android-screenview/index.tsx +++ b/frontend/src/platforms/android-screenview/index.tsx @@ -23,6 +23,6 @@ platforms.register({ installation: { label: 'Registration Code', command: '[CODE]', - qualifier: 'For Android ScreenView', + description: 'For the Android ScreenView app.', }, }) diff --git a/frontend/src/platforms/android/index.tsx b/frontend/src/platforms/android/index.tsx index 2894e95aa..a0b6414a5 100644 --- a/frontend/src/platforms/android/index.tsx +++ b/frontend/src/platforms/android/index.tsx @@ -1,6 +1,5 @@ import React from 'react' import feature from './feature.png' -import { SCREEN_VIEW_APP_LINK } from '../../constants' import { Tooltip, Typography } from '@mui/material' import { platforms } from '..' import { Icon } from '../../components/Icon' @@ -21,10 +20,7 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'android', - name: 'Android', component: Component, - types: { 1213: 'Android Phone' }, - services: [{ application: 48 }], listItemTitle: ( <> Android   @@ -39,9 +35,6 @@ platforms.register({ ), installation: { - download: true, - command: '[CODE]', - qualifier: 'To register an Android phone or tablet', instructions: ( <> @@ -50,6 +43,5 @@ platforms.register({ services ), - link: SCREEN_VIEW_APP_LINK, }, }) diff --git a/frontend/src/platforms/arm/index.tsx b/frontend/src/platforms/arm/index.tsx index 14a86b354..ddf2de40b 100644 --- a/frontend/src/platforms/arm/index.tsx +++ b/frontend/src/platforms/arm/index.tsx @@ -9,13 +9,8 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'arm', - name: 'Arm Virtual Hardware', component: Component, - types: { 1217: 'AVH' }, installation: { - command: true, - qualifier: 'For the Arm Virtual Hardware platform', - link: 'https://link.remote.it/avh', instructions: ( <> Arm Virtual Hardware (AVH) requires an Arm account. diff --git a/frontend/src/platforms/aws/index.tsx b/frontend/src/platforms/aws/index.tsx index 7fea08ac0..69050f30a 100644 --- a/frontend/src/platforms/aws/index.tsx +++ b/frontend/src/platforms/aws/index.tsx @@ -25,12 +25,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'aws', - name: 'AWS', component: Component, - types: { 1185: 'AWS' }, - installation: { - command: true, - qualifier: 'For any Linux based AWS virtual machine', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/axis/index.tsx b/frontend/src/platforms/axis/index.tsx index 30c96bbc9..de7de0676 100644 --- a/frontend/src/platforms/axis/index.tsx +++ b/frontend/src/platforms/axis/index.tsx @@ -15,12 +15,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'axis', - name: 'AXIS', component: Component, - types: { 1209: 'AXIS' }, - installation: { - command: true, - qualifier: 'For AXIS camera systems', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/azure/index.tsx b/frontend/src/platforms/azure/index.tsx index 73b8f1f06..e26a944a7 100644 --- a/frontend/src/platforms/azure/index.tsx +++ b/frontend/src/platforms/azure/index.tsx @@ -43,12 +43,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'azure', - name: 'Azure', component: Component, - types: { 1186: 'Azure' }, - installation: { - command: true, - qualifier: 'For any Linux based Azure Cloud virtual machine', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/cachengo/index.tsx b/frontend/src/platforms/cachengo/index.tsx index d263ae1b8..b3c2d0f04 100644 --- a/frontend/src/platforms/cachengo/index.tsx +++ b/frontend/src/platforms/cachengo/index.tsx @@ -9,9 +9,6 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'cachengo', - name: 'Cachengo', component: Component, - types: { 1227: 'Rent-A-Node', 1224: 'Cachengo' }, - installation: { command: true }, override: RentANodeRequest, }) diff --git a/frontend/src/platforms/catalogue.generated.json b/frontend/src/platforms/catalogue.generated.json new file mode 100644 index 000000000..ea6292e39 --- /dev/null +++ b/frontend/src/platforms/catalogue.generated.json @@ -0,0 +1,376 @@ +{ + "types": { + "4": "Windows", + "5": "Windows Desktop", + "10": "Windows Server", + "256": "Mac", + "407": "Lorex DVR 1", + "408": "Lorex DVR 2", + "768": "Generic Unix", + "769": "Linux", + "1024": "Drobo Box", + "1025": "Lorex Zavio Web", + "1026": "Lorex Zavio Streamer", + "1030": "Astak Mole", + "1033": "Pixord", + "1040": "Lorex DVR 1", + "1041": "Lorex NVR", + "1042": "Lorex DVR 2", + "1043": "Cisco NAS 1", + "1053": "Cisco NAS 2", + "1054": "Cisco NAS 3", + "1056": "Stem", + "1057": "Philips M100", + "1058": "Philips B100", + "1059": "Philips B120", + "1060": "Astak Mini Mole", + "1062": "Foscam", + "1063": "Amnimo", + "1072": "Raspberry Pi", + "1075": "Remote.It Pi", + "1076": "Remote.It Pi Lite", + "1077": "Remote.It Pi 64", + "1120": "Debian Linux", + "1121": "RedHat Linux", + "1122": "Alpine Linux", + "1185": "AWS", + "1186": "Azure", + "1187": "Google Cloud", + "1200": "Linux ARM", + "1201": "NVIDIA Jetson", + "1202": "x86 Generic Linux", + "1204": "Netcom NWL25", + "1205": "OpenWrt", + "1206": "Advantech", + "1207": "Furukawa", + "1208": "Dragino", + "1209": "AXIS", + "1210": "Synology", + "1211": "TEKTELIC", + "1212": "NETGEAR", + "1213": "Android Phone", + "1214": "iPhone", + "1215": "ASUS Tinker Board", + "1216": "Firewalla", + "1217": "AVH", + "1218": "Ubiquiti Router", + "1219": "Docker Container", + "1220": "Docker Extension", + "1221": "Docker Jumpbox", + "1222": "Demo Device", + "1223": "Embedded Works", + "1224": "Cachengo", + "1225": "IDY", + "1226": "Liverock Technologies", + "1227": "Rent-A-Node", + "1228": "TOA", + "1280": "Unknown", + "1281": "Teltonika", + "9999": "Legacy Camera", + "65535": "Unknown", + "65536": "Any" + }, + "routes": { + "1120": ["linux", "ubuntu"] + }, + "installations": { + "advantech": { + "name": "Advantech", + "kind": "command", + "description": "For Advantech systems.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1206": "Advantech" + } + }, + "alpine": { + "name": "Alpine Linux", + "kind": "command", + "description": "For Alpine Linux based systems.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1122": "Alpine Linux" + } + }, + "amnimo": { + "name": "Amnimo", + "kind": "info", + "types": { + "1063": "Amnimo" + } + }, + "android": { + "name": "Android", + "kind": "code", + "description": "For an Android phone or tablet.", + "link": "https://play.google.com/store/apps/details?id=it.remote.screenview", + "services": [ + { + "application": 48 + } + ], + "types": { + "1213": "Android Phone" + } + }, + "arm": { + "name": "Arm Virtual Hardware", + "kind": "command", + "description": "For the Arm Virtual Hardware platform.", + "link": "https://link.remote.it/avh", + "types": { + "1217": "AVH" + } + }, + "aws": { + "name": "AWS", + "kind": "command", + "description": "For any Linux based AWS virtual machine.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1185": "AWS" + } + }, + "axis": { + "name": "AXIS", + "kind": "command", + "description": "For AXIS camera systems.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1209": "AXIS" + } + }, + "azure": { + "name": "Azure", + "kind": "command", + "description": "For any Linux based Azure Cloud virtual machine.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1186": "Azure" + } + }, + "cachengo": { + "name": "Cachengo", + "kind": "command", + "types": { + "1224": "Cachengo", + "1227": "Rent-A-Node" + } + }, + "docker": { + "name": "Docker", + "kind": "command", + "commandTemplate": "docker run -d -e R3_REGISTRATION_CODE=\"[CODE]\" --restart unless-stopped --pull always remoteit/remoteit-agent:latest", + "description": "For testing on any system running Docker.", + "link": "https://hub.docker.com/r/remoteit/remoteit-agent", + "services": [], + "types": { + "1219": "Docker Container" + } + }, + "docker-extension": { + "name": "Docker Jumpbox Extension", + "kind": "code", + "description": "For Docker Desktop.", + "services": [], + "types": { + "1220": "Docker Extension" + } + }, + "docker-jumpbox": { + "name": "Docker Jumpbox", + "kind": "command", + "commandTemplate": "docker run -d -e R3_REGISTRATION_CODE=\"[CODE]\" -v /var/run/docker.sock:/var/run/docker.sock --restart unless-stopped --name remoteit_docker_jumpbox --pull always remoteit/docker-extension:latest", + "description": "For testing on any system running Docker.", + "link": "https://hub.docker.com/r/remoteit/docker-extension", + "services": [], + "types": { + "1221": "Docker Jumpbox" + } + }, + "embedded-works": { + "name": "Embedded Works", + "kind": "info", + "types": { + "1223": "Embedded Works" + } + }, + "firewalla": { + "name": "Firewalla", + "kind": "command", + "description": "For any Firewalla system.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1216": "Firewalla" + } + }, + "gcp": { + "name": "Google Cloud", + "kind": "command", + "description": "For any Linux based Google Cloud instance.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1187": "Google Cloud" + } + }, + "idy": { + "name": "IDY", + "kind": "command", + "commandTemplate": "config net-remoteit-agent=enable; config net-remoteit-registration_code=[CODE]; /etc/init.d/remoteit-refresh start", + "description": "For IDY routers and gateways.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1225": "IDY" + } + }, + "ios": { + "name": "iOS", + "kind": "info", + "types": { + "1214": "iPhone" + } + }, + "linux": { + "name": "Linux", + "kind": "command", + "description": "For any Linux based system.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "769": "Linux", + "1120": "Debian Linux", + "1121": "RedHat Linux", + "1200": "Linux ARM" + } + }, + "liverock": { + "name": "Liverock Technologies", + "kind": "info", + "types": { + "1226": "Liverock Technologies" + } + }, + "mac": { + "name": "Mac", + "kind": "download", + "description": "For macOS systems.", + "instructions": "Install the Desktop or CLI on the Mac you want to enable remote access to.", + "link": "https://link.remote.it/download/desktop", + "types": { + "256": "Mac" + } + }, + "nas": { + "name": "Synology", + "kind": "download", + "description": "For Synology NAS systems.", + "instructions": "Download the package file and install it through your NAS web interface.", + "link": "https://link.remote.it/getting-started/synology", + "types": { + "1210": "Synology" + } + }, + "nvidia": { + "name": "NVIDIA Jetson", + "kind": "command", + "description": "For NVIDIA Jetson systems.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1201": "NVIDIA Jetson" + } + }, + "openwrt": { + "name": "OpenWrt", + "kind": "command", + "description": "For OpenWrt routers.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1205": "OpenWrt" + } + }, + "raspberrypi": { + "name": "Raspberry Pi", + "kind": "command", + "description": "For any Raspberry Pi or Linux based system.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1072": "Raspberry Pi", + "1075": "Remote.It Pi", + "1076": "Remote.It Pi Lite", + "1077": "Remote.It Pi 64" + } + }, + "remoteit": { + "name": "Remote.It", + "kind": "info", + "types": {} + }, + "teltonika": { + "name": "Teltonika", + "kind": "command", + "description": "For Teltonika routers and gateways.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1281": "Teltonika" + } + }, + "this": { + "name": "This system", + "kind": "info", + "types": {} + }, + "tinkerboard": { + "name": "Tinker Board", + "kind": "command", + "description": "For the ASUS Tinker Board.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1215": "ASUS Tinker Board" + } + }, + "toa": { + "name": "TOA", + "kind": "info", + "types": { + "1228": "TOA" + } + }, + "ubiquiti": { + "name": "Ubiquiti", + "kind": "command", + "description": "For Ubiquiti routers.", + "link": "https://link.remote.it/support/streamline-install", + "types": { + "1218": "Ubiquiti Router" + } + }, + "ubuntu": { + "name": "Ubuntu Desktop", + "kind": "download", + "description": "For Ubuntu Desktop systems.", + "link": "https://link.remote.it/download/desktop", + "types": { + "1120": "Debian Linux" + } + }, + "unknown": { + "name": "Unknown", + "kind": "code", + "description": "For registering any device.", + "instructions": "This unique code allows any device to register with your account, keep it safe.", + "types": { + "65535": "Unknown" + } + }, + "windows": { + "name": "Windows", + "kind": "download", + "description": "For Windows systems.", + "instructions": "Install the Desktop or CLI on the Windows system you want to enable remote access to.", + "link": "https://link.remote.it/download/desktop", + "types": { + "5": "Windows Desktop", + "10": "Windows Server" + } + } + } +} diff --git a/frontend/src/platforms/catalogue.ts b/frontend/src/platforms/catalogue.ts new file mode 100644 index 000000000..8b8d250b8 --- /dev/null +++ b/frontend/src/platforms/catalogue.ts @@ -0,0 +1,37 @@ +// The platform catalogue: a build-time snapshot of the API's platformTypes / platformInstallations +// (graphql-api docs/PLATFORM-CATALOGUE.md), regenerated by scripts/platforms-generate.mjs and +// committed. The app reads only this file — there is no runtime fetch — so a catalogue change +// reaches clients when the snapshot is regenerated and shipped. The desktop keeps only what is +// code (logo components, one override, a few JSX blocks) in platforms//index.tsx. +import raw from './catalogue.generated.json' + +export type CatalogueKind = 'command' | 'code' | 'download' | 'info' + +export interface CatalogueInstallation { + name: string + kind: CatalogueKind + // Present only on the few rows the API renders with their own template (Docker, IDY). The + // registry substitutes [CODE] client-side for these — identical to the API's output once a + // stage renders server-side — so the command is right regardless of deploy order. + commandTemplate?: string + // A complete sentence saying who the page is for. The client adds its own action line. + description?: string + instructions?: string + // Where the platform is installed from (download, app store, docs article). + link?: string + // [] = no default service (Docker); absent = unspecified, the UI's default applies. + services?: IServiceRegistration[] + // Platform type ids this page onboards, with their labels. + types: Record +} + +export interface Catalogue { + // Platform type id to the name to show for it (the API's `label`: displayName, else name). + types: Record + // Types that more than one page onboards, default first (the API's sortOrder). A type absent + // here has exactly one page: the one whose `types` lists it. + routes: Record + installations: Record +} + +export const CATALOGUE = raw as Catalogue diff --git a/frontend/src/platforms/docker-extension/index.tsx b/frontend/src/platforms/docker-extension/index.tsx index 63afae89e..71ff2b75a 100644 --- a/frontend/src/platforms/docker-extension/index.tsx +++ b/frontend/src/platforms/docker-extension/index.tsx @@ -9,13 +9,8 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'docker-extension', - name: 'Docker Jumpbox Extension', component: Component, - types: { 1220: 'Docker Extension' }, - services: [], installation: { - label: 'Registration Code', - command: '[CODE]', instructions: ( <> For more information please download Docker Desktop and install our extension or @@ -25,6 +20,5 @@ platforms.register({ if it's already installed. ), - qualifier: 'For docker desktop', }, }) diff --git a/frontend/src/platforms/docker-jumpbox/index.tsx b/frontend/src/platforms/docker-jumpbox/index.tsx index d40241e52..7cac06780 100644 --- a/frontend/src/platforms/docker-jumpbox/index.tsx +++ b/frontend/src/platforms/docker-jumpbox/index.tsx @@ -9,25 +9,13 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'docker-jumpbox', - name: 'Docker Jumpbox', component: Component, - types: { 1221: 'Docker Jumpbox' }, - services: [], installation: { - command: `docker run -d \\ - -e R3_REGISTRATION_CODE="[CODE]" \\ - -v /var/run/docker.sock:/var/run/docker.sock \\ - --restart unless-stopped \\ - --name remoteit_docker_jumpbox \\ - --pull always \\ - remoteit/docker-extension:latest`, instructions: ( <> For production settings please visit our Docker Hub page. ), - qualifier: 'For testing on any system running Docker', - link: 'https://hub.docker.com/r/remoteit/docker-extension', }, }) diff --git a/frontend/src/platforms/docker/index.tsx b/frontend/src/platforms/docker/index.tsx index 4d65a480d..5241a6d8b 100644 --- a/frontend/src/platforms/docker/index.tsx +++ b/frontend/src/platforms/docker/index.tsx @@ -9,20 +9,13 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'docker', - name: 'Docker', component: Component, - types: { 1219: 'Docker Container' }, - services: [], installation: { - command: - 'docker run -d -e R3_REGISTRATION_CODE="[CODE]" --restart unless-stopped --pull always remoteit/remoteit-agent:latest', instructions: ( <> For production settings please visit our Docker Hub page. ), - qualifier: 'For testing on any system running Docker', - link: 'https://hub.docker.com/r/remoteit/remoteit-agent', }, }) diff --git a/frontend/src/platforms/embedded-works/index.tsx b/frontend/src/platforms/embedded-works/index.tsx index 747a34d76..262458049 100644 --- a/frontend/src/platforms/embedded-works/index.tsx +++ b/frontend/src/platforms/embedded-works/index.tsx @@ -8,7 +8,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'embedded-works', - name: 'Embedded Works', component: Component, - types: { 1223: 'Embedded Works' }, }) diff --git a/frontend/src/platforms/firewalla/index.tsx b/frontend/src/platforms/firewalla/index.tsx index 4a123017c..9a023c743 100644 --- a/frontend/src/platforms/firewalla/index.tsx +++ b/frontend/src/platforms/firewalla/index.tsx @@ -8,12 +8,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'firewalla', - name: 'Firewalla', component: Component, - types: { 1216: 'Firewalla' }, - installation: { - command: true, - qualifier: 'For any Firewalla system', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/gcp/index.tsx b/frontend/src/platforms/gcp/index.tsx index b9d31d570..86851bfa7 100644 --- a/frontend/src/platforms/gcp/index.tsx +++ b/frontend/src/platforms/gcp/index.tsx @@ -30,12 +30,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'gcp', - name: 'Google Cloud', component: Component, - types: { 1187: 'Google Cloud' }, - installation: { - command: true, - qualifier: 'For any Linux based Google Cloud instance', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/idy/index.tsx b/frontend/src/platforms/idy/index.tsx index 5d4353fbe..5d1266754 100644 --- a/frontend/src/platforms/idy/index.tsx +++ b/frontend/src/platforms/idy/index.tsx @@ -45,13 +45,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'idy', - name: 'IDY', component: Component, - types: { 1225: 'IDY' }, - installation: { - command: - 'config net-remoteit-agent=enable; config net-remoteit-registration_code=[CODE]; /etc/init.d/remoteit-refresh start', - qualifier: 'For IDY routers and gateways', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/index.ts b/frontend/src/platforms/index.ts index 56f53a53f..b98189c8b 100644 --- a/frontend/src/platforms/index.ts +++ b/frontend/src/platforms/index.ts @@ -1,9 +1,11 @@ +import { CATALOGUE, CatalogueInstallation } from './catalogue' + export interface IPlatform { id: string name: string hidden?: boolean subtitle?: string - component: (props: any) => React.ReactElement + component?: (props: any) => React.ReactElement types?: INumberLookup services?: IServiceRegistration[] listItemTitle?: React.ReactNode @@ -15,12 +17,16 @@ export interface IPlatform { download?: boolean command?: boolean | string instructions?: string | React.ReactNode - qualifier?: string + description?: string link?: string - altLink?: string + oemGuide?: boolean + addThisDevice?: boolean } } +// What a platforms//index.tsx registers; see ./README.md. +export type IPlatformLocal = Partial & Pick + export interface IPlatformOverrideProps { platform: IPlatform serviceTypes: number[] @@ -28,6 +34,10 @@ export interface IPlatformOverrideProps { oneTimeUse?: boolean } +function defined(value?: T): Partial { + return value ? (Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial) : {} +} + class Platforms { platforms: ILookup = {} lookup: INumberLookup = {} @@ -70,28 +80,84 @@ class Platforms { ] constructor() { + this.seedFromCatalogue() this.initialize() } + private seedFromCatalogue() { + for (const [typeId, label] of Object.entries(CATALOGUE.types)) { + this.nameLookup[Number(typeId)] = label + } + for (const id of Object.keys(CATALOGUE.installations)) { + if (!this.installed.includes(id)) this.register({ id }) + } + } + async initialize() { for (const platform of this.installed) { await import(`./${platform}/index.tsx`) } } - register(platform: IPlatform) { + private fromCatalogue(data: CatalogueInstallation): Pick { + const types: INumberLookup = {} + for (const [typeId, label] of Object.entries(data.types)) types[Number(typeId)] = label + const installation: NonNullable = { + // '[CODE]' and a template are substituted client-side; `true` shows the API's command. + command: data.kind === 'command' ? data.commandTemplate ?? true : data.kind === 'code' ? '[CODE]' : undefined, + // A code row WITH a link is a download too: install the app, the code is the fallback. + download: data.kind === 'download' || (data.kind === 'code' && !!data.link) || undefined, + description: data.description, + instructions: data.instructions, + link: data.link, + } + const hasInstallation = Object.values(installation).some(value => value !== undefined) + return { name: data.name, types, services: data.services, installation: hasInstallation ? installation : undefined } + } + + register(local: IPlatformLocal) { + const data = CATALOGUE.installations[local.id] + const base: IPlatform = { name: local.id, ...local } + if (!data && !local.hidden && !local.types && import.meta.env?.DEV) { + console.warn( + `platforms: "${local.id}" has no catalogue row and supplies no types — regenerate the snapshot (npm run platforms:generate)` + ) + } + const catalogue = data && this.fromCatalogue(data) + const platform: IPlatform = catalogue + ? { + ...base, + ...defined(catalogue), + ...defined(local), + installation: { ...catalogue.installation, ...defined(local.installation) }, + } + : base platform.types = platform.types || {} platform.hasScreenView = platform.services?.some(s => s.application === 48) this.platforms[platform.id] = platform Object.keys(platform.types).forEach(type => { if (platform.hidden) return + // Several pages can onboard one type; its devices render as the default, not the last one. + const routes = CATALOGUE.routes[type] + if (routes && routes[0] !== platform.id) return this.lookup[type] = platform.id this.nameLookup[type] = platform.types?.[type] }) } + // For a picker a user chooses from; nameLookup is wider. See ./README.md. + get pageTypes(): INumberLookup { + return Object.fromEntries(Object.keys(this.lookup).map(type => [type, this.nameLookup[type]])) + } + type(type: number): IPlatform { - return this.get(this.lookup[type] || 'unknown') + return this.get(this.lookup[type]) + } + + // A page covers several types, so the type's own label beats the page name: 10 is + // "Windows Server", not "Windows". A type with no page still has a label. + name(type: number): string { + return this.nameLookup[type] || this.type(type).name } get(id: string = 'unknown'): IPlatform { @@ -104,11 +170,11 @@ class Platforms { } component(id?: string): IPlatform['component'] { - return this.get(id).component || (() => null) + return this.get(id).component ?? this.get('unknown').component } componentByType(type: number): IPlatform['component'] { - return this.component(this.type(type).id) + return this.component(this.lookup[type]) } } diff --git a/frontend/src/platforms/ios/index.tsx b/frontend/src/platforms/ios/index.tsx index 2d792641b..da54c5d4f 100644 --- a/frontend/src/platforms/ios/index.tsx +++ b/frontend/src/platforms/ios/index.tsx @@ -39,7 +39,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'ios', - name: 'iOS', component: Component, - types: { 1214: 'iPhone' }, }) diff --git a/frontend/src/platforms/linux/index.tsx b/frontend/src/platforms/linux/index.tsx index 1f7176cdf..cc54f1401 100644 --- a/frontend/src/platforms/linux/index.tsx +++ b/frontend/src/platforms/linux/index.tsx @@ -55,13 +55,7 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'linux', - name: 'Linux', component: Component, - types: { 769: 'Linux', 1121: 'RedHat Linux', 1200: 'Linux ARM' }, - installation: { - command: true, - qualifier: 'For any Linux based system', - link: 'https://link.remote.it/support/streamline-install', - altLink: 'https://link.remote.it/docs/oem-overview', - }, + // Cloning an installed image duplicates the device identity — OEM guidance covers it. + installation: { oemGuide: true }, }) diff --git a/frontend/src/platforms/liverock/index.tsx b/frontend/src/platforms/liverock/index.tsx index 2ea863be1..d575d5969 100644 --- a/frontend/src/platforms/liverock/index.tsx +++ b/frontend/src/platforms/liverock/index.tsx @@ -17,7 +17,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'liverock', - name: 'Liverock Technologies', component: Component, - types: { 1226: 'Liverock Technologies' }, }) diff --git a/frontend/src/platforms/mac/index.tsx b/frontend/src/platforms/mac/index.tsx index c493a7573..e9f54b8ba 100644 --- a/frontend/src/platforms/mac/index.tsx +++ b/frontend/src/platforms/mac/index.tsx @@ -14,13 +14,8 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'mac', - name: 'Mac', component: Component, - types: { 256: 'Mac' }, installation: { - qualifier: 'Macintosh installation', - instructions: 'Install the Desktop or CLI on the Mac to you want to enable remote access to.', - link: 'https://link.remote.it/download/desktop', - altLink: browser.isMac && browser.isElectron ? '/devices/setup' : undefined, + addThisDevice: browser.isMac && browser.isElectron, }, }) diff --git a/frontend/src/platforms/nas/index.tsx b/frontend/src/platforms/nas/index.tsx index 6b2a3bf8f..029093937 100644 --- a/frontend/src/platforms/nas/index.tsx +++ b/frontend/src/platforms/nas/index.tsx @@ -38,12 +38,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'nas', - name: 'Synology', component: Component, - types: { 1210: 'Synology' }, - installation: { - instructions: 'Download the package file and install it through your NAS web interface.', - qualifier: 'Synology manual installation', - link: 'https://link.remote.it/getting-started/synology', - }, }) diff --git a/frontend/src/platforms/nvidia/index.tsx b/frontend/src/platforms/nvidia/index.tsx index 95bfe80ce..411ca70f1 100644 --- a/frontend/src/platforms/nvidia/index.tsx +++ b/frontend/src/platforms/nvidia/index.tsx @@ -17,12 +17,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'nvidia', - name: 'NVIDIA Jetson', component: Component, - types: { 1201: 'NVIDIA Jetson' }, - installation: { - command: true, - qualifier: 'For NVIDIA Jetson systems', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/openwrt/index.tsx b/frontend/src/platforms/openwrt/index.tsx index b9aa25766..17fd89b18 100644 --- a/frontend/src/platforms/openwrt/index.tsx +++ b/frontend/src/platforms/openwrt/index.tsx @@ -32,12 +32,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'openwrt', - name: 'OpenWrt', component: Component, - types: { 1205: 'OpenWrt' }, - installation: { - command: true, - qualifier: 'For OpenWrt routers', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/raspberrypi/index.tsx b/frontend/src/platforms/raspberrypi/index.tsx index ea7691ef5..a8ec029ba 100644 --- a/frontend/src/platforms/raspberrypi/index.tsx +++ b/frontend/src/platforms/raspberrypi/index.tsx @@ -74,10 +74,10 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'raspberrypi', - name: 'Raspberry Pi', component: Component, + // Cloning an installed image duplicates the device identity — OEM guidance covers it. + installation: { oemGuide: true }, route: '/add/raspberrypi-options', - types: { 1072: 'Raspberry Pi', 1075: 'Remote.It Pi', 1076: 'Remote.It Pi Lite', 1077: 'Remote.It Pi 64' }, listItemTitle: ( <> Raspberry Pi   @@ -87,10 +87,4 @@ platforms.register({ ), - installation: { - command: true, - qualifier: 'For any Raspberry Pi or Linux based system', - link: 'https://link.remote.it/support/streamline-install', - altLink: 'https://link.remote.it/docs/oem-overview', - }, }) diff --git a/frontend/src/platforms/teltonika/index.tsx b/frontend/src/platforms/teltonika/index.tsx index bce16958a..e86df1c68 100644 --- a/frontend/src/platforms/teltonika/index.tsx +++ b/frontend/src/platforms/teltonika/index.tsx @@ -16,12 +16,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'teltonika', - name: 'Teltonika', component: Component, - types: { 1281: 'Teltonika' }, - installation: { - command: true, - qualifier: 'For Teltonika routers and gateways', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/text.ts b/frontend/src/platforms/text.ts new file mode 100644 index 000000000..296597d9b --- /dev/null +++ b/frontend/src/platforms/text.ts @@ -0,0 +1,38 @@ +import { useTranslation } from 'react-i18next' +import type { TFunction } from 'i18next' +import { IPlatform } from '.' + +// See ./README.md, Translations. +const key = (platform: IPlatform, field: string) => `platforms:${platform.id}.${field}` + +export interface PlatformText { + name: string + description?: string + // JSX instructions (6 platforms keep theirs in code) pass through untranslated. + instructions?: string | React.ReactNode +} + +export function platformText(t: TFunction, platform: IPlatform): PlatformText { + const { description, instructions } = platform.installation ?? {} + + // An empty default makes i18next return the key itself (returnEmptyString: false), and a + // platform whose module has not loaded yet has no id — both would render as ".name". + if (!platform.id) return { name: platform.name ?? '', description, instructions } + + return { + name: platform.name ? t(key(platform, 'name'), platform.name) : '', + description: description ? t(key(platform, 'description'), description) : undefined, + instructions: + typeof instructions === 'string' + ? instructions + ? t(key(platform, 'instructions'), instructions) + : undefined + : instructions, + } +} + +export function usePlatformText(platform: IPlatform): PlatformText { + const { t } = useTranslation() + + return platformText(t, platform) +} diff --git a/frontend/src/platforms/tinkerboard/index.tsx b/frontend/src/platforms/tinkerboard/index.tsx index 4da6ae2f9..bcd29ed48 100644 --- a/frontend/src/platforms/tinkerboard/index.tsx +++ b/frontend/src/platforms/tinkerboard/index.tsx @@ -9,12 +9,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'tinkerboard', - name: 'Tinker Board', component: Component, - types: { 1215: 'ASUS Tinker Board' }, - installation: { - command: true, - qualifier: 'For the ASUS Tinker Board', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/toa/index.tsx b/frontend/src/platforms/toa/index.tsx index 6ced4607d..d944ef90d 100644 --- a/frontend/src/platforms/toa/index.tsx +++ b/frontend/src/platforms/toa/index.tsx @@ -17,7 +17,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'toa', - name: 'TOA', component: Component, - types: { 1228: 'TOA' }, }) diff --git a/frontend/src/platforms/ubiquiti/index.tsx b/frontend/src/platforms/ubiquiti/index.tsx index a27ccb526..62f9434db 100644 --- a/frontend/src/platforms/ubiquiti/index.tsx +++ b/frontend/src/platforms/ubiquiti/index.tsx @@ -8,12 +8,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'ubiquiti', - name: 'Ubiquiti', component: Component, - types: { 1218: 'Ubiquiti Router' }, - installation: { - command: true, - qualifier: 'For Ubiquiti routers', - link: 'https://link.remote.it/support/streamline-install', - }, }) diff --git a/frontend/src/platforms/ubuntu/index.tsx b/frontend/src/platforms/ubuntu/index.tsx index f05f32409..2609f4a6c 100644 --- a/frontend/src/platforms/ubuntu/index.tsx +++ b/frontend/src/platforms/ubuntu/index.tsx @@ -21,12 +21,8 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'ubuntu', - name: 'Ubuntu Desktop', component: Component, - types: { 1120: 'Debian Linux' }, installation: { - qualifier: 'For Ubuntu Desktop systems', - link: 'https://link.remote.it/download/desktop', instructions: ( <> Install the Desktop app with our CLI on an Ubuntu Desktop system
diff --git a/frontend/src/platforms/unknown/index.tsx b/frontend/src/platforms/unknown/index.tsx index e9fed1f07..8748d3df3 100644 --- a/frontend/src/platforms/unknown/index.tsx +++ b/frontend/src/platforms/unknown/index.tsx @@ -19,13 +19,6 @@ const Index = ({ darkMode, ...props }) => { platforms.register({ id: 'unknown', - name: 'Unknown', component: Index, - types: { 65535: 'Unknown' }, - installation: { - label: 'Registration Code', - command: '[CODE]', - qualifier: 'For generic device registration', - instructions: 'This unique code allows any device to register with your account, keep it safe.', - }, + types: { 65535: 'Unknown', 0: 'Unknown' }, }) diff --git a/frontend/src/platforms/windows/index.tsx b/frontend/src/platforms/windows/index.tsx index 05fabaeef..ff78328eb 100644 --- a/frontend/src/platforms/windows/index.tsx +++ b/frontend/src/platforms/windows/index.tsx @@ -9,13 +9,8 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'windows', - name: 'Windows', component: Component, - types: { 0: 'Windows', 5: 'Windows Desktop', 10: 'Windows Server' }, installation: { - qualifier: 'Windows installation', - instructions: 'Install the Desktop or CLI on the Windows system to you want to enable remote access to.', - link: 'https://link.remote.it/download/desktop', - altLink: browser.isWindows ? '/devices/setup' : undefined, + addThisDevice: browser.isWindows, }, }) diff --git a/frontend/src/services/Notifications.ts b/frontend/src/services/Notifications.ts index b0634e39c..799b8c8aa 100644 --- a/frontend/src/services/Notifications.ts +++ b/frontend/src/services/Notifications.ts @@ -50,7 +50,7 @@ function stateNotification(event: ICloudEvent) { event.target.forEach(target => { // notify if device changes state only if (target.typeID === DEVICE_TYPE) { - let body = platforms.nameLookup[target.platform] + let body = platforms.name(target.platform) let url = `/devices/${target.deviceId}` if (target.service?.id) url += `/${target.service?.id}` if (event.authUserId !== target.owner?.id) body += ' - ' + target.owner?.email @@ -107,7 +107,7 @@ function transferNotification(event: ICloudEvent) { event.target.forEach(target => { if (target.typeID === DEVICE_TYPE) { const isReceiving = target.owner?.id === event.authUserId - + if (isReceiving) { const title = `${target.name} was transferred to you` const body = `from ${event.actor.email}` diff --git a/package-lock.json b/package-lock.json index 0fbeedf0f..4fbcfed1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "dotenv": "^17.2.0", "npm-run-all": "4.1.5", "onchange": "^7.1.0", + "prettier": "^2.8.8", "ts-node": "^10.9.2", "typescript": "^5.8.3" } diff --git a/package.json b/package.json index 57e429037..e9e5043c5 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,12 @@ "start": "npm run brand-web && npm start -w=frontend", "test-watch": "npm run test-watch -w=electron", "test": "npm run test -w=electron && npm run test -w=frontend", - "version": "npm version $npm_package_version --workspaces && sh version.sh $npm_package_version" + "version": "npm version $npm_package_version --workspaces && sh version.sh $npm_package_version", + "platforms:generate": "node scripts/platforms-generate.mjs --cli", + "platforms:check": "node scripts/platforms-generate.mjs --check --cli", + "i18n:check": "npm run i18n:check -w=frontend", + "platforms:generate:token": "node scripts/platforms-generate.mjs", + "platforms:check:token": "node scripts/platforms-generate.mjs --check" }, "dependencies": { "@capacitor-community/bluetooth-le": "^7.1.1", @@ -55,6 +60,7 @@ "dotenv": "^17.2.0", "npm-run-all": "4.1.5", "onchange": "^7.1.0", + "prettier": "^2.8.8", "ts-node": "^10.9.2", "typescript": "^5.8.3" }, diff --git a/scripts/platforms-generate.mjs b/scripts/platforms-generate.mjs new file mode 100644 index 000000000..e68b7aebd --- /dev/null +++ b/scripts/platforms-generate.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +// Regenerate frontend/src/platforms/catalogue.generated.json from the API's platform catalogue. +// +// node scripts/platforms-generate.mjs --cli # rewrite the snapshot, via the remote.it CLI +// node scripts/platforms-generate.mjs # ...or with R3_API_TOKEN set directly +// node scripts/platforms-generate.mjs --check # exit 1 if the committed snapshot is stale +// +// `--cli` is the easy path: it shells out to `sudo remoteit exec-gql`, so the CLI supplies the +// credentials and there is nothing to paste. It needs sudo because the CLI runs as root, which is +// fine for an occasional developer refresh and is exactly why this is NOT a CI mechanism. +// +// Env: R3_API_TOKEN — used when --cli is not given: a bearer JWT for the GraphQL API. The Bearer +// path accepts Cognito and agent JWTs, both short-lived, so there is no static token to +// configure — take one from a signed-in session (dev tools → Network → any request to +// api.remote.it/graphql/v1 → copy the Authorization bearer value). +// R3_GRAPHQL_API | VITE_GRAPHQL_API — endpoint (default: the prod GraphQL API). +// +// The API is the single source of truth for platform names, onboarding routes and install +// commands. The committed JSON is a BUILD-TIME SNAPSHOT of it — the app reads only that file, +// so a catalogue change reaches clients when this is re-run and shipped. The desktop keeps only +// what is code (logo components, one override, a few JSX blocks) in frontend/src/platforms/*/. +// +// NOT WIRED INTO CI, deliberately. Two reasons, and the credential is the smaller one: +// 1. The snapshot is MEANT to lag the database until someone regenerates and ships, so +// "differs from the API" is the normal state after any row edit, not a fault. As a per-PR +// gate it would turn every open pull request red for a change none of them made. The right +// trigger is a scheduled job that regenerates and opens a PR with the diff. +// 2. Auth is enforced at the GATEWAY (`POST /graphql` carries an authorizer), not the +// resolver, so the request never reaches platformTypes and dropping @Authorized() would +// change nothing — a public catalogue would mean a new unauthenticated route. The API's +// Bearer path also takes only short-lived JWTs, so no static CI secret can satisfy it. +// If this is ever automated, `Authorization: Signature` (access key) is the mechanism that +// needs no new surface. +// Until then this is a local tool: `npm run platforms:generate` after a catalogue change. +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import prettier from 'prettier' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const OUT = path.join(here, '..', 'frontend', 'src', 'platforms', 'catalogue.generated.json') +const LOCALES_DIR = path.join(here, '..', 'frontend', 'src', 'i18n', 'locales') +const NAMESPACE = 'platforms' +// English comes from the database; the other locales are for translators. The keys are built at +// render time (`platforms:.description`), so i18next-parser cannot see them statically — +// this script maintains the catalogs instead, the same arrangement the parser config already +// documents for the `columns.` labels. +const TRANSLATABLE = ['name', 'description', 'instructions'] +const API = process.env.R3_GRAPHQL_API || process.env.VITE_GRAPHQL_API || 'https://api.remote.it/graphql/v1' +const check = process.argv.includes('--check') +const viaCli = process.argv.includes('--cli') + +const INSTALLATION = + 'slug name kind commandTemplate description instructions link services { application port name host enabled }' +const QUERY = `{ + platformTypes { id label installations { slug } } + platformInstallations { ${INSTALLATION} } +}` + +// `remoteit exec-gql --json` answers with a status envelope whose data carries the GraphQL +// response — as a string in the versions seen so far, but unwrap an object too rather than +// depending on which. +export function unwrapCli(stdout) { + let parsed + try { + parsed = JSON.parse(stdout) + } catch { + throw new Error(`remoteit exec-gql did not return JSON: ${stdout.slice(0, 160)}`) + } + + // Both shapes carry a top-level `data` meaning different things, so `code` is the + // discriminator: the CLI's status envelope has one, a bare GraphQL response does not. + const envelope = typeof parsed?.code === 'number' ? parsed : null + const raw = envelope ? envelope.data : parsed + if (envelope && raw === undefined) + throw new Error(`remoteit exec-gql failed: ${envelope.message || stdout.slice(0, 160)}`) + + const body = typeof raw === 'string' ? JSON.parse(raw) : raw + if (body?.errors?.length) { + const messages = body.errors.map(e => e.message).join('; ') + // The catalogue fields ship with graphql-api's platform-catalogue work. Until that is + // deployed to whichever stage this is pointed at, the schema simply has no such fields, and + // "Did you mean name?" is a confusing way to learn that. + if (/platformInstallations|"(label|installations|slug)"/.test(messages)) { + throw new Error( + 'This stage does not serve the platform catalogue yet — the schema has no `label`, ' + + '`installations` or `platformInstallations` (graphql-api#209 is not deployed here). ' + + 'Point at a stage that has it, or leave the committed snapshot alone until it ships.\n' + + ` GraphQL said: ${messages}` + ) + } + throw new Error(messages) + } + if (!body?.data) throw new Error(`exec-gql returned no data: ${stdout.slice(0, 160)}`) + + return body.data +} + +function fromCli() { + let stdout + try { + // stdin/stderr inherited so sudo can prompt and the CLI's own errors reach the terminal. + stdout = execFileSync('sudo', ['remoteit', 'exec-gql', '--json', '--query', QUERY], { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'inherit'], + }) + } catch (error) { + // execFileSync throws an object that prints as an unreadable dump; say what to check instead. + throw new Error( + '`sudo remoteit exec-gql` failed (see above). The CLI runs as root, so this needs sudo. ' + + 'Check that the remote.it CLI is installed and signed in, or set R3_API_TOKEN and drop --cli.' + ) + } + const data = unwrapCli(stdout) + if (!data.platformTypes) throw new Error('exec-gql returned no platformTypes') + return normalise(data.platformTypes, data.platformInstallations || []) +} + +async function fromApi() { + const token = process.env.R3_API_TOKEN + if (!token) + throw new Error( + 'R3_API_TOKEN is required (a bearer JWT for the GraphQL API) — or pass --cli to use the remote.it CLI instead' + ) + const res = await fetch(API, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ query: QUERY }), + signal: AbortSignal.timeout(15_000), + }) + if (!res.ok) throw new Error(`${API} → HTTP ${res.status}`) + const text = await res.text() + let body + try { + body = JSON.parse(text) + } catch { + throw new Error(`${API} → HTTP ${res.status} but not JSON (edge/WAF page?): ${text.slice(0, 80)}`) + } + if (body.errors?.length) throw new Error(body.errors.map(e => e.message).join('; ')) + if (!body.data?.platformTypes) throw new Error(`${API} returned no platformTypes`) + return normalise(body.data.platformTypes, body.data.platformInstallations || []) +} + +// Drop null/undefined recursively; keep [] (it means "none", distinct from unset). +const clean = value => + Array.isArray(value) + ? value.map(clean) + : value && typeof value === 'object' + ? Object.fromEntries( + Object.entries(value) + .filter(([, v]) => v !== null && v !== undefined) + .map(([k, v]) => [k, clean(v)]) + ) + : value + +// Three maps: every platform type id to the name to show for it; every onboarding page by slug, +// carrying the type ids it onboards with those same labels; and, for the types that several +// pages onboard, their routes default first — so the registry never has to invert anything, or +// re-derive a name, at startup. Pages are keyed by SLUG: the API's `id` is a surrogate uuid it +// may rename a slug under, and the desktop's logo components are stored by slug. `label` is the +// API's own displayName-or-name rule; it is never recomputed here. +export function normalise(platformTypes, platformInstallations) { + const types = {} + const installations = {} + const routes = {} + const skipped = new Set() + for (const { slug, ...rest } of platformInstallations) installations[slug] = { ...clean(rest), types: {} } + for (const t of platformTypes) { + if (typeof t.label !== 'string') continue + types[t.id] = t.label + // A type can link to a row platformInstallations does not return — `generic`, which the API + // hides because it is an inherited template, not a page. Inventing one gives it no name. + const slugs = (t.installations ?? []) + .map(route => route.slug) + .filter(slug => { + if (installations[slug]) return true + skipped.add(slug) + return false + }) + for (const slug of slugs) installations[slug].types[t.id] = t.label + if (slugs.length > 1) routes[t.id] = slugs + } + if (skipped.size) console.warn(` note: ignored route(s) with no installation row: ${[...skipped].join(', ')}`) + return { types, routes, installations } +} + +const canonical = data => JSON.stringify(data, null, 2) + '\n' +// Written output goes through the project's prettier so a regeneration leaves nothing for +// format-on-save to change. Awaited because prettier 3 returns a promise — writing the result +// unawaited put the string "[object Promise]" in the file. Comparison stays on `canonical`. +const formatted = async (file, text) => + prettier.format(text, { filepath: file, ...((await prettier.resolveConfig(file)) ?? {}) }) + +// Build `.` entries for every translatable string in the catalogue. +function catalogFrom(installations) { + const out = {} + for (const [slug, row] of Object.entries(installations)) { + for (const field of TRANSLATABLE) { + if (typeof row[field] === 'string' && row[field].trim()) (out[slug] ??= {})[field] = row[field] + } + } + return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))) +} + +// English takes the catalogue text. Other locales KEEP whatever has been translated, gain empty +// entries for new keys, and lose entries for keys that no longer exist — so regenerating can +// never discard a translation. +function mergeCatalog(locale, english) { + const file = path.join(LOCALES_DIR, locale, `${NAMESPACE}.json`) + const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {} + const merged = {} + for (const [slug, fields] of Object.entries(english)) { + for (const [field, value] of Object.entries(fields)) { + ;(merged[slug] ??= {})[field] = locale === 'en' ? value : existing[slug]?.[field] ?? '' + } + } + return { file, merged } +} + +async function writeCatalogs(installations) { + const english = catalogFrom(installations) + const written = [] + for (const locale of fs + .readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name)) { + const { file, merged } = mergeCatalog(locale, english) + const next = canonical(merged) + const current = fs.existsSync(file) ? canonical(JSON.parse(fs.readFileSync(file, 'utf8'))) : '' + if (current !== next) { + fs.writeFileSync(file, await formatted(file, next)) + written.push(locale) + } + } + return { keys: Object.values(english).reduce((n, f) => n + Object.keys(f).length, 0), written } +} + +// Only run when invoked directly, so the helpers above stay importable (and testable). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const data = viaCli ? fromCli() : await fromApi() + const live = Object.keys(data.installations).length > 0 + if (!live) { + // The API deploy is ahead of the migration: nothing to compare against yet, and writing + // this would strip every /add page's data from the app. + console.log( + `${API} serves no platform catalogue yet (migration pending) — ${ + check ? 'nothing to check' : 'refusing to overwrite the snapshot' + }` + ) + process.exit(check ? 0 : 2) + } + const next = canonical(data) + if (check) { + const current = fs.existsSync(OUT) ? canonical(JSON.parse(fs.readFileSync(OUT, 'utf8'))) : '' + const englishFile = path.join(LOCALES_DIR, 'en', `${NAMESPACE}.json`) + const englishCurrent = fs.existsSync(englishFile) ? canonical(JSON.parse(fs.readFileSync(englishFile, 'utf8'))) : '' + if (current !== next || englishCurrent !== canonical(catalogFrom(data.installations))) { + console.error( + `STALE: the committed platform snapshot or its English catalog differs from the API. Run: npm run platforms:generate` + ) + process.exit(1) + } + console.log('platform catalogue snapshot is up to date') + } else { + fs.writeFileSync(OUT, await formatted(OUT, next)) + const { keys, written } = await writeCatalogs(data.installations) + console.log( + `wrote ${path.relative(process.cwd(), OUT)}: ${Object.keys(data.types).length} types, ${ + Object.keys(data.installations).length + } installations` + ) + console.log( + `wrote ${keys} translatable string(s) to the ${NAMESPACE} catalog${ + written.length ? ` (${written.join(', ')})` : ' (no change)' + }` + ) + } +} From cac9c27133e1887ff3bfa53a06bec469185ee174 Mon Sep 17 00:00:00 2001 From: evanrbowers Date: Mon, 14 Sep 2026 13:28:41 -0700 Subject: [PATCH 217/238] Icon buttons get their accessible name; the chat's close is "Close chat" (#1219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IconButton wrapped its Tooltip around a around the real button, so MUI's aria-label landed on the span and every icon-only button in the app was nameless to assistive tech (and to getByRole). The button now carries aria-label from a string title, with a `label` prop for the cases a title cannot name: a React-node title (ServiceKeySetting), a title that swaps in a disabled-state explanation (RegisterMenu, ProductsActionBar), and copy controls whose title flashes "Copied!" (CopyIconButton, ScriptEditPage). The chat panel's close is titled "Close chat" (chat.closeChat) — the name the e2e suite uses to dismiss a docked chat on a dev build (e2e-tests#17). Codex clean on b02f2201; typecheck, frontend 49/49, i18n, vite build green; e2e auth + multi-account pass against a local build and against app.dev. Co-Authored-By: Claude Opus 5 --- frontend/src/buttons/CopyIconButton.tsx | 4 ++++ frontend/src/buttons/IconButton.tsx | 13 +++++++++++++ frontend/src/components/Chat/ChatPanel.tsx | 5 ++++- frontend/src/components/ProductsActionBar.tsx | 1 + frontend/src/components/RegisterMenu.tsx | 1 + frontend/src/components/ServiceKeySetting.tsx | 1 + frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + frontend/src/pages/ScriptEditPage.tsx | 1 + 11 files changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/src/buttons/CopyIconButton.tsx b/frontend/src/buttons/CopyIconButton.tsx index 93a047ab9..a9544d386 100644 --- a/frontend/src/buttons/CopyIconButton.tsx +++ b/frontend/src/buttons/CopyIconButton.tsx @@ -56,6 +56,9 @@ export const CopyIconButton = React.forwardRef setOpen(false) + // The name stays the ACTION ("Copy command") while the tooltip flashes the status: a control + // renamed "Copied!" for 800ms is one a screen reader or voice control can no longer find. + const label = props.label ?? (typeof title === 'string' ? title : undefined) title = clipboard.copied ? 'Copied!' : title return ( @@ -66,6 +69,7 @@ export const CopyIconButton = React.forwardRef diff --git a/frontend/src/buttons/IconButton.tsx b/frontend/src/buttons/IconButton.tsx index c88292d9b..903b47dd8 100644 --- a/frontend/src/buttons/IconButton.tsx +++ b/frontend/src/buttons/IconButton.tsx @@ -7,7 +7,14 @@ type VariantType = 'text' | 'contained' | 'outlined' export type ButtonProps = Omit & { to?: string + /** The tooltip. A STRING title is also the button's accessible name — unless `label` says + * otherwise (a title that is a React node, or one that swaps in an explanation). */ title?: React.ReactNode + /** The control's stable accessible name, for when `title` cannot be it: a node title + * (ServiceKeySetting's "Get the Node.js package" + launch icon), or a title that changes to + * a disabled-state explanation ("Manage permission required…") — which must not become + * the name of what the button DOES. */ + label?: string forceTitle?: boolean icon?: string name?: string @@ -35,6 +42,7 @@ export const IconButton = React.forwardRef( to, sx = {}, title, + label, forceTitle, icon, name, @@ -107,6 +115,11 @@ export const IconButton = React.forwardRef( const button = ( around it, so MUI's own aria-label + // landed on the span — a wrapper nothing focuses or reads — and every icon button in the + // app was nameless to assistive tech and to the e2e suite's getByRole('button', { name }). + // `label` wins; otherwise a string title is the name; a node title names nothing here. + aria-label={label ?? (typeof title === 'string' ? title : undefined)} sx={updatedSx} size={buttonBaseSize} onClick={clickHandler} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 31ab6d2d6..43697357e 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -150,7 +150,10 @@ export const ChatPanel: React.FC = () => { /> )} - dispatch.chat.set({ open: false })} /> + {/* "Close chat", not "Close": the title is this icon-only button's accessible name, and a + bare "Close" is ambiguous beside every other close on the page — for a screen reader, + and for the e2e suite, which dismisses a docked chat by that name (helpers/app.ts). */} + dispatch.chat.set({ open: false })} />
diff --git a/frontend/src/components/ProductsActionBar.tsx b/frontend/src/components/ProductsActionBar.tsx index 6eb57e0eb..e2c76f3a6 100644 --- a/frontend/src/components/ProductsActionBar.tsx +++ b/frontend/src/components/ProductsActionBar.tsx @@ -69,6 +69,7 @@ export const ProductsActionBar: React.FC = ({ select }) => { = ({ fab, buttonSize = 38, sidebar, . '&.Mui-disabled': { backgroundColor: 'white.main', color: 'gray.main' }, }), }} + label={t('registerMenu.addDevice', 'Add device')} title={ unauthorized ? t( diff --git a/frontend/src/components/ServiceKeySetting.tsx b/frontend/src/components/ServiceKeySetting.tsx index e8652498f..938a42c0a 100644 --- a/frontend/src/components/ServiceKeySetting.tsx +++ b/frontend/src/components/ServiceKeySetting.tsx @@ -101,6 +101,7 @@ export const ServiceKeySetting: React.FC = ({ connection, service, permis {t('serviceKeySetting.nodePackage', 'Get the Node.js package')} diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 81f1330f3..7f41cd0f8 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -293,6 +293,7 @@ "chat": { "approve": "", "close": "", + "closeChat": "", "deleteConfirmAction": "", "deleteConfirmTitle": "", "deleteConversation": "", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 828806613..b4fde2029 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -293,6 +293,7 @@ "chat": { "approve": "Approve", "close": "Close", + "closeChat": "Close chat", "deleteConfirmAction": "Delete", "deleteConfirmTitle": "Delete this conversation?", "deleteConversation": "Delete", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index ea99ea057..2ef27e54b 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -296,6 +296,7 @@ "chat": { "approve": "", "close": "", + "closeChat": "", "deleteConfirmAction": "", "deleteConfirmTitle": "", "deleteConversation": "", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 42c25c5f8..7bd87ab9d 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -290,6 +290,7 @@ "chat": { "approve": "", "close": "", + "closeChat": "", "deleteConfirmAction": "", "deleteConfirmTitle": "", "deleteConversation": "", diff --git a/frontend/src/pages/ScriptEditPage.tsx b/frontend/src/pages/ScriptEditPage.tsx index 595eff56e..a9681d83b 100644 --- a/frontend/src/pages/ScriptEditPage.tsx +++ b/frontend/src/pages/ScriptEditPage.tsx @@ -169,6 +169,7 @@ export const ScriptEditPage: React.FC = ({ isNew }) => { {!!editForm?.script && !loading && ( Date: Mon, 14 Sep 2026 19:30:55 -0700 Subject: [PATCH 218/238] =?UTF-8?q?Admin=20=E2=86=92=20Add-ons:=20grant=20?= =?UTF-8?q?the=20ai-agent=20licence=20per=20account;=20the=20licence=20is?= =?UTF-8?q?=20the=20chat's=20only=20switch=20(#1220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop half of the ai-agent licence (graphql-api docs/AI-AGENT-LICENSE.md): * Admin → Add-ons (/admin/add-ons/:productId): a system-admin page that lists an add-on's holders and grants / revokes it by email, with an optional expiration. Generic over add-on products — ai-agent is the first. The model carries load statuses and one `refresh` entry point; every request takes a latest-wins ticket, and the list's identity is (product, API target). * The licence card says what it grants ("AI agent is available"; "Expires", not "Renews", for a licence billing does not own) and wears the remote-ai mark. * The licence is the chat's ONLY switch: PENDING_FEATURES / CHAT_ALWAYS_ON / VITE_CHAT_ALWAYS_ON retired; a dev build and app.ai.remote.it no longer default it on. The Test page's AI Agent section is behind the same gate (a standing background grant is ended from Connected Apps). Codex: 8 rounds, clean on 3b180429. Desktop suite, typecheck, i18n green; e2e test:dev against a local build of this head: 80 passed, 1 skipped (admin key), 0 failed. Plan note: docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md Co-Authored-By: Claude Opus 5 --- .env.example | 10 +- .../2026-08-31-ai-agent-license-limit.md | 11 +- .../2026-09-14-admin-addon-licenses-page.md | 147 ++++++ .../buttons/RefreshButton/RefreshButton.tsx | 7 + frontend/src/components/AdminSidebarNav.tsx | 11 + frontend/src/components/Header/Header.tsx | 4 +- frontend/src/components/LicensingIcon.tsx | 7 +- frontend/src/components/LicensingSetting.tsx | 21 +- frontend/src/components/LimitSetting.tsx | 8 + frontend/src/constants.ts | 26 +- frontend/src/hooks/useChatEnabled.ts | 7 +- frontend/src/i18n/locales/de/app.json | 3 +- frontend/src/i18n/locales/en/app.json | 3 +- frontend/src/i18n/locales/es/app.json | 3 +- frontend/src/i18n/locales/ja/app.json | 3 +- .../src/models/adminAddonLicenses.test.ts | 320 ++++++++++++ frontend/src/models/adminAddonLicenses.ts | 234 +++++++++ frontend/src/models/auth.ts | 1 + frontend/src/models/index.ts | 3 + frontend/src/models/plans.ts | 3 + .../AdminAddonLicensesListPage.tsx | 469 ++++++++++++++++++ frontend/src/pages/TestPage.tsx | 117 ++--- frontend/src/routers/Router.tsx | 4 + frontend/src/selectors/organizations.ts | 29 +- frontend/src/services/graphQLMutation.ts | 31 ++ frontend/src/services/graphQLRequest.ts | 46 ++ readme.md | 5 +- 27 files changed, 1407 insertions(+), 126 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md create mode 100644 frontend/src/models/adminAddonLicenses.test.ts create mode 100644 frontend/src/models/adminAddonLicenses.ts create mode 100644 frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx diff --git a/.env.example b/.env.example index fd08716ee..e20dc0440 100644 --- a/.env.example +++ b/.env.example @@ -35,11 +35,11 @@ OAUTH_ISSUER="https://login.dev.remote.it" # that disagrees with the OIDC resource 401s with nothing in the UI explaining why. # --- Remote.It AI chat --------------------------------------------------------------- -# The chat is a license feature ("ai-agent"), so leave this false: local dev turns that -# flag on by default, and Settings → Test Settings → Features toggles it. Set true ONLY -# for the AI portal deployment (app.ai.remote.it), which IS the AI surface — that only -# makes the flag default ON there, so it can still be switched off for testing. -VITE_CHAT_ALWAYS_ON="false" +# The chat is a license feature ("ai-agent") and nothing else switches it on: the account +# you sign in with — locally too — needs the ai-agent add-on licence, granted from +# Admin → Add-ons by a system admin. Settings → Test Settings → Features can switch it +# back off on an account that holds it. (VITE_CHAT_ALWAYS_ON, which used to default the +# flag on for the AI portal and dev builds, is retired and ignored.) # In dev, agentURL() returns the same-origin "/agent" vite proxy unless Test Settings # overrides it, so AGENT_PROXY_TARGET is the knob here: the deployed dev agent, or # http://localhost:3001 to run the ai-agent service locally. DPoP proofs are signed over diff --git a/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md index 9bfdcb7b3..f078ccac3 100644 --- a/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md +++ b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md @@ -3,9 +3,14 @@ **Goal:** Make the Remote.It AI chat a real licensed feature by having the API return an `ai-agent` limit, then remove the client-side scaffolding that stands in for it today. -**Status:** The CLIENT side is done and shipped on `feature/agent-chat-interface`. The API -returns no such limit yet, so the client forward-declares it. Nothing here is blocked on -more frontend work — this note is for whoever picks up the graphql-api / licensing side. +**Status:** DONE, both halves. The API side shipped 2026-09-13 as the `ai-agent` add-on +licence (graphql-api `docs/AI-AGENT-LICENSE.md`); the client cleanup below landed 2026-09-14 +with the Admin → Add-ons page (`2026-09-14-admin-addon-licenses-page.md`): `PENDING_FEATURES`, +`CHAT_ALWAYS_ON` and `VITE_CHAT_ALWAYS_ON` are gone, and the licence is the only switch — +including for dev builds and app.ai.remote.it (decision 3 resolved as "the portal paywalls": +an unlicensed account there gets the ordinary app with no chat, and the popout says +"Remote.It AI is not available for this account"). The rest of this note is the record of +what the client assumed while the limit did not exist. **Where the work lives:** the limit itself is a graphql-api + licensing change, in another repo. The only thing in THIS repo is the cleanup in the last section, which should land at diff --git a/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md new file mode 100644 index 000000000..49d91df72 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md @@ -0,0 +1,147 @@ +# Admin → Add-ons: granting the `ai-agent` licence per account + +**Goal:** a system-admin page on the desktop that lists, grants and revokes **add-on licences** — +the per-account entitlement that turns the Remote.It AI chat on. `ai-agent` is the first add-on; +the page is generic over add-on products so the next one is a data change on the API, not a page. + +**Status (2026-09-14):** built on `feat/admin-addon-licenses` (branched from +`feat/permitteer-login`). The API side shipped earlier and is live on dev and prod — see +graphql-api `docs/AI-AGENT-LICENSE.md` for the model and every decision behind it. This note is the +desktop half: what the page does, where it lives, and how to verify it. + +--- + +## Where things stood before this branch + +- **The gate already existed — with a hole.** `useChatEnabled()` reads `limits['ai-agent']` + through `selectLimitsLookup` (`frontend/src/hooks/useChatEnabled.ts`), the same selector that + gates `saml`, `roles` and `tagging`. But `PENDING_FEATURES` (`frontend/src/constants.ts`) + defaulted the flag ON for dev builds and app.ai.remote.it, so there the chat showed with or + without a licence. +- **The API was done.** graphql-api `main` carries the generic add-on admin surface — + `admin.addonProducts`, `admin.addonCustomers(product, from, size, search)`, + `addAddonCustomer(product, email, expiration?)`, `removeAddonCustomer(product, userId)` — with + `AddonCustomer` shaped like `EnterpriseCustomer` plus `productId` and `expiration`. The `ai-agent` + product (`96aa515b-cf6b-40bf-8d04-7972cbbc7c39`) with its one `ALPHA` plan carrying the `ai-agent` + limit is in the shared database. e2e `addon-license.spec.ts` proves the grant → limit → revoke + round trip on every lane. +- **The desktop already rendered the licence** — `LicensingSetting` draws one card per licence, so a + granted account showed an "AI Agent Alpha plan" card — but with no feature line under it + (`LimitSetting` renders nothing for a limit name it does not know) and the r3 brand mark for an + icon. And there was no way to grant one from the app. + +## What this branch adds + +### The page: `/admin/add-ons/:productId?` + +`frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx`, a clone of the +enterprise-licences page (`AdminEnterpriseLicensesListPage.tsx`) with the product made explicit: + +- **The product is in the URL.** `/admin/add-ons` alone redirects to the product last looked at + (remembered through `ui.defaultSelection['admin']`, the same slot the sidebar's other entries use) + or else the first add-on the API lists; a link to a product the API no longer lists is bounced the + same way, and a deep link to a real one is honoured. The Header treats every `/admin/add-ons/*` + path as a root page (no Back arrow) — the product is the list, not a detail. +- **Header row:** an **Add-on** selector (always shown — one entry today), **Grant Add-on**, and the + email/name search (committed on Enter, like the other admin lists). +- **Columns:** Account, Devices, Members, Granted, **Expires** (`-` when open-ended; a past date + reads "Expired " in red — the API keeps the row but skips it in the limits merge until it is + revoked), and a trash action. +- **Grant dialog:** account email plus an optional **Expires** (`datetime-local`, `min` = now — the + API refuses a date in the past). Blank is sent as `null`, not omitted: the API leaves an *omitted* + expiration alone, and re-granting a time-boxed holder from a blank form should give the + open-ended grant the form shows, not silently keep the old date. Granting an account that already + holds the add-on is idempotent on the API's side and replaces its expiration. +- **Revoke:** a confirm naming the add-on and the account; the account loses the feature at once + (the API publishes `LicenseUpdatedEvent`, which the desktop already turns into `plans.updated`). +- **A disabled add-on** (`Product.enabled = false`, the alpha's kill switch) still lists in the + selector, marked "(disabled)", and its grants can still be revoked — but Grant is hidden, since the + API refuses new grants for it. +- **Errors:** `graphQLBasicRequest` already shows the API's own message as a snackbar ("User does not + exist: …", "Add-on is disabled", the Stripe guard). The page does not overwrite it with a generic + "Failed…" the way the enterprise page does; the grant dialog stays open for a correction. + +### Wiring + +- `models/adminAddonLicenses.ts` — the catalogue, the selected product and the paginated holder + list, each with a load STATUS (`idle | loading | loaded | failed`) kept apart from what it last + delivered, so the page tells "nothing has answered yet" from "nobody holds it". `refresh(urlProduct)` + is the one way in — on mount, on every move of the URL's product, and from the header's refresh + button: catalogue first, the selection checked against it (a product the API stopped listing is + cleared and the page redirects), then the list fetched afresh (a remount can sit over rows from + another API target — Test Settings switches the stage without a reload). Every request carries a + latest-wins ticket, so a page that lands after its list was superseded (a product switch, a new + search, a refresh under a Load More, sign-out) is dropped. Registered in `models/index.ts`, reset + on sign-out in `models/auth.ts`. +- `services/graphQLRequest.ts` — `graphQLAdminAddonProducts`, `graphQLAdminAddonCustomers`; + `services/graphQLMutation.ts` — `graphQLAddAddonCustomer`, `graphQLRemoveAddonCustomer`. +- `routers/Router.tsx` (the `/admin/*` block), `components/AdminSidebarNav.tsx` ("Add-ons"), + `components/Header/Header.tsx` (root-page rule). + +### The licence is the only switch + +`PENDING_FEATURES`, `CHAT_ALWAYS_ON` and `VITE_CHAT_ALWAYS_ON` are gone (the 2026-08-31 note's +"client cleanup"). `selectLimitsLookup` is built only from the limits the API returns, so an +account without the add-on has no `ai-agent` entry at all — falsy — and nothing chat-related +mounts: no header button, no docked column, no popout (it says "Remote.It AI is not available for +this account"), and the Test page's **AI Agent** section (background work, agent URL) is behind the +same gate, so the agent service is not even asked for the background status. A standing +**background-work grant** — the agent's own OAuth grant at the AS, which outlives the entitlement +(sign-out revokes it explicitly for that reason) — is ended from Account → Connected Apps, which is +not gated on the licence and kills every token minted from the grant; the Test page toggle is a +convenience for licensed accounts, not the grant's only door. (Whether the agent should refuse +*background work* for an account whose licence lapsed is the agent service's question — it gates on +nothing licence-shaped today, and no background scheduler exists yet.) The Test page's Features +list shows only what the licence mentions — an account holding the add-on can switch it +off there; one without it has no row and gets it granted, not toggled. This holds for a dev build +and for app.ai.remote.it alike: a developer's dev account needs the grant too. + +### The licence card + +- `components/LimitSetting.tsx` — `case 'ai-agent'`: "AI agent is available" when true, and **no + row at all** when false (the alpha's decision 1: accounts that lack it are shown nothing; the API + sends no default row, so today the false branch never arrives anyway). Key + `limitSetting.aiAgentAvailable`, extracted into all four catalogs. +- `models/plans.ts` — `AI_AGENT_PRODUCT_ID`; `components/LicensingIcon.tsx` draws the `remote-ai` + mark for that product's card. + +## Verifying + +- `npm run typecheck`, `cd frontend && npm test` (`models/adminAddonLicenses.test.ts` covers the + model: product switch empties the list, same-product select is a no-op, search is trimmed into the + request, paging appends from the rows held, a refused request clears the spinner), `npm run + i18n:check`. +- Driving it: run the frontend against dev (`frontend/.env.local`), sign in as a **system admin** + (`r3_Users.admin`), Admin → Add-ons. Grant a test account with and without an expiration; on that + account, Account → License shows the "AI Agent Alpha plan" card with "AI agent is available", the + header's AI button appears and Test Settings lists `ai-agent`. Revoke → the card, the line, the + button and the row go, live. Grant an unknown email → the API's message, dialog still open. An + account never granted: no AI button, no docked chat, no AI Agent section on the Test page. +- The API round trip is covered by e2e `addon-license.spec.ts`; a UI spec would need an admin + sign-in through Permitteer, which the suite does not have — deliberately not added. + +## Rollout + +PR into `feat/permitteer-login` → Codex loop → merge → app.dev auto-builds and `next` mirrors. No +server, database or Amplify-env change: the API and rows are already live on every stage, so the +page works the day it lands, and prod gets it with the branch's promotion. + +## Left for later + +- **Expiry is enforced at the next sync, not at the second.** A time-boxed grant that lapses while + the grantee's app stays open keeps its cached `ai-agent` limit until the desktop next refetches + limits (a licence event, a reconnect, a refresh) — exactly as every other licensed feature behaves + when its licence expires. The real enforcement point is server-side: the agent service gates on + nothing licence-shaped today, and neither does the MCP surface (graphql-api + `docs/AI-AGENT-LICENSE.md`, "Exposure"). A client-side timer would only paper over that; the + server check is the fix, and once it exists the client's lazy refresh is merely cosmetic. + +- **app.ai.remote.it for the unlicensed.** With no floor, an account without the add-on gets the + ordinary portal there, chat-less and without a word about why (the popout is the one place that + says so). If the AI portal should explain itself, that is a notice keyed on the same gate — not a + bypass. The Amplify branch env's `VITE_CHAT_ALWAYS_ON=true` is now inert and can be removed. +- **The admin user-detail "License" column** (`pages/AdminUsersPage/adminUserAttributes.tsx`, a + TODO) is the natural place to *show* an account's add-ons beside its plan. +- **Phase 2/3** (paid tiers carrying the limit; the add-on sold through Stripe) are API-side — see + graphql-api `docs/AI-AGENT-LICENSE.md`. Nothing on this page changes for them: a Stripe-owned + licence is refused by the API's `remove`, and the page just shows that message. diff --git a/frontend/src/buttons/RefreshButton/RefreshButton.tsx b/frontend/src/buttons/RefreshButton/RefreshButton.tsx index 61edaaeb7..9e5ded271 100644 --- a/frontend/src/buttons/RefreshButton/RefreshButton.tsx +++ b/frontend/src/buttons/RefreshButton/RefreshButton.tsx @@ -39,6 +39,7 @@ export const RefreshButton: React.FC = props => { const adminUsersPage = useRouteMatch('/admin/users') const adminPartnersPage = useRouteMatch('/admin/partners') const adminEnterpriseLicensesPage = useRouteMatch('/admin/enterprise-licenses') + const adminAddonLicensesPage = useRouteMatch<{ productId?: string }>('/admin/add-ons/:productId?') const adminNoticesPage = useRouteMatch('/admin/notices') const scriptingPage = useRouteMatch(['/script', '/scripts', '/runs']) const runsPage = useRouteMatch<{ fileID?: string }>('/runs/:fileID?') @@ -140,6 +141,12 @@ export const RefreshButton: React.FC = props => { title = 'Refresh enterprise customers' methods.push(async () => await dispatch.adminEnterpriseLicenses.fetch()) + // admin add-on licenses page + } else if (adminAddonLicensesPage) { + title = 'Refresh add-on licenses' + // One call: the catalogue, the selection re-checked against it, then the list + methods.push(async () => await dispatch.adminAddonLicenses.refresh(adminAddonLicensesPage.params.productId)) + // admin notices pages } else if (adminNoticesPage) { title = 'Refresh notices' diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index 2efe4554b..ff400be87 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -99,6 +99,17 @@ export const AdminSidebarNav: React.FC = () => { + handleNavClick('/admin/add-ons')} + > + + + + + + = ({ panels = 1 }) => { '/admin/notices', '/partner-stats', ] - const isAdminRootPage = adminRootPages.includes(location.pathname) + // The add-ons page keys its product into the URL (/admin/add-ons/:productId): that is its root + // list, not a detail with a level above it. + const isAdminRootPage = adminRootPages.includes(location.pathname) || location.pathname.startsWith('/admin/add-ons') const isRootMenu = menu === location.pathname || isAdminRootPage return ( diff --git a/frontend/src/components/LicensingIcon.tsx b/frontend/src/components/LicensingIcon.tsx index 10752ca17..1d048d0e5 100644 --- a/frontend/src/components/LicensingIcon.tsx +++ b/frontend/src/components/LicensingIcon.tsx @@ -1,11 +1,16 @@ import React from 'react' -import { REMOTEIT_PRODUCT_ID, AWS_PRODUCT_ID } from '../models/plans' +import { REMOTEIT_PRODUCT_ID, AWS_PRODUCT_ID, AI_AGENT_PRODUCT_ID } from '../models/plans' import { Icon } from './Icon' export const LicensingIcon: React.FC<{ license: ILicense }> = ({ license }) => { let type: IconType = 'brands' let name: string = '' + // The add-on's card gets the feature's own mark rather than the remote.it brand mark. Keyed on + // the product, unlike the switch below, which compares a licence id to product ids and so only + // ever lands on its default. + if (license.plan.product.id === AI_AGENT_PRODUCT_ID) return + switch (license.id) { case AWS_PRODUCT_ID: name = 'aws' diff --git a/frontend/src/components/LicensingSetting.tsx b/frontend/src/components/LicensingSetting.tsx index 2f02f892b..6cbe4e687 100644 --- a/frontend/src/components/LicensingSetting.tsx +++ b/frontend/src/components/LicensingSetting.tsx @@ -26,15 +26,18 @@ export const LicensingSetting: React.FC<{ licenses: ILicense[]; limits?: ILimit[
} secondary={ - !license.id ? ( - t('licensingSetting.notSubscribed', 'Not subscribed') - ) : ( - license.expiration && ( - <> - {t('licensingSetting.renews', 'Renews')} - - ) - ) + !license.id + ? t('licensingSetting.notSubscribed', 'Not subscribed') + : license.expiration && ( + <> + {/* Billing renews a SUBSCRIBED licence at this date. Any other licence with an + expiration — an admin-granted add-on's time-box, a custom term — ends there. */} + {license.subscription + ? t('licensingSetting.renews', 'Renews') + : t('licensingSetting.expires', 'Expires')}{' '} + + + ) } /> diff --git a/frontend/src/components/LimitSetting.tsx b/frontend/src/components/LimitSetting.tsx index bedc6d646..e9a728038 100644 --- a/frontend/src/components/LimitSetting.tsx +++ b/frontend/src/components/LimitSetting.tsx @@ -38,6 +38,14 @@ export const LimitSetting: React.FC<{ limit: ILimit }> = ({ limit }) => { ? t('limitSetting.rolesAvailable', 'Custom roles are available') : t('limitSetting.rolesUnavailable', 'Custom roles are unavailable') break + case 'ai-agent': + // An alpha granted per account (graphql-api docs/AI-AGENT-LICENSE.md, decision 1): accounts + // that lack it are shown nothing, so there is no "unavailable" line — false renders no row. + if (limit.value) { + template = 'text' + message = t('limitSetting.aiAgentAvailable', 'AI agent is available') + } + break case 'tagging': // ignore break diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 92afefe2f..754c063e3 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -3,32 +3,16 @@ import { CATALOGUE } from './platforms/catalogue' const env = import.meta.env export const MODE = env.MODE || 'development' -// The AI portal (app.ai.remote.it) shows the Remote.It AI chat unconditionally: it IS the -// AI surface. Set per-deployment via the Amplify branch env so the general app stays on -// the licensed gate below even if this branch's code merges elsewhere. -export const CHAT_ALWAYS_ON = env.VITE_CHAT_ALWAYS_ON === 'true' /* The license limit that gates the Remote.It AI chat. The whole surface hangs off this one name — the header button, the docked column and everything the panel loads — so - switching the feature on for an account is a licensing change rather than a release. */ + switching the feature on for an account is a licensing change rather than a release: + the ai-agent ADD-ON licence, granted per account from Admin → Add-ons (graphql-api + docs/AI-AGENT-LICENSE.md). It is the ONLY switch. Until 2026-09-14 a dev build and the + AI portal defaulted the flag on ahead of the licence (PENDING_FEATURES / CHAT_ALWAYS_ON); + now an account without the licence — a developer's included — sees no chat anywhere. */ export const CHAT_FEATURE = 'ai-agent' -/* Boolean license features this build gates on that an account's license may not carry - yet, each paired with what it is worth until a license speaks. Naming one here gives it - a row on the Test page AND puts it in the limits lookup, which is what makes it testable - at all: the lookup is built FROM the limits the API returned, so a flag the API has - never mentioned has nothing for an override to attach to. The API's value wins once it - starts arriving, so an entry whose limit has gone live is dead weight and can go. - - These are DEFAULTS, not bypasses — every one of them stays a normal feature flag, so - the Test page switch reads what is actually in effect and can turn the feature back - OFF. That is the point of routing local dev and the AI portal through here rather than - around the gate: on app.ai.remote.it the chat is on because CHAT_ALWAYS_ON makes this - default true, and it is still one switch away from off. */ -export const PENDING_FEATURES: ILookup = { - [CHAT_FEATURE]: MODE === 'development' || CHAT_ALWAYS_ON, -} - // Renderer-owned OIDC (permitteer docs/remoteit-desktop-login.md, D8) — identical on // web and desktop; the backend never touches auth. export const OAUTH_ISSUER = env.VITE_OAUTH_ISSUER || '' diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index fc2f5fb29..33cd0cfd9 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -24,10 +24,9 @@ import { It is a LICENSE feature, read exactly the way tagging/saml/roles are, which means it follows the ACCOUNT you are viewing: the chat is scoped to the organization in the sidebar selector, so an org whose license does not carry the agent does not get one. - Nothing skips this gate. Local dev and the AI portal (CHAT_ALWAYS_ON) only set the - flag's default where no license carries it yet (PENDING_FEATURES), so even there the - chat is a feature flag you can switch back off in Test Settings → Features — which is - also how you turn it on anywhere else until the API starts sending the limit. */ + Nothing skips this gate — not a dev build, not the AI portal. The limit comes from the + ai-agent add-on licence (Admin → Add-ons); an account holding it can still switch the + feature off in Test Settings → Features, and one without it has no row there. */ export const useChatEnabled = (): boolean => useSelector((state: State) => !!selectLimitsLookup(state)[CHAT_FEATURE]) /* The widest the chat column may be dragged: whatever the window holds once the diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 7f41cd0f8..047be7c03 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -1108,11 +1108,13 @@ "title": "Lizenz" }, "licensingSetting": { + "expires": "", "notSubscribed": "Nicht abonniert", "plan": "Plan", "renews": "Verlängert sich" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "E-Mail-Support verfügbar", "evaluationPeriod": "Diensten wird ein Testzeitraum von {{period}} gewährt", "forumSupport": "Nur Forum-Support", @@ -2255,7 +2257,6 @@ "disableTestUI": "Test-UI deaktivieren", "disableTestUIHint": "Um die Alpha-UI wieder zu aktivieren, müssen Sie das Avatar-Menü bei gedrückter Alt-Umschalt-Taste auswählen.", "featureLabel": "{{name}} (Standard {{state}})", - "featurePending": "", "features": "Funktionen", "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index b4fde2029..83ccf9778 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1108,11 +1108,13 @@ "title": "License" }, "licensingSetting": { + "expires": "Expires", "notSubscribed": "Not subscribed", "plan": "plan", "renews": "Renews" }, "limitSetting": { + "aiAgentAvailable": "AI agent is available", "emailSupport": "Email support available", "evaluationPeriod": "Services are granted an evaluation period of {{period}}", "forumSupport": "Forum support only", @@ -2255,7 +2257,6 @@ "disableTestUI": "Disable Test UI", "disableTestUIHint": "To re-enable the alpha UI you will have to select the Avatar menu while holding alt-shift.", "featureLabel": "{{name}} (default {{state}})", - "featurePending": "Not in any license yet. Switches here apply to your personal account only.", "features": "Features", "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 2ef27e54b..f3fe86fe4 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -1119,11 +1119,13 @@ "title": "Licencia" }, "licensingSetting": { + "expires": "", "notSubscribed": "Sin suscripción", "plan": "plan", "renews": "Se renueva" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "Soporte por correo electrónico disponible", "evaluationPeriod": "Los servicios reciben un período de evaluación de {{period}}", "forumSupport": "Solo soporte en el foro", @@ -2292,7 +2294,6 @@ "disableTestUI": "Deshabilitar la interfaz de prueba", "disableTestUIHint": "Para volver a habilitar la interfaz alfa, debes seleccionar el menú de avatar mientras mantienes presionado alt-shift.", "featureLabel": "{{name}} (predeterminado {{state}})", - "featurePending": "", "features": "Funciones", "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 7bd87ab9d..bf19f31c5 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -1097,11 +1097,13 @@ "title": "ライセンス" }, "licensingSetting": { + "expires": "", "notSubscribed": "未契約", "plan": "プラン", "renews": "更新" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "メールサポートが利用可能です", "evaluationPeriod": "サービスには{{period}}の評価期間が付与されます", "forumSupport": "フォーラムサポートのみ", @@ -2218,7 +2220,6 @@ "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", "featureLabel": "{{name}}(デフォルト: {{state}})", - "featurePending": "", "features": "機能", "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts new file mode 100644 index 000000000..00913d1bb --- /dev/null +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// The model touches nothing but the two request wrappers; stub those and drive the effects and +// reducers directly, the way chat.test.ts does. +const { graphQLAdminAddonProducts, graphQLAdminAddonCustomers, getApiURL } = vi.hoisted(() => ({ + graphQLAdminAddonProducts: vi.fn(), + graphQLAdminAddonCustomers: vi.fn(), + getApiURL: vi.fn(() => 'https://cloud.dev.remote.it/api/graphql'), +})) +vi.mock('../services/graphQLRequest', () => ({ graphQLAdminAddonProducts, graphQLAdminAddonCustomers })) +vi.mock('../helpers/apiHelper', () => ({ getApiURL })) + +import { adminAddonLicenses } from './adminAddonLicenses' + +const model = adminAddonLicenses as any +const effectsFor = (dispatch: any) => model.effects(dispatch) +// refresh dispatches fetchProducts and fetch through the model; the fake routes those to the +// real effects so a refresh test exercises the whole way in. +const withRealEffects = (dispatch: any, state: Record = {}) => { + const effects = effectsFor(dispatch) + dispatch.adminAddonLicenses.fetchProducts = () => effects.fetchProducts() + dispatch.adminAddonLicenses.fetch = vi.fn(() => effects.fetch(undefined, stateWith(state))) + return effects +} +const catalogue = (...ids: string[]) => ({ + data: { data: { admin: { addonProducts: ids.map(id => ({ id, name: id, enabled: true })) } } }, +}) +const makeDispatch = () => ({ + adminAddonLicenses: { + setProducts: vi.fn(), + setProductId: vi.fn(), + setCustomers: vi.fn(), + appendCustomers: vi.fn(), + setProductsStatus: vi.fn(), + setTarget: vi.fn(), + setListStatus: vi.fn(), + setSearchValue: vi.fn(), + resetState: vi.fn(), + fetch: vi.fn(), + }, +}) +// A request the test resolves by hand, to interleave events with a page in flight. +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise(r => (resolve = r)) + return { promise, resolve } +} +const stateWith = (over: Record = {}) => ({ + adminAddonLicenses: { ...model.state, ...over }, +}) +const page = (items: unknown[], total: number, hasMore: boolean) => ({ + data: { data: { admin: { addonCustomers: { items, total, hasMore } } } }, +}) +const holder = (userId: string) => ({ userId, email: `${userId}@example.com` }) + +beforeEach(() => { + graphQLAdminAddonProducts.mockReset() + graphQLAdminAddonCustomers.mockReset() +}) + +describe('adminAddonLicenses reducers', () => { + it('a new product empties the list — the rows on screen belong to the old one', () => { + const before = { ...model.state, productId: 'a', customers: [holder('u1')], total: 1, hasMore: true } + const after = model.reducers.setProductId(before, 'b') + expect(after).toMatchObject({ productId: 'b', customers: [], total: 0, hasMore: false }) + }) + + it('re-selecting the current product keeps the state, identity included', () => { + const before = { ...model.state, productId: 'a', customers: [holder('u1')], total: 1 } + expect(model.reducers.setProductId(before, 'a')).toBe(before) + }) + + it('the product list marks itself loaded, empty or not', () => { + expect(model.reducers.setProducts(model.state, [])).toMatchObject({ products: [], productsStatus: 'loaded' }) + }) + + it('a new product resets the list to never-asked, so the page shows loading rather than empty', () => { + const before = { ...model.state, productId: 'a', listStatus: 'loaded' } + expect(model.reducers.setProductId(before, 'b')).toMatchObject({ listStatus: 'idle', customers: [] }) + }) + + it('a new API target empties the list exactly like a new product; the same target keeps it', () => { + const before = { ...model.state, target: 'dev', customers: [holder('u1')], total: 1, listStatus: 'loaded' } + expect(model.reducers.setTarget(before, 'prod')).toMatchObject({ + target: 'prod', + customers: [], + listStatus: 'idle', + }) + expect(model.reducers.setTarget(before, 'dev')).toBe(before) + }) +}) + +describe('adminAddonLicenses effects', () => { + it('fetchProducts stores what the API lists and resolves to it', async () => { + const dispatch = makeDispatch() + const products = [{ id: 'p1', name: 'ai-agent', description: 'AI Agent', enabled: true }] + graphQLAdminAddonProducts.mockResolvedValueOnce({ data: { data: { admin: { addonProducts: products } } } }) + await expect(effectsFor(dispatch).fetchProducts()).resolves.toEqual(products) + expect(dispatch.adminAddonLicenses.setProductsStatus).toHaveBeenCalledWith('loading') + expect(dispatch.adminAddonLicenses.setProducts).toHaveBeenCalledWith(products) + }) + + it('a refused or missing response (offline, no auth yet) is not an empty product list — the list held stays, marked failed', async () => { + const dispatch = makeDispatch() + for (const answer of ['ERROR', undefined, { data: { data: { admin: {} } } }]) { + graphQLAdminAddonProducts.mockResolvedValueOnce(answer) + await expect(effectsFor(dispatch).fetchProducts()).resolves.toBeUndefined() + } + expect(dispatch.adminAddonLicenses.setProducts).not.toHaveBeenCalled() + expect( + dispatch.adminAddonLicenses.setProductsStatus.mock.calls.filter((call: unknown[]) => call[0] === 'failed') + ).toHaveLength(3) + }) + + it('refresh stamps the current API target before anything is awaited — rows from another target leave at once', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + getApiURL.mockReturnValueOnce('https://cloud.remote.it/api/graphql') + graphQLAdminAddonProducts.mockReturnValueOnce(new Promise(() => {})) // never answers + void effects.refresh('A', stateWith({ productId: 'A', target: 'https://cloud.dev.remote.it/api/graphql' })) + expect(dispatch.adminAddonLicenses.setTarget).toHaveBeenCalledWith('https://cloud.remote.it/api/graphql') + }) + + it("a target change retires the other target's page in flight: it cannot refill the emptied list", async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + const oldTargetPage = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(oldTargetPage.promise) + const inFlight = effects.fetch(undefined, stateWith({ productId: 'A', target: 'dev' })) + + getApiURL.mockReturnValueOnce('prod') + graphQLAdminAddonProducts.mockReturnValueOnce(new Promise(() => {})) // the catalogue is still being awaited + void effects.refresh('A', stateWith({ productId: 'A', target: 'dev' })) + oldTargetPage.resolve(page([holder('dev-user')], 1, false)) + await inFlight + + expect(dispatch.adminAddonLicenses.setCustomers).not.toHaveBeenCalled() + }) + + it("refresh takes the URL's product when the catalogue lists it, and fetches its list afresh", async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'B' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(catalogue('A', 'B')) + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([holder('b1')], 1, false)) + await effects.refresh('B', stateWith({ productId: 'A', customers: [holder('a1')] })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith('B') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + expect(graphQLAdminAddonCustomers).toHaveBeenCalledWith('B', { from: 0, size: 50 }, undefined) + }) + + it('refresh keeps the product held when the URL names none, and refetches even a loaded list (another API target may have filled it)', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(catalogue('A')) + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([], 0, false)) + await effects.refresh(undefined, stateWith({ productId: 'A', customers: [holder('stale')], listStatus: 'loaded' })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith('A') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + }) + + it('refresh clears a selection the catalogue no longer lists and asks for no list — the page picks a product that exists', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'gone' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(catalogue('A')) + await effects.refresh('gone', stateWith({ productId: 'gone' })) + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith(undefined) + expect(dispatch.adminAddonLicenses.fetch).not.toHaveBeenCalled() + expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() + }) + + it('refresh touches neither the selection nor the list when the catalogue did not answer', async () => { + const dispatch = makeDispatch() + const effects = withRealEffects(dispatch, { productId: 'A' }) + graphQLAdminAddonProducts.mockResolvedValueOnce(undefined) + await effects.refresh('A', stateWith({ productId: 'A' })) + expect(dispatch.adminAddonLicenses.setProductId).not.toHaveBeenCalled() + expect(dispatch.adminAddonLicenses.fetch).not.toHaveBeenCalled() + }) + + it('fetch asks for the selected product with the committed search, and never without a product', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).fetch(undefined, stateWith({ productId: undefined })) + expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() + + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([holder('u1')], 7, true)) + await effectsFor(dispatch).fetch(undefined, stateWith({ productId: 'p1', pageSize: 50, searchValue: ' ann ' })) + expect(graphQLAdminAddonCustomers).toHaveBeenCalledWith('p1', { from: 0, size: 50 }, 'ann') + expect(dispatch.adminAddonLicenses.setListStatus).toHaveBeenCalledWith('loading') + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledWith({ + customers: [holder('u1')], + total: 7, + hasMore: true, + }) + }) + + it('a refused or missing list marks the list failed and leaves the rows alone', async () => { + const dispatch = makeDispatch() + for (const answer of ['ERROR', undefined]) { + graphQLAdminAddonCustomers.mockResolvedValueOnce(answer) + await effectsFor(dispatch).fetch(undefined, stateWith({ productId: 'p1' })) + expect(dispatch.adminAddonLicenses.setListStatus).toHaveBeenLastCalledWith('failed') + } + expect(dispatch.adminAddonLicenses.setCustomers).not.toHaveBeenCalled() + }) + + it('fetchMore pages from the rows already held and appends', async () => { + const dispatch = makeDispatch() + const held = [holder('u1'), holder('u2')] + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([holder('u3')], 3, false)) + await effectsFor(dispatch).fetchMore( + undefined, + stateWith({ productId: 'p1', customers: held, hasMore: true, pageSize: 2 }) + ) + expect(graphQLAdminAddonCustomers).toHaveBeenCalledWith('p1', { from: 2, size: 2 }, undefined) + expect(dispatch.adminAddonLicenses.appendCustomers).toHaveBeenCalledWith({ + customers: [holder('u3')], + total: 3, + hasMore: false, + }) + }) + + it('fetchMore does nothing at the end of the list or while a page is loading', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).fetchMore(undefined, stateWith({ productId: 'p1', hasMore: false })) + await effectsFor(dispatch).fetchMore( + undefined, + stateWith({ productId: 'p1', hasMore: true, listStatus: 'loading' }) + ) + expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() + }) + + it('setSearch commits the term and refetches for it', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).setSearch('ann') + expect(dispatch.adminAddonLicenses.setSearchValue).toHaveBeenCalledWith('ann') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + }) + + it('reset clears the state', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).reset() + expect(dispatch.adminAddonLicenses.resetState).toHaveBeenCalledTimes(1) + }) +}) + +/* The races: a page that lands after the list it was fetched for has been superseded — by a + newer request, a product switch, a new search term or sign-out — must not be written. */ +describe('adminAddonLicenses stale responses', () => { + it("a product switch retires the old product's page: it never lands under the new product", async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const a = deferred() + const b = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise) + + const forA = effects.fetch(undefined, stateWith({ productId: 'A' })) + const forB = effects.fetch(undefined, stateWith({ productId: 'B' })) // what refresh(B) issues + b.resolve(page([holder('b1')], 1, false)) + a.resolve(page([holder('a1')], 1, false)) // A's answer arrives last + await Promise.all([forA, forB]) + + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledTimes(1) + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledWith({ + customers: [holder('b1')], + total: 1, + hasMore: false, + }) + }) + + it('a refresh that lands under a Load More retires it: the paged rows are not appended to the new list', async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const more = deferred() + const fresh = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(more.promise).mockReturnValueOnce(fresh.promise) + + const paging = effects.fetchMore(undefined, stateWith({ productId: 'A', customers: [holder('a1')], hasMore: true })) + const refreshing = effects.fetch(undefined, stateWith({ productId: 'A', customers: [holder('a1')] })) + fresh.resolve(page([holder('a1')], 1, false)) + more.resolve(page([holder('a2')], 2, false)) + await Promise.all([paging, refreshing]) + + expect(dispatch.adminAddonLicenses.setCustomers).toHaveBeenCalledTimes(1) + expect(dispatch.adminAddonLicenses.appendCustomers).not.toHaveBeenCalled() + }) + + it("a superseded request leaves the list's status to the request that owns it", async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const first = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(first.promise).mockResolvedValueOnce(page([], 0, false)) + + const stale = effects.fetch(undefined, stateWith({ productId: 'A' })) + await effects.fetch(undefined, stateWith({ productId: 'A' })) + dispatch.adminAddonLicenses.setListStatus.mockClear() + first.resolve('ERROR') // a refused stale request must not mark the newer one's list failed either + await stale + + expect(dispatch.adminAddonLicenses.setListStatus).not.toHaveBeenCalled() + }) + + it('sign-out retires every request in flight, the product list included', async () => { + const dispatch = makeDispatch() + const effects = effectsFor(dispatch) + const list = deferred() + const products = deferred() + graphQLAdminAddonCustomers.mockReturnValueOnce(list.promise) + graphQLAdminAddonProducts.mockReturnValueOnce(products.promise) + + const listing = effects.fetch(undefined, stateWith({ productId: 'A' })) + const loadingProducts = effects.fetchProducts() + await effects.reset() + list.resolve(page([holder('a1')], 1, false)) + products.resolve({ data: { data: { admin: { addonProducts: [{ id: 'A', name: 'a', enabled: true }] } } } }) + await Promise.all([listing, loadingProducts]) + + expect(dispatch.adminAddonLicenses.setCustomers).not.toHaveBeenCalled() + expect(dispatch.adminAddonLicenses.setProducts).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/models/adminAddonLicenses.ts b/frontend/src/models/adminAddonLicenses.ts new file mode 100644 index 000000000..0e735dff2 --- /dev/null +++ b/frontend/src/models/adminAddonLicenses.ts @@ -0,0 +1,234 @@ +import { createModel } from '@rematch/core' +import { graphQLAdminAddonCustomers, graphQLAdminAddonProducts } from '../services/graphQLRequest' +import { getApiURL } from '../helpers/apiHelper' +import type { RootModel } from '.' + +/* Admin grants of ADD-ON licences (graphql-api docs/AI-AGENT-LICENSE.md) — generic over add-on + products, of which ai-agent is the first. An add-on product has one entitled plan and no default + plan: holding the licence row IS the entitlement, so the list is every holder and the two + actions are grant / revoke. Same shape as adminEnterpriseLicenses plus the product the page is + looking at; a future add-on is a product row on the API and nothing here. */ + +export interface AdminAddonProduct { + id: string + name: string + description?: string | null + /* The kill switch: a disabled add-on still lists (and lets an admin revoke) its residual + grants, but the API refuses new ones. */ + enabled: boolean +} + +export interface AdminAddonCustomer { + productId: string + userId: string + email: string + name: string + deviceCount: number + memberCount: number + licenseId: string + created: string + /* Set when the grant is time-boxed; the licence stops counting the moment it passes. */ + expiration?: string | null +} + +/* Where a request stands, kept apart from what it last delivered. `idle` = never asked; `failed` = + the last ask got no usable answer (offline, no auth header yet, a refused query) — whatever was + delivered before is kept, so the page decides what to show from the status AND the rows, never + from an empty array alone: "empty because nobody holds it" and "empty because nothing has + answered yet" are different screens. */ +export type LoadStatus = 'idle' | 'loading' | 'loaded' | 'failed' + +interface AdminAddonLicensesState { + products: AdminAddonProduct[] + productsStatus: LoadStatus + productId?: string + /* The API target (graphql URL) the rows were fetched from. Part of the list's identity with the + product: Test Settings switches the target without a reload, a product id is the same on every + stage, and rows from the other stage must not sit on screen — interactive — behind the same id. */ + target?: string + customers: AdminAddonCustomer[] + total: number + hasMore: boolean + listStatus: LoadStatus + pageSize: number + searchValue: string +} + +const initialState: AdminAddonLicensesState = { + products: [], + productsStatus: 'idle', + productId: undefined, + target: undefined, + customers: [], + total: 0, + hasMore: false, + listStatus: 'idle', + pageSize: 50, + searchValue: '', +} + +type Page = { customers: AdminAddonCustomer[]; total: number; hasMore: boolean } + +/* Latest-wins tickets. Every request takes one before its await and writes only if it is still + the newest when the response lands; every event that makes an in-flight page meaningless — a + product switch, a new search, sign-out — and every newer request takes the next number. One + ticket covers the whole list, first page and Load More alike, because they invalidate each + other: a refresh that lands under a Load More would otherwise be appended to by rows paged off + the list it replaced. (Comparing the response's product and search to the store at resolve time + would let exactly that through — they still match.) The product list has its own, invalidated + only by sign-out: it is not scoped to a selection, and two of its responses say the same thing. */ +let listRequest = 0 +let productsRequest = 0 + +export const adminAddonLicenses = createModel()({ + name: 'adminAddonLicenses', + state: initialState, + reducers: { + setProductsStatus: (state, productsStatus: LoadStatus) => ({ ...state, productsStatus }), + setProducts: (state, products: AdminAddonProduct[]) => ({ ...state, products, productsStatus: 'loaded' as const }), + // Switching product empties the list: the rows on screen belong to the old one. + setProductId: (state, productId?: string) => + productId === state.productId + ? state + : { ...state, productId, customers: [], total: 0, hasMore: false, listStatus: 'idle' as const }, + // A new target empties the list the same way a new product does: nothing on screen is from here. + setTarget: (state, target?: string) => + target === state.target + ? state + : { ...state, target, customers: [], total: 0, hasMore: false, listStatus: 'idle' as const }, + setListStatus: (state, listStatus: LoadStatus) => ({ ...state, listStatus }), + setCustomers: (state, payload: Page) => ({ + ...state, + customers: payload.customers, + total: payload.total, + hasMore: payload.hasMore, + listStatus: 'loaded' as const, + }), + appendCustomers: (state, payload: Page) => ({ + ...state, + customers: [...state.customers, ...payload.customers], + total: payload.total, + hasMore: payload.hasMore, + listStatus: 'loaded' as const, + }), + setSearchValue: (state, searchValue: string) => ({ ...state, searchValue }), + resetState: () => initialState, + }, + effects: dispatch => ({ + /* The product catalogue. Resolves to the fresh list, or undefined when nothing answered — the + products held stay, marked failed. */ + async fetchProducts(): Promise { + const ticket = ++productsRequest + dispatch.adminAddonLicenses.setProductsStatus('loading') + const result = await graphQLAdminAddonProducts() + if (ticket !== productsRequest) return undefined + + // No response at all (offline, no auth header yet) is not an empty list. + const products: AdminAddonProduct[] | undefined = + result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonProducts + if (!Array.isArray(products)) { + dispatch.adminAddonLicenses.setProductsStatus('failed') + return undefined + } + + dispatch.adminAddonLicenses.setProducts(products) + return products + }, + + /* The ONE way in — the page on mount and on every move of the URL's product, and the refresh + button: the catalogue first, then the selection checked against it (the URL's product when it + names one that exists, else the one held if it still exists, else none — a product the API + no longer lists cannot stay selected, or every list request for it is refused; clearing it + hands the choice back to the page, which redirects to one that exists), then that product's + list, fetched AFRESH. Always afresh: the page can remount over rows from another API target + (Test Settings switches the stage without reloading, and cloudSync.all() knows nothing of + this model), and a product id is the same on every stage — so the target is checked FIRST, + before anything is awaited: rows from another target leave the screen at once rather than + staying interactive until (or beyond, if it fails) the new answer. A switch's request retires + whatever the old product still had in flight (the tickets above). */ + async refresh(preferredProductId: string | undefined, rootState) { + const target = getApiURL() + // A page still in flight from the other target is retired with its rows — it would otherwise + // pass the ticket check and refill the emptied list while the catalogue is awaited. + if (target !== rootState.adminAddonLicenses.target) ++listRequest + dispatch.adminAddonLicenses.setTarget(target) + const products = await dispatch.adminAddonLicenses.fetchProducts() + if (!products) return + + const held = rootState.adminAddonLicenses.productId + const listed = (id?: string) => !!id && products.some(p => p.id === id) + const productId = listed(preferredProductId) ? preferredProductId : listed(held) ? held : undefined + dispatch.adminAddonLicenses.setProductId(productId) + if (productId) await dispatch.adminAddonLicenses.fetch() + }, + + /* A committed search term: the list is refetched for it, which retires the page in flight for + the old term. */ + async setSearch(searchValue: string) { + dispatch.adminAddonLicenses.setSearchValue(searchValue) + await dispatch.adminAddonLicenses.fetch() + }, + + async fetch(_: void, rootState) { + const state = rootState.adminAddonLicenses + if (!state.productId) return + const ticket = ++listRequest + dispatch.adminAddonLicenses.setListStatus('loading') + + const result = await graphQLAdminAddonCustomers( + state.productId, + { from: 0, size: state.pageSize }, + state.searchValue.trim() || undefined + ) + + // Superseded: a newer request, or an event that retired this one, owns the list (and its + // status) now — this response describes a list nobody is looking at. + if (ticket !== listRequest) return + + const data = result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonCustomers + if (data) { + dispatch.adminAddonLicenses.setCustomers({ + customers: data.items || [], + total: data.total || 0, + hasMore: !!data.hasMore, + }) + } else { + dispatch.adminAddonLicenses.setListStatus('failed') + } + }, + + async fetchMore(_: void, rootState) { + const state = rootState.adminAddonLicenses + if (!state.productId || !state.hasMore || state.listStatus === 'loading') return + const ticket = ++listRequest + dispatch.adminAddonLicenses.setListStatus('loading') + + const result = await graphQLAdminAddonCustomers( + state.productId, + { from: state.customers.length, size: state.pageSize }, + state.searchValue.trim() || undefined + ) + + if (ticket !== listRequest) return + + const data = result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonCustomers + if (data) { + dispatch.adminAddonLicenses.appendCustomers({ + customers: data.items || [], + total: data.total || 0, + hasMore: !!data.hasMore, + }) + } else { + // The rows held stay; the page keeps its Load More for another try. + dispatch.adminAddonLicenses.setListStatus('failed') + } + }, + + // Sign-out: nothing in flight may land in the next session's state. + async reset() { + ++listRequest + ++productsRequest + dispatch.adminAddonLicenses.resetState() + }, + }), +}) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index fbb31f193..8524f1f68 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -538,6 +538,7 @@ export default createModel()({ dispatch.adminUsers.reset() dispatch.adminPartners.reset() dispatch.adminEnterpriseLicenses.reset() + dispatch.adminAddonLicenses.reset() dispatch.adminNotices.reset() // ui.reset() only restores redux defaults; the live i18next/luxon locale must be // re-resolved so signed-out screens follow the OS rather than the previous diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index a99ba139d..e63cbdb60 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -5,6 +5,7 @@ import agents from './agents' import { adminPartners } from './adminPartners' import { adminUsers } from './adminUsers' import { adminEnterpriseLicenses } from './adminEnterpriseLicenses' +import { adminAddonLicenses } from './adminAddonLicenses' import adminNotices from './adminNotices' import announcements from './announcements' import applicationTypes from './applicationTypes' @@ -43,6 +44,7 @@ export interface RootModel extends Models { adminPartners: typeof adminPartners adminUsers: typeof adminUsers adminEnterpriseLicenses: typeof adminEnterpriseLicenses + adminAddonLicenses: typeof adminAddonLicenses adminNotices: typeof adminNotices announcements: typeof announcements applicationTypes: typeof applicationTypes @@ -82,6 +84,7 @@ export const models: RootModel = { adminPartners, adminUsers, adminEnterpriseLicenses, + adminAddonLicenses, adminNotices, announcements, applicationTypes, diff --git a/frontend/src/models/plans.ts b/frontend/src/models/plans.ts index 346cbd719..0bbc38f17 100644 --- a/frontend/src/models/plans.ts +++ b/frontend/src/models/plans.ts @@ -27,6 +27,9 @@ type ILicenseLookup = { productId: string; platform?: number } export const REMOTEIT_PRODUCT_ID = 'b999e047-5532-11eb-8872-063ce187bcd7' export const AWS_PRODUCT_ID = '55d9e884-05fd-11eb-bda8-021f403e8c27' +// The ai-agent ADD-ON product (graphql-api docs/AI-AGENT-LICENSE.md): a licence for it is the +// account's entitlement to Remote.It AI, granted per account from Admin → Add-ons. +export const AI_AGENT_PRODUCT_ID = '96aa515b-cf6b-40bf-8d04-7972cbbc7c39' export const PERSONAL_PLAN_ID = 'e147a026-81d7-11eb-afc8-02f048730623' export const PROFESSIONAL_PLAN_ID = '6b5e1e70-045d-11ec-8a08-02ea65a4da2d' export const BUSINESS_PLAN_ID = '85ce6edf-9e70-11ec-b51a-0a63867cb0b9' diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx new file mode 100644 index 000000000..dc52a6c55 --- /dev/null +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -0,0 +1,469 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material' +import React, { useEffect, useMemo, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory, useLocation, useParams } from 'react-router-dom' +import { Attribute } from '../../components/Attributes' +import { Confirm } from '../../components/Confirm' +import { Container } from '../../components/Container' +import { GridList } from '../../components/GridList' +import { GridListItem } from '../../components/GridListItem' +import { Gutters } from '../../components/Gutters' +import { Icon } from '../../components/Icon' +import { LoadingMessage } from '../../components/LoadingMessage' +import { removeObject } from '../../helpers/utilHelper' +import { graphQLAddAddonCustomer, graphQLRemoveAddonCustomer } from '../../services/graphQLMutation' +import { AdminAddonCustomer, AdminAddonProduct } from '../../models/adminAddonLicenses' +import { AI_AGENT_PRODUCT_ID } from '../../models/plans' +import { Dispatch, State } from '../../store' + +/* Add-on licences (graphql-api docs/AI-AGENT-LICENSE.md): one page for every add-on product, not + one per add-on. The product is in the URL (/admin/add-ons/:productId) so a reload, a deep link + and the sidebar's remembered route all land on the same list. ai-agent is the first product; + the next one is a product row on the API and shows up in the selector with no change here. */ + +export const ADMIN_ADDONS_ROUTE = '/admin/add-ons' + +const productLabel = (product?: AdminAddonProduct) => + product ? `${product.description || product.name}${product.enabled ? '' : ' (disabled)'}` : 'add-on' + +// `datetime-local` needs `YYYY-MM-DDTHH:mm` in local time — toISOString() would shift to UTC. +const toInputValue = (date: Date) => { + const offset = date.getTimezoneOffset() * 60000 + return new Date(date.getTime() - offset).toISOString().slice(0, 16) +} + +const Empty: React.FC<{ title: string; body?: string; children?: React.ReactNode }> = ({ title, body, children }) => ( + + + + {title} + + {body && ( + + {body} + + )} + {children} + +) + +type AddonCustomerAttributeOptions = { + customer?: AdminAddonCustomer + /* The add-on the row's licence is for, looked up from the row's own productId — so the row says + what it holds without leaning on the selector above it. */ + product?: AdminAddonProduct +} + +// The ai-agent add-on wears the feature's own mark; any other add-on the generic one. +const addonIcon = (productId?: string) => (productId === AI_AGENT_PRODUCT_ID ? 'remote-ai' : 'puzzle-piece') + +class AddonCustomerAttribute extends Attribute { + type: Attribute['type'] = 'MASTER' + translate = false // internal-only admin registry: render English, skip columns.* translation +} + +const addonCustomerAttributes: AddonCustomerAttribute[] = [ + new AddonCustomerAttribute({ + id: 'email', + label: 'Account', + defaultWidth: 250, + required: true, + value: ({ customer }) => customer?.email || '-', + }), + new AddonCustomerAttribute({ + id: 'addon', + label: 'Add-on', + defaultWidth: 150, + value: ({ product, customer }) => product?.description || product?.name || customer?.productId || '-', + }), + new AddonCustomerAttribute({ + id: 'devices', + label: 'Devices', + defaultWidth: 100, + value: ({ customer }) => customer?.deviceCount ?? 0, + }), + new AddonCustomerAttribute({ + id: 'members', + label: 'Members', + defaultWidth: 100, + value: ({ customer }) => customer?.memberCount ?? 0, + }), + new AddonCustomerAttribute({ + id: 'created', + label: 'Granted', + defaultWidth: 150, + value: ({ customer }) => (customer?.created ? new Date(customer.created).toLocaleDateString() : '-'), + }), + new AddonCustomerAttribute({ + id: 'expiration', + label: 'Expires', + defaultWidth: 170, + value: ({ customer }) => { + if (!customer?.expiration) return '-' + // The row outlives its time-box (the API skips an expired licence in the limits merge but + // keeps the row until it is revoked), so say so rather than show a date that reads as future. + const date = new Date(customer.expiration) + const expired = date.getTime() < Date.now() + return ( + + {expired ? 'Expired ' : ''} + {date.toLocaleDateString()} + + ) + }, + }), +] + +export const AdminAddonLicensesListPage: React.FC = () => { + const dispatch = useDispatch() + const history = useHistory() + const location = useLocation() + const { productId: urlProductId } = useParams<{ productId?: string }>() + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) + /* Each dialog acts on what it was OPENED for, not on the selection at the moment it is confirmed: + the grant dialog captures the product, and the revoke confirm takes the product from the row. + The URL can move the selection while a dialog is up — Back/Forward, or the product refresh + redirecting off a product the API dropped — and a mutation built from the live selection would + then hit product B under a title that said A. Both dialogs also close when that happens. */ + const [grantFor, setGrantFor] = useState(null) + const [grantEmail, setGrantEmail] = useState('') + const [grantExpiration, setGrantExpiration] = useState('') + const [granting, setGranting] = useState(false) + const [removeTarget, setRemoveTarget] = useState(null) + const [removing, setRemoving] = useState(false) + const [searchInput, setSearchInput] = useState('') + + const products = useSelector((state: State) => state.adminAddonLicenses.products) + const productsStatus = useSelector((state: State) => state.adminAddonLicenses.productsStatus) + const productId = useSelector((state: State) => state.adminAddonLicenses.productId) + const customers = useSelector((state: State) => state.adminAddonLicenses.customers) + const listStatus = useSelector((state: State) => state.adminAddonLicenses.listStatus) + const loading = listStatus === 'loading' + const total = useSelector((state: State) => state.adminAddonLicenses.total) + const hasMore = useSelector((state: State) => state.adminAddonLicenses.hasMore) + const searchValue = useSelector((state: State) => state.adminAddonLicenses.searchValue) + + const product = products.find(p => p.id === productId) + const label = productLabel(product) + /* What is on screen is worth showing only if the list ANSWERED for it, or rows are held (a failed + Load More keeps them). Decides between the grid and the four "nothing to list" screens below — + never products.length or customers.length alone: a stale catalogue survives a failed refresh + (fetchProducts keeps what it held), and rows survive a failed page. */ + const listUsable = listStatus === 'loaded' || customers.length > 0 + const retry = ( + + ) + const productOf = (customer: AdminAddonCustomer) => products.find(p => p.id === customer.productId) + const removeLabel = removeTarget ? productLabel(productOf(removeTarget)) : label + + const listAttributes = useMemo( + () => [ + ...addonCustomerAttributes, + new AddonCustomerAttribute({ + id: 'actions', + label: '', + defaultWidth: 48, + align: 'right', + value: ({ customer }) => ( + { + e.stopPropagation() + if (customer) setRemoveTarget(customer) + }} + > + + + ), + }), + ], + [] + ) + const [required, attributes] = removeObject(listAttributes, a => a.required === true) + + useEffect(() => { + setSearchInput(searchValue) + }, []) + + // The URL is the selection, and refresh is the one way in: on mount and on every move of the + // product it re-reads the catalogue, checks the product against it, and fetches the list afresh + // — so a stale link never fires a list request that can only be refused, and a remount never + // shows rows fetched from another API target. + useEffect(() => { + dispatch.adminAddonLicenses.refresh(urlProductId) + }, [urlProductId]) + + // No product in the URL, or one the API no longer lists: go to the product last looked at, else + // the first add-on. Waits for the catalogue so a deep link to a real product is never bounced. + useEffect(() => { + if (productsStatus !== 'loaded' || !products.length) return + if (urlProductId && products.some(p => p.id === urlProductId)) return + const saved = defaultSelection['admin']?.[ADMIN_ADDONS_ROUTE] + const remembered = products.find(p => saved === `${ADMIN_ADDONS_ROUTE}/${p.id}`) + history.replace(`${ADMIN_ADDONS_ROUTE}/${(remembered || products[0]).id}`) + }, [urlProductId, productsStatus, products]) + + // Remember the product for the sidebar's Add-ons entry (AdminSidebarNav.handleNavClick) + useEffect(() => { + if (urlProductId) + dispatch.ui.setDefaultSelected({ key: ADMIN_ADDONS_ROUTE, value: location.pathname, accountId: 'admin' }) + }, [location.pathname]) + + // Enter commits the term; the model refetches for it (and retires the page in flight). + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + dispatch.adminAddonLicenses.setSearch(searchInput) + } + } + + const closeGrantDialog = () => { + setGrantFor(null) + setGrantEmail('') + setGrantExpiration('') + } + + // The selection moved: whatever a dialog was about is no longer on screen. + useEffect(() => { + setRemoveTarget(null) + closeGrantDialog() + }, [productId]) + + const handleGrant = async () => { + const email = grantEmail.trim() + if (!email || !grantFor) return + const grantLabel = productLabel(grantFor) + + // Blank = open-ended. Sent as null, not omitted: the API leaves an OMITTED expiration alone, + // and re-granting a time-boxed holder from a blank form should give the open-ended grant the + // form shows, not silently keep the old date. + const expiration = grantExpiration ? new Date(grantExpiration).toISOString() : null + + setGranting(true) + const result = await graphQLAddAddonCustomer(grantFor.id, email, expiration) + setGranting(false) + + // A refused grant (unknown email, a disabled add-on, a Stripe-owned licence) already surfaced + // the API's own message; the dialog stays open for a correction. + if (result === 'ERROR') return + if (result?.data?.data?.addAddonCustomer) { + closeGrantDialog() + dispatch.ui.set({ successMessage: `Granted ${grantLabel} to ${email}` }) + await dispatch.adminAddonLicenses.fetch() + } else { + dispatch.ui.set({ errorMessage: `Failed to grant ${grantLabel}` }) + } + } + + const handleRemove = async () => { + if (!removeTarget) return + + setRemoving(true) + // The row's own product — the licence being revoked is the one the row showed + const result = await graphQLRemoveAddonCustomer(removeTarget.productId, removeTarget.userId) + setRemoving(false) + + if (result === 'ERROR') return + if (result?.data?.data?.removeAddonCustomer) { + dispatch.ui.set({ successMessage: `Revoked ${removeLabel} from ${removeTarget.email}` }) + setRemoveTarget(null) + await dispatch.adminAddonLicenses.fetch() + } else { + dispatch.ui.set({ errorMessage: `Failed to revoke ${removeLabel}` }) + } + } + + return ( + + {/* Two rows: which add-on and its one action, then the search over that add-on's holders */} + + + history.push(`${ADMIN_ADDONS_ROUTE}/${e.target.value}`)} + sx={{ minWidth: 180 }} + > + {products.map(p => ( + + {productLabel(p)} + + ))} + + {/* A disabled add-on refuses new grants at the API; its existing ones can still be revoked. */} + {product?.enabled && ( + + )} + + setSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + } + > + {/* Each screen is decided by the STATUSES, never by an empty array alone — "nothing has + answered yet", "the answer was no", and "nobody holds it" are different screens. With + nothing usable on screen, a catalogue that failed to answer is the screen even when a + stale catalogue is held (a target switch empties the rows first, then its refresh can fail); + with something usable, the rows stay and the failure is the snackbar. */} + {!listUsable && productsStatus === 'failed' ? ( + + {retry} + + ) : !listUsable && productsStatus !== 'loaded' ? ( + + ) : !products.length ? ( + + ) : !product ? ( + // the redirect above is choosing one + ) : !listUsable && listStatus === 'failed' ? ( + + {retry} + + ) : !listUsable ? ( + + ) : !customers.length ? ( + + ) : ( + + {customers.map(customer => ( + } + required={required?.value({ customer, product: productOf(customer) })} + > + {attributes.map(attribute => ( + + {attribute.id === 'actions' ? ( + attribute.value({ customer }) + ) : ( + + {attribute.value({ customer, product: productOf(customer) })} + + )} + + ))} + + ))} + {hasMore && ( + + + + )} + + )} + + + Grant {productLabel(grantFor ?? undefined)} + + setGrantEmail(e.target.value)} + sx={{ marginTop: 2 }} + /> + setGrantExpiration(e.target.value)} + helperText="Optional — blank grants it open-ended. Granting an account that already holds it replaces its expiration." + sx={{ marginTop: 2 }} + /> + + + + + + + + setRemoveTarget(null)} + > + {removeTarget && ( + <> + Are you sure you want to revoke {removeLabel} from {removeTarget.email}? + The account loses the feature immediately. + + )} + + + ) +} diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index cc00720d8..e6249112d 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -11,6 +11,7 @@ import { isSecureAgentURL, backgroundConnectUrl, backgroundStatus, backgroundDis import { windowOpen } from '../services/browser' import { selectLimitsLookup, selectFeatures } from '../selectors/organizations' import { useSelector, useDispatch } from 'react-redux' +import { useChatEnabled } from '../hooks/useChatEnabled' import { InlineTextFieldSetting } from '../components/InlineTextFieldSetting' import { ListItemSetting } from '../components/ListItemSetting' import { ListItemRadio } from '../components/ListItemRadio' @@ -50,10 +51,16 @@ export const TestPage: React.FC = () => { // grant — enrollment is a browser ceremony at the AS; this page only reads/ends it. (The // one UI entry point for it: without this control backgroundConnectUrl/backgroundStatus // have no caller and the workflow cannot be enabled.) + // Behind the chat's licence gate: without it the section is not shown and the agent service is + // not asked anything. This toggle is a convenience, not the grant's only door: the background + // grant is an OAuth grant held at the AS for the agent's own client, and Account → Connected Apps + // (not gated on the licence) lists and revokes it — killing every token minted from it — whether + // or not this account still has Remote.It AI, and whether or not the agent service answers. + const chatEnabled = useChatEnabled() const [backgroundEnrolled, setBackgroundEnrolled] = useState(undefined) useEffect(() => { - backgroundStatus().then(setBackgroundEnrolled) - }, []) + if (chatEnabled) backgroundStatus().then(setBackgroundEnrolled) + }, [chatEnabled]) async function connectBackground() { await windowOpen(backgroundConnectUrl(), '_blank', true) // The ceremony finishes in the browser — poll briefly for the verdict. @@ -288,53 +295,57 @@ export const TestPage: React.FC = () => { - {t('testPage.aiAgent', 'AI Agent')} - - (backgroundEnrolled ? disableBackground() : connectBackground())} - /> - - - - { - const url = result.toString().trim() - if (url && !isSecureAgentURL(url)) { - setAgentError(t('testPage.agentURLInvalid', 'Agent service URL must start with https://')) - return - } - setAgentError('') - // Reset (or entering the default) CLEARS the override so agentURL() falls back to the - // /agent proxy (dev) or VITE_AGENT_URL (build) — never pinning the OAuth audience as the transport. - setAPIPreference('agentURL', url === OAUTH_AGENT_RESOURCE ? '' : url) - }} - hideIcon - /> - {!!agentError && ( - - - {agentError} - - - )} - - - - + {chatEnabled && ( + <> + {t('testPage.aiAgent', 'AI Agent')} + + (backgroundEnrolled ? disableBackground() : connectBackground())} + /> + + + + { + const url = result.toString().trim() + if (url && !isSecureAgentURL(url)) { + setAgentError(t('testPage.agentURLInvalid', 'Agent service URL must start with https://')) + return + } + setAgentError('') + // Reset (or entering the default) CLEARS the override so agentURL() falls back to the + // /agent proxy (dev) or VITE_AGENT_URL (build) — never pinning the OAuth audience as the transport. + setAPIPreference('agentURL', url === OAUTH_AGENT_RESOURCE ? '' : url) + }} + hideIcon + /> + {!!agentError && ( + + + {agentError} + + + )} + + + + + + )} {t('testPage.features', 'Features')} {features.map(f => ( @@ -346,14 +357,6 @@ export const TestPage: React.FC = () => { state: f.value ? t('testPage.enabled', 'enabled') : t('testPage.disabled', 'disabled'), defaultValue: '{{name}} (default {{state}})', })} - subLabel={ - f.pending - ? t( - 'testPage.featurePending', - 'Not in any license yet. Switches here apply to your personal account only.' - ) - : undefined - } toggle={!!featureValues[f.name]} /* Writes the OVERRIDE, not the effective lookup. Spreading the lookup pinned every OTHER feature at its current value as well, so a later change to the diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 98110fda5..0f0591204 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -63,6 +63,7 @@ import { AdminConfirmPage } from '../pages/AdminConfirmPage' import { AdminAdminsPage } from '../pages/AdminAdminsPage/AdminAdminsPage' import { AdminPartnersPage } from '../pages/AdminPartnersPage/AdminPartnersPage' import { AdminEnterpriseLicensesListPage } from '../pages/AdminEnterpriseLicensesPage/AdminEnterpriseLicensesListPage' +import { AdminAddonLicensesListPage } from '../pages/AdminAddonLicensesPage/AdminAddonLicensesListPage' import { AdminNoticesPage } from '../pages/AdminNoticesPage/AdminNoticesPage' import { PartnerStatsPage } from '../pages/PartnerStatsPage/PartnerStatsPage' import browser, { getOs } from '../services/browser' @@ -439,6 +440,9 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { + + + diff --git a/frontend/src/selectors/organizations.ts b/frontend/src/selectors/organizations.ts index 8f8d46f28..7f944ce97 100644 --- a/frontend/src/selectors/organizations.ts +++ b/frontend/src/selectors/organizations.ts @@ -1,6 +1,5 @@ import { createSelector } from 'reselect' import { REMOTEIT_PRODUCT_ID } from '../models/plans' -import { PENDING_FEATURES } from '../constants' import { getUser, getOrganizations, @@ -98,10 +97,8 @@ export const selectLimitsLookup = createSelector( [selectLimits, isUserAccount, getLimitsOverride], (baseLimits, isUserAccount, limitsOverride): ILookup => { const result: ILookup = {} - // Flags this build knows about but no license carries yet: worth their declared - // default until the API says otherwise, and — being named — something an override can - // take a position on, which a name the lookup has never seen would not be. - Object.entries(PENDING_FEATURES).forEach(([name, value]) => (result[name] = value)) + // Built FROM the limits the API returned: a name no license has mentioned is simply + // absent (falsy), and there is nothing for a Test page override to attach to. baseLimits.forEach(l => (result[l.name] = l.value)) if (isUserAccount) Object.keys(result).forEach(name => { @@ -111,20 +108,14 @@ export const selectLimitsLookup = createSelector( } ) -export type IFeature = { name: string; value: boolean; pending?: boolean } - -/* The boolean features the Test page lists: the ones this account's license mentions, - plus the ones this build forward-declares. `pending` is the difference between "the - license said no" and "no license has mentioned it yet" — the second is a flag still - soft-launching, where the Test page switch is the only way to see the feature. */ -export const selectFeatures = createSelector([selectLimits], (limits): IFeature[] => { - const features: IFeature[] = limits - .filter(l => typeof l.value === 'boolean') - .map(l => ({ name: l.name, value: l.value as boolean })) - for (const [name, value] of Object.entries(PENDING_FEATURES)) - if (!features.some(f => f.name === name)) features.push({ name, value, pending: true }) - return features -}) +export type IFeature = { name: string; value: boolean } + +/* The boolean features the Test page lists: exactly the ones this account's license + mentions. A feature no license carries has no row — it is granted (Admin → Add-ons for + the add-ons), not switched on here. */ +export const selectFeatures = createSelector([selectLimits], (limits): IFeature[] => + limits.filter(l => typeof l.value === 'boolean').map(l => ({ name: l.name, value: l.value as boolean })) +) export const selectLicensesWithLimits = createSelector([selectLicenses, selectLimits], (licenses, limits) => { return { diff --git a/frontend/src/services/graphQLMutation.ts b/frontend/src/services/graphQLMutation.ts index 2e2234b19..23f645ae2 100644 --- a/frontend/src/services/graphQLMutation.ts +++ b/frontend/src/services/graphQLMutation.ts @@ -708,6 +708,37 @@ export async function graphQLRemoveEnterpriseCustomer(userId: string) { ) } +// Add-on licence grants. `expiration` is a String on purpose — the API parses it strictly (a +// malformed date is refused rather than read as open-ended); undefined leaves an existing grant's +// expiry alone, null clears it. +export async function graphQLAddAddonCustomer(product: string, email: string, expiration?: string | null) { + return await graphQLBasicRequest( + ` mutation AddAddonCustomer($product: String!, $email: String!, $expiration: String) { + addAddonCustomer(product: $product, email: $email, expiration: $expiration) { + productId + userId + email + name + deviceCount + memberCount + licenseId + created + expiration + } + }`, + { product, email, expiration } + ) +} + +export async function graphQLRemoveAddonCustomer(product: string, userId: string) { + return await graphQLBasicRequest( + ` mutation RemoveAddonCustomer($product: String!, $userId: String!) { + removeAddonCustomer(product: $product, userId: $userId) + }`, + { product, userId } + ) +} + export async function graphQLAdminUpdateEmail(from: string, to: string) { return await graphQLBasicRequest( ` mutation UpdateEmail($from: String!, $to: String!) { diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index bc03f8853..e6fee7d73 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -646,6 +646,52 @@ export async function graphQLAdminEnterpriseCustomers( ) } +// Add-on licences (graphql-api docs/AI-AGENT-LICENSE.md): generic over add-on products, of which +// ai-agent is the first. A product is selected on the admin page, then its holders are listed. +export async function graphQLAdminAddonProducts() { + return await graphQLBasicRequest( + ` query AdminAddonProducts { + admin { + addonProducts { + id + name + description + enabled + } + } + }` + ) +} + +export async function graphQLAdminAddonCustomers( + product: string, + options: { from?: number; size?: number }, + search?: string +) { + return await graphQLBasicRequest( + ` query AdminAddonCustomers($product: String!, $from: Int, $size: Int, $search: String) { + admin { + addonCustomers(product: $product, from: $from, size: $size, search: $search) { + items { + productId + userId + email + name + deviceCount + memberCount + licenseId + created + expiration + } + total + hasMore + } + } + }`, + { product, from: options.from || 0, size: options.size || 50, search: search || undefined } + ) +} + export async function graphQLAdminPartners() { return await graphQLBasicRequest( ` query AdminPartners { diff --git a/readme.md b/readme.md index ec2eb3fd8..b36f87840 100644 --- a/readme.md +++ b/readme.md @@ -134,8 +134,9 @@ why. Reach Test Settings by holding **shift+option** and clicking your avatar #### AI chat -The chat is always on where `VITE_CHAT_ALWAYS_ON=true` (the AI portal, and locally); elsewhere -it soft-launches behind the Test UI. In dev its requests go through the same-origin `/agent` +The chat is a license feature: it shows only for an account holding the `ai-agent` add-on licence, +which a system admin grants from **Admin → Add-ons** — your dev account included; nothing in a build +or env turns it on otherwise. In dev its requests go through the same-origin `/agent` vite proxy, so `AGENT_PROXY_TARGET` is what selects the service — the deployed dev agent, or `http://localhost:3001` to run `ai-agent` locally. **Settings → Test Settings → Override agent service** overrides it at runtime without a restart (https only). DPoP proofs are signed over From e4b0023af25ed677b4aece874aabc41833824370 Mon Sep 17 00:00:00 2001 From: evanrbowers Date: Mon, 14 Sep 2026 21:59:49 -0700 Subject: [PATCH 219/238] "Sign out everywhere" is one AS call, then the local teardown (#1222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Security page's button now makes the one call permitteer#19 added — POST {issuer}/account/api/devices/sign-out-all: every session of the account, this one included, refresh families swept, resource servers told, and on a bridged stage the legacy pool's tokens revoked — then the local teardown. devices.write joins the permitteer_account declaration (the heal path re-authorizes silently). The background grant is revoked once per identity, before the call; the call is bounded at 10 s; a support session skips it (control hidden). The silent /session/end lane goes. Codex: 2 rounds. 78/78; e2e test:dev vs localhost 81/81 with e2e-tests#20's new spec. --- frontend/src/constants.ts | 3 + frontend/src/models/auth.test.ts | 80 ++++++++++++++++++---- frontend/src/models/auth.ts | 61 +++++++++++++---- frontend/src/models/chat.test.ts | 30 ++++++++ frontend/src/models/chat.ts | 16 ++++- frontend/src/pages/SecurityPage.tsx | 14 +++- frontend/src/services/oidc.ts | 25 ++----- frontend/src/services/permitteerAccount.ts | 10 +++ 8 files changed, 187 insertions(+), 52 deletions(-) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 754c063e3..60b122291 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -154,6 +154,9 @@ export const FRONTEND_RETRY_DELAY = 20000 // How long sign out waits for the local backend to come back before giving up and // tearing down the frontend on its own. Short: it's a localhost socket. export const SIGN_OUT_BACKEND_TIMEOUT = 3000 +// How long "Sign out everywhere" waits for the AS to end every session before signing out +// locally regardless — a stalled token mint must never leave the person signed in here. +export const SIGN_OUT_EVERYWHERE_TIMEOUT = 10000 export const MAX_CONNECTION_NAME_LENGTH = 62 export const MAX_DESCRIPTION_LENGTH = 1024 export const SIDEBAR_WIDTH = 250 diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index defe88d81..e233512f4 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -6,10 +6,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // the hoisted vi.mock factory runs. `browser` and the live `store` state are hoisted MUTABLE // objects so individual tests can steer the electron/backend branch and what the effects // re-read from the store after a teardown. -const { oidcStart, oidcEndSessionSilently, oidcGrantStale, oidcMcpDetailReady, browser, storeState } = vi.hoisted(() => ({ +const { oidcStart, signOutEverywhere, oidcGrantStale, oidcMcpDetailReady, oidcActor, browser, storeState } = vi.hoisted(() => ({ oidcStart: vi.fn(), - oidcEndSessionSilently: vi.fn(), + signOutEverywhere: vi.fn(), oidcGrantStale: vi.fn(), + oidcActor: vi.fn(), oidcMcpDetailReady: vi.fn(), browser: { isElectron: false, hasBackend: false }, storeState: { auth: {} as Record }, @@ -19,11 +20,12 @@ const { oidcStart, oidcEndSessionSilently, oidcGrantStale, oidcMcpDetailReady, b // (an undefined right-hand side of instanceof throws rather than returning false). vi.mock('../services/oidc', () => ({ oidcStart, - oidcEndSessionSilently, oidcGrantStale, oidcMcpDetailReady, + oidcActor, OidcError: class OidcError extends Error {}, })) +vi.mock('../services/permitteerAccount', () => ({ signOutEverywhere })) vi.mock('../services/Controller', () => ({ default: {}, emit: vi.fn(() => false) })) vi.mock('../services/CloudSync', () => ({ default: {} })) vi.mock('../services/cloudController', () => ({ default: {} })) @@ -36,13 +38,13 @@ vi.mock('../services/remoteit', () => ({ getToken: vi.fn(), apiAuthHeaders: vi.f vi.mock('../selectors/devices', () => ({ selectDeviceModelAttributes: vi.fn() })) vi.mock('../store', () => ({ persistor: { purge: vi.fn() }, store: { getState: () => storeState } })) vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) -vi.mock('../constants', () => ({ API_URL: '', DEVELOPER_KEY: '', SIGN_OUT_BACKEND_TIMEOUT: 1000 })) +vi.mock('../constants', () => ({ API_URL: '', DEVELOPER_KEY: '', SIGN_OUT_BACKEND_TIMEOUT: 1000, SIGN_OUT_EVERYWHERE_TIMEOUT: 50 })) vi.mock('axios', () => ({ default: {} })) // The effects are `dispatch => ({...})`; build them against a fake dispatch so each auth.* // call is an observable spy rather than a real reducer/effect. function makeDispatch() { - return { auth: { set: vi.fn(), signedOut: vi.fn(), signOut: vi.fn() }, ui: { set: vi.fn() } } + return { auth: { set: vi.fn(), signedOut: vi.fn(), signOut: vi.fn() }, ui: { set: vi.fn() }, chat: { signOut: vi.fn() } } } // The only shape SignInApp renders: it shows a message ONLY while signInFailed is true, and @@ -56,7 +58,8 @@ const effectsFor = (dispatch: any) => (authModel as any).effects(dispatch) beforeEach(() => { oidcStart.mockReset() - oidcEndSessionSilently.mockReset() + signOutEverywhere.mockReset().mockResolvedValue({ status: 200, body: { ended: 1, pool: 'skipped' } }) + oidcActor.mockReset().mockReturnValue(null) oidcGrantStale.mockReset() oidcMcpDetailReady.mockReset().mockResolvedValue('mcp_type') }) @@ -71,25 +74,72 @@ describe('auth model — sign-in always offers the chooser', () => { }) describe('auth model — sign-out is local to the app', () => { - it('signOut does NOT end the AS session (no oidcEndSessionSilently)', async () => { + it('signOut does NOT end the AS sessions (no signOutEverywhere)', async () => { const dispatch = makeDispatch() await effectsFor(dispatch).signOut(undefined, { auth: { backendAuthenticated: false } }) - expect(oidcEndSessionSilently).not.toHaveBeenCalled() + expect(signOutEverywhere).not.toHaveBeenCalled() // Local teardown still happens. expect(dispatch.auth.signedOut).toHaveBeenCalledTimes(1) }) }) -describe('auth model — "Sign out everywhere" stays AS-wide', () => { - it('globalSignOut ends the AS session BEFORE local teardown', async () => { +/* "Sign out everywhere" is ONE call at the AS — every session of the account, this one + included — and it must run while this app still holds a usable token: before the local + teardown, and after the agent's background grant is revoked (that revocation mints from the + very session the call ends). It is best-effort: the person reaching for the panic button + must end up signed out here whatever the AS answered. */ +describe('auth model — "Sign out everywhere" is one AS call, then the local teardown', () => { + it('globalSignOut revokes the background grant, calls sign-out-all, THEN signs out locally', async () => { const dispatch = makeDispatch() await effectsFor(dispatch).globalSignOut() - expect(oidcEndSessionSilently).toHaveBeenCalledTimes(1) + expect(dispatch.chat.signOut).toHaveBeenCalledTimes(1) + expect(signOutEverywhere).toHaveBeenCalledTimes(1) + expect(dispatch.auth.signOut).toHaveBeenCalledTimes(1) + const [grant, everywhere, local] = [ + dispatch.chat.signOut.mock.invocationCallOrder[0], + signOutEverywhere.mock.invocationCallOrder[0], + dispatch.auth.signOut.mock.invocationCallOrder[0], + ] + expect(grant).toBeLessThan(everywhere) + expect(everywhere).toBeLessThan(local) + }) + + it('a refused sign-out-all still signs the app out locally', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + signOutEverywhere.mockResolvedValue({ status: 403, body: { error: 'insufficient_authorization' } }) + const dispatch = makeDispatch() + await effectsFor(dispatch).globalSignOut() + expect(dispatch.auth.signOut).toHaveBeenCalledTimes(1) + }) + + it('an AS that cannot be reached still signs the app out locally', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + signOutEverywhere.mockRejectedValue(new Error('network down')) + const dispatch = makeDispatch() + await effectsFor(dispatch).globalSignOut() + expect(dispatch.auth.signOut).toHaveBeenCalledTimes(1) + }) + + /* Audience mints serialize through one shared promise; a mint the grant revoke abandoned + mid-stall would queue the AS call behind it for good. The bound is what keeps the panic + button from leaving the person signed in here. */ + it('a call that never answers is cut off at the bound — the local sign-out still follows', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + signOutEverywhere.mockReturnValue(new Promise(() => {})) // never settles + const dispatch = makeDispatch() + await effectsFor(dispatch).globalSignOut() + expect(dispatch.auth.signOut).toHaveBeenCalledTimes(1) + }) + + /* A support session (the id_token carries `act`) holds no refresh token and the AS refuses its + writes: there is nothing to call. Straight to the local teardown, no revoke, no AS round trip. */ + it('a support session goes straight to the local sign-out — nothing is asked of the AS', async () => { + oidcActor.mockReturnValue({ sub: 'op_1' }) + const dispatch = makeDispatch() + await effectsFor(dispatch).globalSignOut() + expect(signOutEverywhere).not.toHaveBeenCalled() + expect(dispatch.chat.signOut).not.toHaveBeenCalled() expect(dispatch.auth.signOut).toHaveBeenCalledTimes(1) - // Order matters: the AS logout must precede the local sign-out. - expect(oidcEndSessionSilently.mock.invocationCallOrder[0]).toBeLessThan( - dispatch.auth.signOut.mock.invocationCallOrder[0] - ) }) }) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 8524f1f68..ae5c1bbab 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -5,7 +5,7 @@ import network from '../services/Network' import browser from '../services/browser' import analytics from '../services/analytics' import { selectDeviceModelAttributes } from '../selectors/devices' -import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT } from '../constants' +import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT, SIGN_OUT_EVERYWHERE_TIMEOUT } from '../constants' import { persistor, store } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken, apiAuthHeaders } from '../services/remoteit' @@ -463,10 +463,9 @@ export default createModel()({ async signOut(_: void, state) { // Sign-out is LOCAL to this app: drop this app's tokens/session (dispatch.auth.signedOut // below). The AS browser session belongs to the user and is NOT ended here — a true - // "sign out everywhere" is a separate, explicit action (oidcEndSessionSilently / - // end_session remain for it). Because signIn always uses prompt=select_account, the next - // sign-in and any reload land on the AS chooser rather than silently SSO-ing back in, so - // no login-prompt guard is needed. + // "sign out everywhere" is a separate, explicit action (globalSignOut). Because signIn + // always uses prompt=select_account, the next sign-in and any reload land on the AS + // chooser rather than silently SSO-ing back in, so no login-prompt guard is needed. // emit returns false when the local socket isn't connected, and // backendAuthenticated can still be true at that moment - the flag is only // cleared once the socket's disconnect event lands. Without checking the @@ -504,7 +503,7 @@ export default createModel()({ await persistor.purge() // LOCAL-ONLY: drop this app's tokens. The AS session is never ended from here — // signing out of the app must not sign the user out of login.* (their browser - // session is theirs; an explicit "sign out everywhere" action can come later). + // session is theirs; the explicit "sign out everywhere" is globalSignOut). oidcClearLocal() /* signInCleared as well as the user: a failure recorded while SIGNED IN — a refused account switch, say — would otherwise survive into the signed-out screen, where @@ -555,13 +554,49 @@ export default createModel()({ Controller.close() }, async globalSignOut() { - // "Sign out everywhere" (SecurityPage) is the EXPLICIT, AS-wide action, distinct from the - // avatar-menu sign-out which is local to this app: end the AS browser session (RP-initiated - // logout) BEFORE the local teardown, so the security control does what it reports. The - // every-device /logout/all lands with Phase 2b. signOut itself stays LOCAL — a failure-path - // or menu sign-out must never end the AS session. - const { oidcEndSessionSilently } = await import('../services/oidc') - await oidcEndSessionSilently() + // "Sign out everywhere" (SecurityPage) is the EXPLICIT, account-wide action, distinct from + // the avatar-menu sign-out which is local to this app. ONE call at the AS ends every session + // of the account — this one included — with each refresh family swept, the resource servers + // told, and on a bridged stage the legacy pool's tokens revoked too (permitteer + // docs/remoteit-desktop-login.md Phase 4e); it runs BEFORE the local teardown, so the + // security control does what it reports, and it needs only the access token this app + // already holds. Best-effort by design: the refusal or outage that a person hits while + // reaching for the panic button must not leave them signed in here, so the local sign-out + // always follows — a miss is logged, never fatal. signOut itself stays LOCAL — a + // failure-path or menu sign-out must never end the AS sessions. + // + // + // A SUPPORT session (an operator viewing as the person) holds no refresh token and can + // mint for nothing but the data plane, and the account API refuses writes from an acted + // token anyway — so there is nothing to call; the control is hidden for it (SecurityPage), + // and this is the backstop: straight to the local teardown. Ending the support session + // itself is the operator's console or the person's account page, never this button. + if (oidcActor()) { + dispatch.auth.signOut() + return + } + // The agent's background grant goes FIRST: chat.signOut revokes it through the agent + // service with a token minted from THIS session, and once the AS has ended the session no + // token can be minted for that call. It revokes once per identity, so the chat.signOut + // inside signedOut() is a real no-op on the far side. + await dispatch.chat.signOut() + // BOUNDED, like the revoke above. Audience mints serialize through one shared promise + // (services/oidc), so a mint the revoke abandoned mid-stall would otherwise queue this call + // behind it indefinitely — and the panic button must never leave the person signed in here + // because the token service was half-open. Past the bound, the local sign-out proceeds and + // the AS is told nothing; that is the failure the mail and the account page can still show. + try { + const { signOutEverywhere } = await import('../services/permitteerAccount') + const r = await Promise.race([ + signOutEverywhere(), + new Promise(resolve => setTimeout(() => resolve(null), SIGN_OUT_EVERYWHERE_TIMEOUT)), + ]) + if (!r) console.warn('SIGN OUT EVERYWHERE timed out — signing out locally') + else if (r.status === 200) console.log('SIGN OUT EVERYWHERE', r.body) + else console.warn('SIGN OUT EVERYWHERE refused', r.status, r.body) + } catch (error) { + console.warn('SIGN OUT EVERYWHERE FAILED', error) + } dispatch.auth.signOut() }, }), diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index bc7bd6482..69f808375 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -381,3 +381,33 @@ describe('chat model — syncTranscript discards a response for a conversation n expect(dispatch.chat.set).not.toHaveBeenCalled() }) }) + +/* The background grant is revoked ONCE per identity. "Sign out everywhere" (models/auth) revokes + it before the AS ends the session, and the local teardown that follows calls signOut again — + a second enrollment DELETE, and another bounded wait on a slow agent, for nothing. reset() + ends every teardown and re-arms it for the next identity. */ +describe('chat model — the background grant is revoked once per identity', () => { + const reducers = (chatModel as any).reducers + const signedInAs = (id: string) => ({ auth: { user: { id } } }) + it('a second signOut for the same identity issues no second revoke; reset re-arms it', async () => { + reducers.reset({}) // whatever an earlier test left behind + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + await fx.signOut(undefined, signedInAs('alice')) + await fx.signOut(undefined, signedInAs('alice')) + expect(backgroundDisable).toHaveBeenCalledTimes(1) + reducers.reset({}) + await fx.signOut(undefined, signedInAs('alice')) + expect(backgroundDisable).toHaveBeenCalledTimes(2) + reducers.reset({}) // leave the module armed for the tests that follow + }) + it('a DIFFERENT identity is never skipped', async () => { + reducers.reset({}) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + await fx.signOut(undefined, signedInAs('alice')) + await fx.signOut(undefined, signedInAs('bob')) + expect(backgroundDisable).toHaveBeenCalledTimes(2) + reducers.reset({}) + }) +}) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 48091ed21..798de1de8 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -184,6 +184,14 @@ const usageLimitMessage = (e: UsageLimitError): string => { } let abortController: AbortController | null = null +/* The background grant is revoked ONCE per signed-in identity: the id whose revoke this cycle has + already issued. "Sign out everywhere" revokes it before the AS call (models/auth globalSignOut) + and the local teardown that follows runs signOut again — without this the second pass issued a + second enrollment DELETE and could hold the teardown for another bounded wait on a slow agent. + Keyed by identity rather than a bare flag so a different account is never skipped. Module + state, like the controller above: it belongs to the process's sign-in cycle, not to persisted + chat state. Cleared by reset(), which every completed teardown ends with. */ +let backgroundRevokedFor: string | null = null /* The GENERATION of the conversation on screen — the one guard for everything that writes fetched chat content into the store. It advances on every event that makes a load already in flight unwanted: a history pick (the pick itself takes the new ticket), New Chat, a send (the user has @@ -521,7 +529,7 @@ export default createModel()({ chat's end. The transcript reset is dispatched by auth.signedOut alongside the other model resets — dispatching it here would land in the purge-to-reload window and re-persist the pre-signout state. */ - async signOut() { + async signOut(_: void, state) { broadcastChatSignout() // Aborting covers the STREAM; the generation covers every other load in flight. Without it a // slow history pick started under this account passed its own guard after the reset (its @@ -534,7 +542,10 @@ export default createModel()({ // grant BEFORE the session tokens vanish. AWAITED but BOUNDED — an unawaited revoke raced // oidcClearLocal(), so its authenticated DELETE minted no token and background AI access // survived sign-out. Awaiting lets the revoke finish while the tokens are still valid; the - // timeout keeps a slow agent from blocking sign-out. + // timeout keeps a slow agent from blocking sign-out. Once per identity (see the marker). + const who = state?.auth?.user?.id + if (who && backgroundRevokedFor === who) return + backgroundRevokedFor = who ?? null await Promise.race([ backgroundDisable().catch(() => {}), new Promise(resolve => setTimeout(resolve, 3000)), @@ -580,6 +591,7 @@ export default createModel()({ return state }, reset() { + backgroundRevokedFor = null // the next sign-in cycle gets its own revoke return { ...defaultChatState } }, }, diff --git a/frontend/src/pages/SecurityPage.tsx b/frontend/src/pages/SecurityPage.tsx index c5530d8cb..0cd4449f2 100644 --- a/frontend/src/pages/SecurityPage.tsx +++ b/frontend/src/pages/SecurityPage.tsx @@ -9,6 +9,7 @@ import { MFASettings } from '../components/MFA/MFASettings' import { PasskeysSettings } from '../components/MFA/PasskeysSettings' import { Dispatch } from '../store' import { useDispatch } from 'react-redux' +import { oidcActor } from '../services/oidc' export const SecurityPage: React.FC = () => { const { t } = useTranslation() @@ -26,8 +27,17 @@ export const SecurityPage: React.FC = () => { - - + {/* A SUPPORT session (an operator viewing as the person — the id_token says so) has nothing + this button can do: no refresh token to mint the account-API audience with, and the AS + refuses writes from an acted token regardless. Offering a "sign out everywhere" that + could only clear this tab would misdescribe itself; the session ends from the operator's + console or the person's account page. */} + {!oidcActor() && ( + <> + + + + )} ) } diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 39b759c4a..66d006222 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -334,7 +334,9 @@ const declared = (): Array<{ resource: string; type: string; actions: string[]; { resource: OAUTH_PASSPORT_RESOURCE, type: 'passport_account', actions: ['profile.read', 'credentials.write'] }, // accounts.read: the OTHER accounts signed in on this browser, served by the account API from // this token's session — first-party apps only (permitteer docs/browser-accounts.md). - { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write', 'accounts.read'] }, + // devices.write: "Sign out everywhere" (SecurityPage) — every session of the account, this + // one included, ended in one call at the AS (permitteer docs/remoteit-desktop-login.md 4e). + { resource: `${OAUTH_ISSUER}/account/api`, type: 'permitteer_account', actions: ['apps.read', 'apps.write', 'accounts.read', 'devices.write'] }, // The AI agent's slice (remoteit-ai-agent.md D5): the stage's MCP detail, delegated // ONWARD to the agent service — `actor` is what stamps may_act into this session's // tokens, which is the exchange's precondition. The slice partitions from any plain @@ -597,23 +599,6 @@ async function refreshOnce(resource: string): Promise { * navigation — the parade of redirect hops was the only thing the front-channel bought * us. Best-effort: an unreachable AS must not block local teardown; the session gate * kills the tokens lazily anyway. */ -export async function oidcEndSessionSilently(): Promise { - const idToken = stored()?.id_token - if (!idToken) return - try { - const d = await discover() - if (!d.end_session_api_endpoint) return - const response = await fetch(d.end_session_api_endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id_token_hint: idToken }), - }) - if (!response.ok && response.status !== 204) console.warn('OIDC SILENT LOGOUT', response.status) - } catch (error) { - console.warn('OIDC SILENT LOGOUT FAILED', error) - } -} - /** Why the last mint for this audience was refused, if it was. */ export const oidcMintError = (resource: string): string | undefined => mintErrors[resource] @@ -626,8 +611,8 @@ export function invalidateOidcToken() { * NOTHING else. App sign-out never ends the AS session (user directive — the browser * session at the AS belongs to the user, not to this app's error handling), and it never * touches the OTHER saved accounts — signing out one identity is not signing out of the - * app's memory of the rest. `oidcSignOut` (RP-initiated end_session) remains for a future - * explicit "sign out everywhere" action only. */ + * app's memory of the rest. The explicit "Sign out everywhere" (models/auth globalSignOut) + * ends the sessions at the AS through the account API before it lands here. */ export function oidcClearLocal() { clearLocal() } diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index d25207f1f..136ad4bb8 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -37,6 +37,16 @@ async function call(path: string, init: RequestInit = {}): Promise> { + return await call('/devices/sign-out-all', { method: 'POST' }) +} + /** The person's connected apps — the AS account API's own view rows, unreshaped. */ export async function accountApps(): Promise> { return await call('/apps') From 138bcb530dc6947e52e1d8c2f5f3541b8eff99c8 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 15 Sep 2026 09:11:10 -0700 Subject: [PATCH 220/238] =?UTF-8?q?chore(platforms):=20sync=20the=20catalo?= =?UTF-8?q?gue=20snapshot=20with=20production=20=E2=80=94=20android=20and?= =?UTF-8?q?=20docker-extension=20instructions=20(#1221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/i18n/locales/de/platforms.json | 6 ++++-- frontend/src/i18n/locales/en/platforms.json | 6 ++++-- frontend/src/i18n/locales/es/platforms.json | 6 ++++-- frontend/src/i18n/locales/ja/platforms.json | 6 ++++-- frontend/src/platforms/catalogue.generated.json | 2 ++ 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/src/i18n/locales/de/platforms.json b/frontend/src/i18n/locales/de/platforms.json index 22804689a..7292fd749 100644 --- a/frontend/src/i18n/locales/de/platforms.json +++ b/frontend/src/i18n/locales/de/platforms.json @@ -12,7 +12,8 @@ }, "android": { "name": "Android", - "description": "Für ein Android-Smartphone oder -Tablet." + "description": "Für ein Android-Smartphone oder -Tablet.", + "instructions": "Installieren Sie die Remote.It ScreenView-App aus dem Play Store, öffnen Sie sie und geben Sie diesen Code ein, um das Gerät zu registrieren." }, "arm": { "name": "Arm Virtual Hardware", @@ -39,7 +40,8 @@ }, "docker-extension": { "name": "Docker Jumpbox-Erweiterung", - "description": "Für Docker Desktop." + "description": "Für Docker Desktop.", + "instructions": "Installieren Sie die Remote.It Jumpbox-Erweiterung in Docker Desktop, öffnen Sie sie und geben Sie diesen Code ein, um die Registrierung abzuschließen." }, "docker-jumpbox": { "name": "Docker Jumpbox", diff --git a/frontend/src/i18n/locales/en/platforms.json b/frontend/src/i18n/locales/en/platforms.json index 8e54bb8b2..f0e9405c9 100644 --- a/frontend/src/i18n/locales/en/platforms.json +++ b/frontend/src/i18n/locales/en/platforms.json @@ -12,7 +12,8 @@ }, "android": { "name": "Android", - "description": "For an Android phone or tablet." + "description": "For an Android phone or tablet.", + "instructions": "Install the Remote.It ScreenView app from the Play Store, open it, and enter this code to register the device." }, "arm": { "name": "Arm Virtual Hardware", @@ -39,7 +40,8 @@ }, "docker-extension": { "name": "Docker Jumpbox Extension", - "description": "For Docker Desktop." + "description": "For Docker Desktop.", + "instructions": "Install the Remote.It Jumpbox extension in Docker Desktop, open it, and enter this code to register." }, "docker-jumpbox": { "name": "Docker Jumpbox", diff --git a/frontend/src/i18n/locales/es/platforms.json b/frontend/src/i18n/locales/es/platforms.json index 4dcad5c31..e8495d406 100644 --- a/frontend/src/i18n/locales/es/platforms.json +++ b/frontend/src/i18n/locales/es/platforms.json @@ -12,7 +12,8 @@ }, "android": { "name": "Android", - "description": "Para un teléfono o tableta Android." + "description": "Para un teléfono o tableta Android.", + "instructions": "Instala la aplicación Remote.It ScreenView desde Play Store, ábrela e introduce este código para registrar el dispositivo." }, "arm": { "name": "Arm Virtual Hardware", @@ -39,7 +40,8 @@ }, "docker-extension": { "name": "Extensión Docker Jumpbox", - "description": "Para Docker Desktop." + "description": "Para Docker Desktop.", + "instructions": "Instala la extensión Remote.It Jumpbox en Docker Desktop, ábrela e introduce este código para completar el registro." }, "docker-jumpbox": { "name": "Docker Jumpbox", diff --git a/frontend/src/i18n/locales/ja/platforms.json b/frontend/src/i18n/locales/ja/platforms.json index 71ac41f1e..619832062 100644 --- a/frontend/src/i18n/locales/ja/platforms.json +++ b/frontend/src/i18n/locales/ja/platforms.json @@ -12,7 +12,8 @@ }, "android": { "name": "Android", - "description": "Androidのスマートフォンまたはタブレット向けです。" + "description": "Androidのスマートフォンまたはタブレット向けです。", + "instructions": "Play StoreからRemote.It ScreenViewアプリをインストールして開き、このコードを入力してデバイスを登録してください。" }, "arm": { "name": "Arm Virtual Hardware", @@ -39,7 +40,8 @@ }, "docker-extension": { "name": "Docker Jumpbox拡張機能", - "description": "Docker Desktop向けです。" + "description": "Docker Desktop向けです。", + "instructions": "Docker DesktopにRemote.It Jumpbox拡張機能をインストールして開き、このコードを入力して登録してください。" }, "docker-jumpbox": { "name": "Docker Jumpbox", diff --git a/frontend/src/platforms/catalogue.generated.json b/frontend/src/platforms/catalogue.generated.json index ea6292e39..74ff0c0ce 100644 --- a/frontend/src/platforms/catalogue.generated.json +++ b/frontend/src/platforms/catalogue.generated.json @@ -103,6 +103,7 @@ "name": "Android", "kind": "code", "description": "For an Android phone or tablet.", + "instructions": "Install the Remote.It ScreenView app from the Play Store, open it, and enter this code to register the device.", "link": "https://play.google.com/store/apps/details?id=it.remote.screenview", "services": [ { @@ -172,6 +173,7 @@ "name": "Docker Jumpbox Extension", "kind": "code", "description": "For Docker Desktop.", + "instructions": "Install the Remote.It Jumpbox extension in Docker Desktop, open it, and enter this code to register.", "services": [], "types": { "1220": "Docker Extension" From a2d0636fd51359bb197d5eab4893575794239aae Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 13:48:48 -0700 Subject: [PATCH 221/238] feat(auth): the authorize request carries the app's language as ui_locales (#1225) --- frontend/src/services/oidc.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 66d006222..5137a3373 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -1,4 +1,5 @@ import browser from './browser' +import i18n from '../i18n' import { OAUTH_ISSUER, OAUTH_CLIENT_ID, OAUTH_GRAPHQL_RESOURCE, OAUTH_PASSPORT_RESOURCE, OAUTH_MCP_RESOURCE, OAUTH_MCP_DETAIL, OAUTH_AGENT_ACTOR, PROTOCOL } from '../constants' /** @@ -405,6 +406,9 @@ export async function oidcStart(opts: { prompt?: 'login' | 'select_account' | 'n authorization_details: JSON.stringify(declared().map(d => ({ type: d.type, actions: d.actions, ...(d.locations ? { locations: d.locations } : {}), ...(d.actor ? { actor: d.actor } : {}) }))), state: flow.state, nonce: flow.nonce, + // The language this app is showing: the sign-in renders in it, and — because the app + // asked rather than the AS guessing from Accept-Language — offers no language picker. + ui_locales: i18n.resolvedLanguage ?? i18n.language, } // Naming WHO is signing in turns a step-up into "confirm it's you" rather than an account // chooser — without it, prompt=login lands on the picker and choosing your own account From 3c9e38e62e13c7d845ed48af02d2f83a25b8acab Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 15:11:45 -0700 Subject: [PATCH 222/238] refactor: drop the retired Cognito MFA lane and other dead code left behind by the permitteer login --- .env.example | 4 +- electron/src/backend/constants.ts | 10 - frontend/src/components/App.tsx | 63 ++--- frontend/src/components/Chat/ChatPanel.tsx | 9 +- frontend/src/components/DoublePanel.tsx | 1 - .../src/components/MFA/MFAConfigureApp.tsx | 74 ----- .../src/components/MFA/MFAConfigureSms.tsx | 111 -------- frontend/src/components/MFA/MFAMethod.tsx | 77 ----- frontend/src/components/MFA/MFAPhoneForm.tsx | 116 -------- frontend/src/components/MFA/MFAPreference.tsx | 228 --------------- .../src/components/MFA/MFASelectMethod.tsx | 52 ---- frontend/src/components/MFA/MFASettings.tsx | 108 +++++-- frontend/src/components/SignInApp.tsx | 3 +- frontend/src/components/TriplePanel.tsx | 37 ++- frontend/src/constants.ts | 1 - frontend/src/hooks/useChatSync.ts | 2 - frontend/src/hooks/usePanelDrag.ts | 6 +- frontend/src/models/auth.ts | 65 +++-- frontend/src/models/chat.ts | 16 +- frontend/src/models/index.ts | 3 - frontend/src/models/mfa.ts | 75 ----- frontend/src/services/agent.ts | 51 ++-- frontend/src/services/oidc.ts | 266 +++++++++++++----- frontend/src/services/post.ts | 6 +- frontend/src/store.ts | 38 ++- frontend/vite.config.ts | 10 +- 26 files changed, 448 insertions(+), 984 deletions(-) delete mode 100644 frontend/src/components/MFA/MFAConfigureApp.tsx delete mode 100644 frontend/src/components/MFA/MFAConfigureSms.tsx delete mode 100644 frontend/src/components/MFA/MFAMethod.tsx delete mode 100644 frontend/src/components/MFA/MFAPhoneForm.tsx delete mode 100644 frontend/src/components/MFA/MFAPreference.tsx delete mode 100644 frontend/src/components/MFA/MFASelectMethod.tsx delete mode 100644 frontend/src/models/mfa.ts diff --git a/.env.example b/.env.example index e20dc0440..37bfb1d83 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,7 @@ # Values below are the DEV stage — the one local development is registered for. # --- Permitteer sign-in (D8: renderer-owned, identical web/desktop) ------------------ -# VITE_* reach the renderer (the whole OIDC client); bare OAUTH_ISSUER reaches the -# Electron main process, which only bounces issuer navigations to the system browser. +# VITE_* reach the renderer (the whole OIDC client). # # VITE_OAUTH_ISSUER is the ONLY var with no built-in fallback — without it the app logs # "VITE_OAUTH_ISSUER is not configured" and sign-in never starts. @@ -24,7 +23,6 @@ VITE_OAUTH_PASSPORT_RESOURCE="https://passport.dev.remote.it/account/api" VITE_OAUTH_AGENT_RESOURCE="https://agent.dev.remote.it" VITE_OAUTH_MCP_RESOURCE="https://cloud.dev.remote.it/mcp" VITE_OAUTH_MCP_DETAIL="remoteit_mcp" -OAUTH_ISSUER="https://login.dev.remote.it" # The GraphQL and WebSocket URLs are NOT set here. They default to the stage named by # VITE_OAUTH_GRAPHQL_RESOURCE above — on the unified front the resource is the TREE, so diff --git a/electron/src/backend/constants.ts b/electron/src/backend/constants.ts index cfc6b015f..fd1a16212 100644 --- a/electron/src/backend/constants.ts +++ b/electron/src/backend/constants.ts @@ -1,10 +1,5 @@ import os from 'os' import path from 'path' -import * as dotenv from 'dotenv' -// Load .env BEFORE reading it: index.ts calls dotenv.config() too, but import hoisting -// runs every module body (including this one) first — reading process.env at module load -// saw only the shell env, so .env-only settings (the OAUTH_* block) never landed. -dotenv.config() const env = process.env //General @@ -17,11 +12,6 @@ export const REDIRECT_URL = env.REDIRECT_URL || PROTOCOL + 'authCallback' export const SIGNOUT_REDIRECT_URL = PROTOCOL + 'signoutCallback' export const API_URL = env.API_URL || 'https://api.remote.it/apv/v27' -// The Permitteer issuer origin (D8: sign-in is RENDERER-owned — the backend never -// touches auth). The main process only needs this to bounce issuer-origin navigations -// out to the system browser (ElectronApp will-navigate). -export const OAUTH_ISSUER = env.OAUTH_ISSUER || '' - // Airbrake error reporting export const AIRBRAKE_PROJECT_ID = 223457 export const AIRBRAKE_PROJECT_KEY = 'e1376551dbe5b1326f98edd78b6247ba' diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 2e70ed072..3a2c2be93 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -30,7 +30,7 @@ import { AnnouncementBanner } from './AnnouncementBanner' import { isChatPopout } from '../services/chatPopout' // Lazy: keeps the chat surface (and its react-markdown dependency tree) out -// of the startup bundle — the feature is dev/Test-UI gated +// of the startup bundle — the feature is licence-gated const ChatPanel = React.lazy(() => import('./Chat/ChatPanel').then(m => ({ default: m.ChatPanel }))) const ChatWindow = React.lazy(() => import('./Chat/ChatWindow').then(m => ({ default: m.ChatWindow }))) @@ -68,7 +68,6 @@ export const App: React.FC = () => { row — it is a column beside the whole app side — so it must not be counted here: the panels' own parent already excludes it, and adding it back subtracted the chat twice, which drove their max width below their minimum and froze the drag. */ - const sidePanelWidth = sidebarWidth const isRootMenu = location.pathname.match(REGEX_FIRST_PATH)?.[0] === location.pathname const showBottomMenu = (mobile || browser.isMobile) && isRootMenu && hideSidebar const needsUserHydration = authenticated && !user @@ -82,7 +81,7 @@ export const App: React.FC = () => { showBottomMenu, singlePanel, triplePanel, - sidePanelWidth, + sidePanelWidth: sidebarWidth, } useViewAsUser() @@ -96,7 +95,7 @@ export const App: React.FC = () => { useEffect(() => { dispatch.ui.set({ layout }) - }, [insets, mobile, showOrgs, hideSidebar, showBottomMenu, singlePanel, triplePanel, sidePanelWidth]) + }, [insets, mobile, showOrgs, hideSidebar, showBottomMenu, singlePanel, triplePanel, sidebarWidth]) if (waitMessage) return ( @@ -158,50 +157,48 @@ export const App: React.FC = () => { /> ) ) : ( - <> + + {/* The app side owns its own chrome. The sidebar, the pages AND the bottom + menu stack in this column, so the docked chat is a full-height column + BESIDE all three rather than a panel the menu runs underneath. */} - {/* The app side owns its own chrome. The sidebar, the pages AND the bottom - menu stack in this column, so the docked chat is a full-height column - BESIDE all three rather than a panel the menu runs underneath. */} - - {hideSidebar ? : } - - - {showBottomMenu && } + {hideSidebar ? : } + - {chatEnabled && ( - - - - )} + {showBottomMenu && } - + {chatEnabled && ( + + + + )} + )} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 43697357e..5aad61c45 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -43,7 +43,6 @@ export const ChatPanel: React.FC = () => { const maxWidth = useChatMaxWidth() const sidebarWidth = useSidebarWidth() const viewport = useViewportWidth() - const panelRef = useRef(null) const dispatch = useDispatch() useChatMainSync() @@ -77,7 +76,6 @@ export const ChatPanel: React.FC = () => { [setWidth, viewport] ) const drag = usePanelDrag(chatWidth, { - panelRef, minWidth: CHAT_PANEL_WIDTH_MIN, getMaxWidth, onChange: publishIfLayoutChanges, @@ -137,7 +135,6 @@ export const ChatPanel: React.FC = () => { // owns its safe-area inset rather than leaving it to the menu below it paddingBottom: insets?.bottomPx || 1.5, }} - ref={panelRef} > {docked && } @@ -153,7 +150,11 @@ export const ChatPanel: React.FC = () => { {/* "Close chat", not "Close": the title is this icon-only button's accessible name, and a bare "Close" is ambiguous beside every other close on the page — for a screen reader, and for the e2e suite, which dismisses a docked chat by that name (helpers/app.ts). */} - dispatch.chat.set({ open: false })} /> + dispatch.chat.set({ open: false })} + />
diff --git a/frontend/src/components/DoublePanel.tsx b/frontend/src/components/DoublePanel.tsx index 15feb55d3..07a594c31 100644 --- a/frontend/src/components/DoublePanel.tsx +++ b/frontend/src/components/DoublePanel.tsx @@ -36,7 +36,6 @@ export const DoublePanel: React.FC = ({ left, right, layout, header = tru }, [secondaryMinWidth, sidePanelWidth]) const drag = usePanelDrag(panelWidth, { - panelRef: primaryRef, minWidth: MIN_WIDTH, getMaxWidth, onPersist: setPanelWidth, diff --git a/frontend/src/components/MFA/MFAConfigureApp.tsx b/frontend/src/components/MFA/MFAConfigureApp.tsx deleted file mode 100644 index 9d26be7e1..000000000 --- a/frontend/src/components/MFA/MFAConfigureApp.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import { QRCodeSVG } from 'qrcode.react' -import { Box, Button, TextField, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { spacing, radius } from '../../styling' - -type Props = { - email?: string - totpCode?: string - loadTotpCode: () => Promise - totpVerified: boolean - sendVerifyTotp: (event: any) => void - setTotpVerificationCode: (event: any) => void - totpVerificationCode: string - loading: boolean - cancel: (event: any) => void -} - -export const MFAConfigureApp: React.FC = ({ - email, - totpCode, - loadTotpCode, - totpVerified, - sendVerifyTotp, - setTotpVerificationCode, - totpVerificationCode, - loading, - cancel, -}) => { - const { t } = useTranslation() - return ( - - - - - - - {t('mfaConfigureApp.scanInstructions', 'Scan this QR Code with your Authenticator app.')} - - {t('mfaConfigureApp.code', { code: totpCode, defaultValue: 'Code: {{code}}' })} - - {!totpVerified && ( -
- - setTotpVerificationCode(e.currentTarget.value.trim())} - value={totpVerificationCode} - /> -     - - - -
- )} -
-
- ) -} - diff --git a/frontend/src/components/MFA/MFAConfigureSms.tsx b/frontend/src/components/MFA/MFAConfigureSms.tsx deleted file mode 100644 index 0997f5053..000000000 --- a/frontend/src/components/MFA/MFAConfigureSms.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react' -import { Link } from '../Link' -import { Notice } from '../Notice' -import { MFAPhoneForm } from './MFAPhoneForm' -import { Box, Button, TextField, Typography } from '@mui/material' -import { useDispatch, useSelector } from 'react-redux' -import { useTranslation } from 'react-i18next' -import { State, Dispatch } from '../../store' - -type Props = { - cancelEditPhone: () => void - successfulPhoneUpdate: (orginalNumber: any, newNumber: any) => Promise - sendVerifyPhone: (event: any) => void - hasOldSentVerification: boolean - verificationCode: string - loading: boolean - resendCode: (event: any) => void - setCancelShowVerificationCode: (event: any) => void -} - -export const MFAConfigureSms: React.FC = ({ - cancelEditPhone, - successfulPhoneUpdate, - sendVerifyPhone, - hasOldSentVerification, - verificationCode, - loading, - resendCode, - setCancelShowVerificationCode, -}) => { - const { mfa } = useDispatch() - const { showPhone, showVerificationCode } = useSelector((state: State) => state.mfa) - const AWSUser = useSelector((state: State) => state.auth.AWSUser) - const AWSPhone = AWSUser.phone_number || '' - const { t } = useTranslation() - return ( - <> - {showPhone && ( - { - cancelEditPhone() - mfa.set({ showSMSConfig: false }) - }} - onSuccess={successfulPhoneUpdate} - /> - )} - {showVerificationCode && ( - <> -
- - {hasOldSentVerification ? ( - <> - {t( - 'mfaConfigureSms.previouslySent', - 'A verification code had previously been sent to your mobile device.' - )}{' '} - - {t( - 'mfaConfigureSms.previouslySentHint', - 'A code is only valid for 24 hours. Please request the code again if it has been over 24 hours since requested.' - )} - - - ) : ( - <> - {t('mfaConfigureSms.sent', { phone: AWSPhone, defaultValue: 'A verification code has been sent to your mobile device. {{phone}}' })} - {t('mfaConfigureSms.sentHint', 'This code is only valid for 24 hours.')} - - )} - - - mfa.set({ verificationCode: e.currentTarget.value.trim() })} - value={verificationCode} - /> -     - - - -
- - {t('mfaConfigureSms.didNotReceive', "Didn't receive the verification code?")} - {t('mfaConfigureSms.resendCode', 'Resend Verification Code')}{' '} - {t('common.or', 'or')} - { - mfa.set({ showPhone: true, showVerificationCode: false }) - setCancelShowVerificationCode(true) - }} - > - {t('mfaConfigureSms.changePhoneNumber', 'Change your verification phone number')} - - - - )} - - ) -} diff --git a/frontend/src/components/MFA/MFAMethod.tsx b/frontend/src/components/MFA/MFAMethod.tsx deleted file mode 100644 index 47d510299..000000000 --- a/frontend/src/components/MFA/MFAMethod.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { List } from '@mui/material' -import { ListItemCopy } from '../ListItemCopy' -import { Box, Button, Typography } from '@mui/material' -import { ColorChip } from '../ColorChip' -import { IMfa } from '../../models/mfa' -import { spacing } from '../../styling' - -const chipSx = { - marginTop: `${spacing.lg}px`, - marginBottom: `${spacing.sm}px`, - display: 'flex', - alignItems: 'center', - '& > *': { marginRight: `${spacing.md}px` }, -} as const - -type Props = { - method?: IMfa['mfaMethod'] - phoneNumber: string - verified?: boolean - backupCode?: string - loading?: boolean - onClick: () => void -} - -export const MFAMethod: React.FC = ({ method, phoneNumber, verified, backupCode, loading, onClick }) => { - const { t } = useTranslation() - return ( - <> - {/* Authenticator Enabled */} - {method === 'SOFTWARE_TOKEN_MFA' && ( - - - - )} - - {/* SMS Enabled */} - {method === 'SMS_MFA' && ( - - - {verified && ( - <> - {phoneNumber} - - {t('mfaMethod.verified', 'Verified')} - - - )} - - )} - - {(method === 'SMS_MFA' || method === 'SOFTWARE_TOKEN_MFA') && ( - <> - - - - - {t( - 'mfaMethod.recoveryCodeDescription', - 'The recovery code is used to access your account in the event you cannot receive two-factor authentication codes.' - )}{' '} -
- {t( - 'mfaMethod.recoveryCodeWarning', - 'Treat your recovery code with the same level of attention as you would your password.' - )} -
- - - )} - - ) -} - diff --git a/frontend/src/components/MFA/MFAPhoneForm.tsx b/frontend/src/components/MFA/MFAPhoneForm.tsx deleted file mode 100644 index ee1352072..000000000 --- a/frontend/src/components/MFA/MFAPhoneForm.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { MuiTelInput, matchIsValidTel } from 'mui-tel-input' -import React, { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useDispatch, useSelector } from 'react-redux' -import { State, Dispatch } from '../../store' -import { Typography, Button, Box } from '@mui/material' -import { Notice } from '../Notice' - -export interface Props { - onClose: () => void - onSuccess: (orignalNumber, newNumber) => void -} - -export const MFAPhoneForm: React.FC = ({ onClose, onSuccess }) => { - const { t } = useTranslation() - const AWSUser = useSelector((state: State) => state.auth.AWSUser) - const mfaMethod = useSelector((state: State) => state.mfa.mfaMethod) - const { mfa } = useDispatch() - const AWSPhone = AWSUser.phone_number || '' - const originalPhone = AWSUser.phone_number - const [phone, setPhone] = useState(AWSPhone) - const [error, setError] = React.useState(null) - const [message, setMessage] = React.useState(null) - const [loading, setLoading] = React.useState(false) - - const updateUsersPhone = event => { - event.preventDefault() - if (AWSUser.phone_number !== phone) { - setError(null) - setMessage(null) - setLoading(true) - mfa - .updatePhone(phone) - .then(() => { - onSuccess(originalPhone, phone) - }) - .catch(error => { - console.error(error) - setError(error.message) - }) - .finally(() => { - setLoading(false) - }) - } else { - onSuccess(originalPhone, phone) - } - } - return ( - - {error && ( - - {error} - - )} - {message && ( - - {message} - - )} - {AWSUser && AWSUser.phone_number_verified && AWSPhone && ( - <> - {mfaMethod === 'SMS_MFA' && ( - - {t( - 'mfaPhoneForm.disableWarning', - 'Updating your mobile device number will disable two-factor authentication until the number is verified.' - )} - - )} - - {t('mfaPhoneForm.updateTitle', 'Update your mobile device number and send verification code.')} - - - )} - {AWSUser && !AWSPhone && ( - - {t('mfaPhoneForm.enterTitle', 'Enter your mobile number so we can send you the verification code')} - - )} - {AWSUser.phone_number_verified && AWSPhone === phone && ( - - {t('mfaPhoneForm.verified', 'Your mobile device is verified.')} - - )} -
- - setPhone(value)} - /> - - - - {t( - 'mfaPhoneForm.disclaimer', - 'We will only use this number for account security. Message and data rates may apply.' - )} - - - - - - -
-
- ) -} diff --git a/frontend/src/components/MFA/MFAPreference.tsx b/frontend/src/components/MFA/MFAPreference.tsx deleted file mode 100644 index ed611b539..000000000 --- a/frontend/src/components/MFA/MFAPreference.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React, { useState } from 'react' -import { State, Dispatch } from '../../store' -import { Box, Button, Typography, Divider } from '@mui/material' -import { useDispatch, useSelector } from 'react-redux' -import { useTranslation } from 'react-i18next' -import { MFASelectMethod } from './MFASelectMethod' -import { MFAConfigureApp } from './MFAConfigureApp' -import { MFAConfigureSms } from './MFAConfigureSms' -import { ColorChip } from '../ColorChip' -import { MFAMethod } from './MFAMethod' -import { Gutters } from '../Gutters' - -export const MFAPreference: React.FC = () => { - const { t } = useTranslation() - const AWSUser = useSelector((state: State) => state.auth.AWSUser) - const { mfaMethod, verificationCode, showMFASelection, showSMSConfig, backupCode } = useSelector( - (state: State) => state.mfa - ) - const { mfa } = useDispatch() - const [showEnableSelection, setShowEnableSelection] = useState(mfaMethod === 'NO_MFA') - const [showAuthenticatorConfig, setShowAuthenticatorConfig] = useState(false) - const [totpCode, setTotpCode] = useState() - const [totpVerified] = useState(false) - const [totpVerificationCode, setTotpVerificationCode] = useState('') - const [cancelShowVerificationCode, setCancelShowVerificationCode] = useState(false) - const [loading, setLoading] = useState(false) - const [verificationMethod, setVerificationMethod] = useState('sms') - const [hasOldSentVerification, setHasOldSentVerification] = useState( - AWSUser && !AWSUser.phone_number_verified - ) - - const AWSPhone = AWSUser.phone_number || '' - const loadTotpCode = async () => setTotpCode(await mfa.getTotpCode()) - const setVerificationCode = (verificationCode: string) => mfa.set({ verificationCode }) - const setShowPhone = (showPhone: boolean) => mfa.set({ showPhone }) - const setShowMFASelection = (showMFASelection: boolean) => mfa.set({ showMFASelection }) - const setShowVerificationCode = (showVerificationCode: boolean) => mfa.set({ showVerificationCode }) - const setShowSMSConfig = (showSMSConfig: boolean) => mfa.set({ showSMSConfig }) - - const sendVerifyTotp = async event => { - event.preventDefault() - setLoading(true) - await mfa.verifyTotpCode(totpVerificationCode) - setShowAuthenticatorConfig(false) - setLoading(false) - } - - const cancelTotp = () => { - setShowEnableSelection(true) - setShowAuthenticatorConfig(false) - } - - const successfulPhoneUpdate = async (orginalNumber, newNumber) => { - setShowPhone(false) - setVerificationCode('') - if (AWSUser && AWSUser.phone_number_verified && orginalNumber === newNumber && mfaMethod !== 'SMS_MFA') { - //no update to verified phone number, so just enable MFA - await mfa.setMFAPreference('SMS_MFA') - setShowSMSConfig(false) - } else if (AWSUser && orginalNumber === newNumber && !AWSUser.phone_number_verified) { - //not updating the phone but it needs to verify - setHasOldSentVerification(true) - setShowVerificationCode(true) - } else { - //new phone number and needs to verify - setHasOldSentVerification(false) - setShowVerificationCode(true) - } - } - - const sendVerifyPhone = async event => { - event.preventDefault() - setLoading(true) - await mfa.verifyPhone(verificationCode) - setVerificationCode('') - setCancelShowVerificationCode(false) - setHasOldSentVerification(false) - setShowPhone(false) - setShowVerificationCode(false) - setLoading(false) - } - - const resendCode = async event => { - event.preventDefault() - setLoading(true) - await mfa.updatePhone(AWSPhone) - setHasOldSentVerification(false) - setVerificationCode('') - setShowVerificationCode(true) - setShowPhone(false) - setCancelShowVerificationCode(true) - setLoading(false) - } - - const cancelEditPhone = () => { - if (cancelShowVerificationCode) { - setCancelShowVerificationCode(false) - setShowPhone(false) - setShowVerificationCode(true) - } else { - setShowPhone(false) - setShowVerificationCode(false) - setShowEnableSelection(true) - } - } - - const changeVerificationMethod = (type: any) => { - setVerificationMethod(type) - } - - const nextVerificationMethod = () => { - if (verificationMethod === 'sms') { - setShowMFASelection(false) - setShowSMSConfig(true) - setShowPhone(true) - } else { - loadTotpCode() - setShowMFASelection(false) - setShowAuthenticatorConfig(true) - } - } - - if (AWSUser && AWSUser.authProvider === 'Google') { - return ( - <> - {t('mfaPreference.title', 'Two-factor Authentication')} - - - {t( - 'mfaPreference.googleSignInNotice', - 'You are signed in with your Google account. You can enable two-factor authentication in your Google account settings. If you also have Remote.It login and password, you can sign in with those credentials and then enable two-factor authentication.' - )} - - - - ) - } - - if (AWSUser) { - // let totp_code = await mfa.getTotpCode() - return ( - <> - {t('mfaPreference.title', 'Two-factor Authentication')} - - - {t( - 'mfaPreference.description', - 'Add an additional layer of security to your account by requiring more than just a password to sign in.' - )} - - - { - setShowEnableSelection(true) - mfa.setMFAPreference('NO_MFA') - }} - /> - - {/* Show Enable Two-Factor*/} - {mfaMethod === 'NO_MFA' && showEnableSelection && ( - - - - - - - )} - - {/* Select Two-Factor Method */} - {mfaMethod === 'NO_MFA' && showMFASelection && ( - - )} - {/* CONFIGURE Authenticator App */} - {mfaMethod === 'NO_MFA' && showAuthenticatorConfig && ( - - )} - - {/* CONFIGURE SMS */} - {mfaMethod === 'NO_MFA' && showSMSConfig && ( - - )} - - - ) - } - - return null -} diff --git a/frontend/src/components/MFA/MFASelectMethod.tsx b/frontend/src/components/MFA/MFASelectMethod.tsx deleted file mode 100644 index 8e44e0b7c..000000000 --- a/frontend/src/components/MFA/MFASelectMethod.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { Box, Button, MenuItem, TextField, Typography } from '@mui/material' - -type Props = { - verificationMethod: string - changeVerificationMethod: (e: any) => void - nextVerificationMethod: () => void - setShowEnableSelection: (e: any) => void - setShowMFASelection: (e: any) => void -} - -export const MFASelectMethod: React.FC = ({ - verificationMethod, - changeVerificationMethod, - nextVerificationMethod, - setShowEnableSelection, - setShowMFASelection, -}) => { - const { t } = useTranslation() - return ( - - - {t('mfaSelectMethod.chooseMethod', 'Choose a verification method:')} - - changeVerificationMethod(e.target.value)} - > - {t('mfaSelectMethod.smsNumber', 'SMS Number')} - {t('mfaSelectMethod.authenticatorApp', 'Authenticator app')} - - - - - - - ) -} diff --git a/frontend/src/components/MFA/MFASettings.tsx b/frontend/src/components/MFA/MFASettings.tsx index d8d816721..f8281a024 100644 --- a/frontend/src/components/MFA/MFASettings.tsx +++ b/frontend/src/components/MFA/MFASettings.tsx @@ -4,8 +4,14 @@ import { useTranslation } from 'react-i18next' import { Box, Button, Chip, Radio, RadioGroup, FormControlLabel, TextField, Typography } from '@mui/material' import { Gutters } from '../Gutters' import { - selfMfaStanding, selfMfaEnroll, selfMfaConfirm, selfMfaPrefer, selfMfaDisable, selfChallenge, - MfaMethod, SelfContinuation, + selfMfaStanding, + selfMfaEnroll, + selfMfaConfirm, + selfMfaPrefer, + selfMfaDisable, + selfChallenge, + MfaMethod, + SelfContinuation, } from '../../services/passportSelf' import { OAUTH_PASSPORT_RESOURCE } from '../../constants' @@ -43,13 +49,21 @@ export const MFASettings: React.FC = () => { const refresh = async () => { const standing = await selfMfaStanding() if (standing.httpStatus === 403) return setStep({ at: 'none' }) - setStep({ at: 'view', methods: standing.methods ?? [], preferred: standing.preferred, available: standing.available ?? ['totp'] }) + setStep({ + at: 'view', + methods: standing.methods ?? [], + preferred: standing.preferred, + available: standing.available ?? ['totp'], + }) } useEffect(() => { refresh() }, []) - const followContinuation = (r: SelfContinuation & { httpStatus: number }, pending: { mode: Mode; method?: MfaMethod }): boolean => { + const followContinuation = ( + r: SelfContinuation & { httpStatus: number }, + pending: { mode: Mode; method?: MfaMethod } + ): boolean => { if (r.status === 'ok') { if (r.recovery_codes?.length) setStep({ at: 'codes', codes: r.recovery_codes }) else refresh() @@ -73,34 +87,39 @@ export const MFASettings: React.FC = () => { const submitPassword = async (mode: Mode, method?: MfaMethod) => { setBusy(true) const r = - mode === 'enroll' ? await selfMfaEnroll(password, method ?? 'totp', method === 'sms' ? phone : undefined) - : mode === 'prefer' ? await selfMfaPrefer(password, method ?? 'totp') - : await selfMfaDisable(password, method) + mode === 'enroll' + ? await selfMfaEnroll(password, method ?? 'totp', method === 'sms' ? phone : undefined) + : mode === 'prefer' + ? await selfMfaPrefer(password, method ?? 'totp') + : await selfMfaDisable(password, method) setBusy(false) setPassword('') if (followContinuation(r, { mode, method })) return setStep({ - at: 'password', mode, method, - error: r.error === 'invalid_password' - ? t('mfa.wrongPassword', "That password didn't match.") - : r.error_description || t('mfa.failed', 'Something went wrong — try again.'), + at: 'password', + mode, + method, + error: + r.error === 'invalid_password' + ? t('mfa.wrongPassword', "That password didn't match.") + : r.error_description || t('mfa.failed', 'Something went wrong — try again.'), }) } const submitCode = async () => { setBusy(true) const current = step as Extract - const r = current.at === 'scan' - ? await selfMfaConfirm(current.challenge, code) - : await selfChallenge(current.challenge, { code }) + const r = + current.at === 'scan' + ? await selfMfaConfirm(current.challenge, code) + : await selfChallenge(current.challenge, { code }) setBusy(false) setCode('') const pending = current.at === 'relay' ? current.pending : { mode: 'enroll' as Mode } if (followContinuation(r, pending)) return if (r.challenge) { const error = t('mfa.wrongCode', "That code didn't match — try again.") - if (current.at === 'scan') setStep({ ...current, challenge: r.challenge, error }) - else setStep({ ...current, challenge: r.challenge, error }) + setStep({ ...current, challenge: r.challenge, error }) return } refresh() @@ -133,7 +152,12 @@ export const MFASettings: React.FC = () => { 'You sign in with an identity provider (like Google), so your password and two-factor are managed there. To add a Remote.It password — usable alongside your provider — set one up first.' )} - @@ -156,7 +180,13 @@ export const MFASettings: React.FC = () => { {enrolled ? ( <> @@ -170,7 +200,12 @@ export const MFASettings: React.FC = () => { ) : ( - )} @@ -186,15 +221,19 @@ export const MFASettings: React.FC = () => { ) - if (step.at === 'password') + if (step.at === 'password') { + const needsPhone = step.mode === 'enroll' && step.method === 'sms' return ( <> {title} - {t('mfa.confirmPassword', 'Confirm your password to continue — changing a credential re-proves the one you hold.')} + {t( + 'mfa.confirmPassword', + 'Confirm your password to continue — changing a credential re-proves the one you hold.' + )} - {step.mode === 'enroll' && step.method === 'sms' && ( + {needsPhone && ( { /> )} { variant="contained" color="primary" size="small" - disabled={!password || busy || (step.mode === 'enroll' && step.method === 'sms' && !phone)} + disabled={!password || busy || (needsPhone && !phone)} onClick={() => submitPassword(step.mode, step.method)} > {t('common.continue', 'Continue')} @@ -234,6 +273,7 @@ export const MFASettings: React.FC = () => { ) + } if (step.at === 'select') return ( @@ -245,7 +285,12 @@ export const MFASettings: React.FC = () => { setChoice(e.target.value as MfaMethod)}> {step.options.map(o => ( - } label={t(`mfa.method.${o}`, METHOD_LABEL[o] ?? o)} /> + } + label={t(`mfa.method.${o}`, METHOD_LABEL[o] ?? o)} + /> ))} @@ -307,7 +352,13 @@ export const MFASettings: React.FC = () => { )} - + 0} + title={saving ? t('common.saving', 'Saving…') : t('connectedAppDetailPage.save', 'Save changes')} + color="primary" + size="small" + disabled={saving} + onClick={save} + confirmProps={{ + title: t('connectedAppDetailPage.save', 'Save changes'), + action: t('connectedAppDetailPage.save', 'Save changes'), + children: ( + + {t('connectedAppDetailPage.confirmExtend', { + name, + list: adding.join(', '), + defaultValue: 'Give {{name}} access it does not have yet?\n\nAdding: {{list}}', + })} + + ), + }} + /> @@ -447,55 +521,59 @@ export const ConnectedAppDetailPage: React.FC = () => { {agent.active ? ( - <> - {t('connectedAppDetailPage.revokeSection', 'Revoke access')} - - - {t('connectedAppDetailPage.revokeExplain', { - name, - defaultValue: 'Signs {{name}} out of your account and blocks it from getting new access. It can request access again by signing in.', - })} - - - - {name}{' '} - {t('connectedAppDetailPage.signOutBefore', 'will be signed out and can no longer get new access.')} - {reach?.delayed?.length ? ( - <> - {' '} - {t('connectedAppDetailPage.delayedReach', { - apis: reach.delayed.join(', '), - window: revokeWindow(reach.delayMinutes), - defaultValue: 'Access already in progress at {{apis}} ends within {{window}}.', - })} - - ) : null} - - - {t('connectedAppDetailPage.requestAgain', 'It can request access again by signing in.')} - - - ), - }} - onClick={async () => { - await dispatch.agents.revoke(agent.id) - back() - }} - /> - - + <> + {t('connectedAppDetailPage.revokeSection', 'Revoke access')} + + + {t('connectedAppDetailPage.revokeExplain', { + name, + defaultValue: + 'Signs {{name}} out of your account and blocks it from getting new access. It can request access again by signing in.', + })} + + + + {name}{' '} + {t( + 'connectedAppDetailPage.signOutBefore', + 'will be signed out and can no longer get new access.' + )} + {reach?.delayed?.length ? ( + <> + {' '} + {t('connectedAppDetailPage.delayedReach', { + apis: reach.delayed.join(', '), + window: humanizeDuration(reach.delayMinutes * 60_000, { units: ['m'], round: true }), + defaultValue: 'Access already in progress at {{apis}} ends within {{window}}.', + })} + + ) : null} + + + {t('connectedAppDetailPage.requestAgain', 'It can request access again by signing in.')} + + + ), + }} + onClick={async () => { + await dispatch.agents.revoke(agent.id) + back() + }} + /> + + ) : null} ) diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 5ca66badd..4ecc84e6b 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,8 +1,17 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import cloudSync from '../services/CloudSync' -import { TEST_HEADER, GRAPHQL_API, OAUTH_AGENT_RESOURCE } from '../constants' +import { + TEST_HEADER, + GRAPHQL_API, + OAUTH_AGENT_RESOURCE, + CLOUD_TREE_RE, + LEGACY_GRAPHQL_RE, + LEGACY_EVENTS_RE, + cloudTreeUrls, +} from '../constants' import { Dispatch, State } from '../store' +import { UIState } from '../models/ui' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL, resourceForApiURL } from '../helpers/apiHelper' import { bindableResources } from '../services/permitteerAccount' @@ -20,6 +29,7 @@ import { PortalUI } from '../components/PortalUI' import { Title } from '../components/Title' import { Quote } from '../components/Quote' import { emit } from '../services/Controller' +import sleep from '../helpers/sleep' export const TestPage: React.FC = () => { const { t } = useTranslation() @@ -32,9 +42,9 @@ export const TestPage: React.FC = () => { const features = useSelector(selectFeatures) const overrides = useSelector((state: State) => state.ui.limitsOverride) - async function setAPIPreference(key: string, value: string | number | boolean) { - await dispatch.ui.setPersistent({ apis: { ...apis, [key]: value } }) - emit('preferences', { [key]: value }) + async function setAPIPreferences(values: UIState['apis']) { + await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) + emit('preferences', values) } // --- the stage-pair switcher (D10+D11a, permitteer docs/remoteit-desktop-login.md 4c) ---- @@ -64,7 +74,7 @@ export const TestPage: React.FC = () => { await windowOpen(backgroundConnectUrl(), '_blank', true) // The ceremony finishes in the browser — poll briefly for the verdict. for (let i = 0; i < 30; i++) { - await new Promise(r => setTimeout(r, 2000)) + await sleep(2000) if (await backgroundStatus()) break } setBackgroundEnrolled(await backgroundStatus()) @@ -89,20 +99,19 @@ export const TestPage: React.FC = () => { for (const target of targets) { // The UNIFIED FRONT (graphql-permitteer docs/CLOUD-EDGE.md). The identifier is not a URL to // call: graphql and the socket hang off it, and one audience covers both. - const cloud = target.identifier.match(/^https:\/\/cloud(?:\.([a-z0-9-]+))?\.remote\.it\/api$/) + const cloud = target.identifier.match(CLOUD_TREE_RE) if (cloud) { const key = `cloud:${cloud[1] || 'prod'}` pairs.set(key, { ...at(key, target.name), name: target.name, - graphql: `${target.identifier}/graphql`, - ws: `${target.identifier.replace(/^https:/, 'wss:')}/ws`, + ...cloudTreeUrls(target.identifier), resources: [target.identifier], }) continue } - const gql = target.identifier.match(/^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/) - const ws = target.identifier.match(/^wss:\/\/ws(?:\.([a-z0-9-]+))?\.remote\.it\/v1$/) + const gql = target.identifier.match(LEGACY_GRAPHQL_RE) + const ws = target.identifier.match(LEGACY_EVENTS_RE) if (!gql && !ws) continue // passport / account-api entries are not switch targets const stage = (gql?.[1] ?? ws?.[1]) || 'prod' const key = `legacy:${stage}` @@ -133,26 +142,22 @@ export const TestPage: React.FC = () => { async function selectCustom() { setMintError('') setCustomMode(true) - const values = { + await setAPIPreferences({ switchApi: true, apiGraphqlURL: apis.apiGraphqlURL || getApiURL() || '', webSocketURL: apis.webSocketURL || getWebSocketURL() || '', - } - await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) - emit('preferences', values) + }) } async function selectStage(pair: StagePair) { setMintError('') setCustomMode(false) const isDefault = pair.graphql === GRAPHQL_API - const values = { + await setAPIPreferences({ switchApi: !isDefault, apiGraphqlURL: pair.graphql!, ...(pair.ws ? { webSocketURL: pair.ws } : {}), - } - await dispatch.ui.setPersistent({ apis: { ...apis, ...values } }) - emit('preferences', values) + }) try { // One mint per RESOURCE, which is two on a legacy stage and one on the unified front — where // asking for the socket URL separately would answer invalid_target, correctly. @@ -269,7 +274,7 @@ export const TestPage: React.FC = () => { onSave={async result => { const url = result.toString() setMintError('') - await setAPIPreference('apiGraphqlURL', url) + await setAPIPreferences({ apiGraphqlURL: url }) try { await oidcAccessToken(resourceForApiURL(url)) } catch (error) { @@ -287,7 +292,7 @@ export const TestPage: React.FC = () => { resetValue={getWebSocketURL()} maxLength={200} onSave={url => { - setAPIPreference('webSocketURL', url) + setAPIPreferences({ webSocketURL: url.toString() }) emit('binaries/install') }} hideIcon @@ -331,7 +336,7 @@ export const TestPage: React.FC = () => { setAgentError('') // Reset (or entering the default) CLEARS the override so agentURL() falls back to the // /agent proxy (dev) or VITE_AGENT_URL (build) — never pinning the OAuth audience as the transport. - setAPIPreference('agentURL', url === OAUTH_AGENT_RESOURCE ? '' : url) + setAPIPreferences({ agentURL: url === OAUTH_AGENT_RESOURCE ? '' : url }) }} hideIcon /> diff --git a/frontend/src/platforms/android-screenview/index.tsx b/frontend/src/platforms/android-screenview/index.tsx index 472998660..0139880b3 100644 --- a/frontend/src/platforms/android-screenview/index.tsx +++ b/frontend/src/platforms/android-screenview/index.tsx @@ -21,7 +21,6 @@ platforms.register({ types: { 1213: 'Android ScreenView' }, services: [{ application: 48 }], installation: { - label: 'Registration Code', command: '[CODE]', description: 'For the Android ScreenView app.', }, diff --git a/frontend/src/platforms/index.ts b/frontend/src/platforms/index.ts index b98189c8b..731b1751e 100644 --- a/frontend/src/platforms/index.ts +++ b/frontend/src/platforms/index.ts @@ -13,7 +13,6 @@ export interface IPlatform { hasScreenView?: boolean override?: React.FC installation?: { - label?: string download?: boolean command?: boolean | string instructions?: string | React.ReactNode diff --git a/frontend/src/services/agent.test.ts b/frontend/src/services/agent.test.ts index 4a61c2095..77b382ea9 100644 --- a/frontend/src/services/agent.test.ts +++ b/frontend/src/services/agent.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const state: { ui: { apis: { agentURL?: string } } } = { ui: { apis: {} } } vi.mock('../store', () => ({ store: { getState: () => state } })) vi.mock('./oidc', () => ({ oidcAuthHeaders: vi.fn() })) -vi.mock('../constants', () => ({ OAUTH_AGENT_RESOURCE: 'https://agent.remote.it' })) +vi.mock('../constants', () => ({ OAUTH_AGENT_RESOURCE: 'https://agent.remote.it', AGENT_URL: '/agent' })) import { agentURL, isSecureAgentURL, streamChat, AgentStreamEndedError } from './agent' @@ -97,13 +97,17 @@ describe('streamChat — SSE framing', () => { It used to resolve like a completion, leaving a truncated answer looking finished with the composer open for another send. */ it('reports a clean EOF with no terminal event as a cut-off, after delivering what arrived', async () => { - const { events, outcome } = await run(['event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"half an"}\n\n']) + const { events, outcome } = await run([ + 'event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"half an"}\n\n', + ]) expect(events).toEqual([turn, { type: 'text_delta', text: 'half an' }]) expect(outcome).toBeInstanceOf(AgentStreamEndedError) }) it('drops a torn tail rather than surfacing a parse error — and reports the cut-off', async () => { - const { events, outcome } = await run(['event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"tru']) + const { events, outcome } = await run([ + 'event: turn\ndata: {"turnId":"t1"}\n\nevent: text_delta\ndata: {"text":"tru', + ]) expect(events).toEqual([turn]) expect(outcome).toBeInstanceOf(AgentStreamEndedError) }) diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index d422cb355..9704cc47d 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -12,21 +12,18 @@ */ import { store } from '../store' import { oidcAuthHeaders } from './oidc' -import { OAUTH_AGENT_RESOURCE } from '../constants' +import { OAUTH_AGENT_RESOURCE, AGENT_URL } from '../constants' /* The override must be https — the app's CSP blocks plain http. Shared with the Test Settings validation so what saves is exactly what engages. */ export const isSecureAgentURL = (url: string): boolean => /^https:\/\//i.test(url) /* Base URL for the agent service, resolved per request. A Test UI override - wins (Test Settings → Agent service URL, https only). Otherwise dev rides the - vite proxy (same-origin, CSP-clean) even when VITE_AGENT_URL is set, staying - out of CORS; builds have no proxy and use the deployed agent domain from - VITE_AGENT_URL. */ + wins (Test Settings → Agent service URL, https only); otherwise the build's AGENT_URL. */ export function agentURL(): string { const override = store.getState().ui.apis.agentURL if (override && isSecureAgentURL(override)) return override.replace(/\/+$/, '') - return import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent' + return AGENT_URL } /* The agent rejected our credential (401 reauth_required) — sign in again */ diff --git a/frontend/src/services/cloudController.ts b/frontend/src/services/cloudController.ts index 13322df7e..7a5062b5c 100644 --- a/frontend/src/services/cloudController.ts +++ b/frontend/src/services/cloudController.ts @@ -10,6 +10,7 @@ import { getWebSocketURL, getTestHeader } from '../helpers/apiHelper' import { DEVICE_TYPE } from '@common/applications' import { getToken } from './remoteit' import { oidcAccessToken } from './oidc' +import { LEGACY_EVENTS_RE } from '../constants' import { version } from '../helpers/versionHelper' import { store } from '../store' import { notify } from './Notifications' @@ -43,10 +44,9 @@ const connectTimes = new CloudTimes() // Do NOT widen this pattern to match the unified front. Minting for the socket URL there asks the AS // for a resource that does not exist and fails `invalid_target` — which is exactly how the e2e // suite discovered the same assumption on its own side. -const EVENTS_RESOURCE = /^wss:\/\/ws(\.[a-z0-9-]+)?\.remote\.it\/v1$/ async function wsAuthorization(): Promise { const url = getWebSocketURL() || '' - if (!EVENTS_RESOURCE.test(url)) return await getToken() + if (!LEGACY_EVENTS_RE.test(url)) return await getToken() const token = await oidcAccessToken(url) return token ? 'Bearer ' + token : '' } @@ -551,8 +551,8 @@ class CloudController { status: event.job.status, jobDevices: jobDevice ? jobDevices.map(jd => - jd.device.id === jobDevice.device.id ? { ...jd, status: jobDevice.status } : jd - ) + jd.device.id === jobDevice.device.id ? { ...jd, status: jobDevice.status } : jd + ) : jobDevices, }, ], @@ -631,4 +631,4 @@ class CloudController { } const cloudController = new CloudController() -export default cloudController \ No newline at end of file +export default cloudController diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 2e7c206bd..ff82b6408 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -8,8 +8,10 @@ import { OAUTH_MCP_RESOURCE, OAUTH_MCP_DETAIL, OAUTH_AGENT_ACTOR, + OAUTH_ACCOUNT_RESOURCE, PROTOCOL, } from '../constants' +import { toBase64url, decodeBase64url } from '../helpers/base64url' /** * The renderer-owned OIDC client (permitteer docs/remoteit-desktop-login.md, D8): @@ -236,15 +238,10 @@ const responseError = (response: Response, detail: string): OidcError => { return new OidcError('refused', detail) } -const b64u = (bytes: Uint8Array) => - btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') -const randomB64u = (length: number) => b64u(crypto.getRandomValues(new Uint8Array(length))) +const randomB64u = (length: number) => toBase64url(crypto.getRandomValues(new Uint8Array(length))) const decodeJwt = (jwt?: string): any => { try { - return jwt ? JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) : undefined + return jwt ? JSON.parse(decodeBase64url(jwt.split('.')[1])) : undefined } catch { return undefined } @@ -273,16 +270,25 @@ export const oidcIsSupportTab = (): boolean => { return false } } -/** The launch ticket, ONCE — consumed by the authorize auth.init starts. */ -export function oidcTakeSupportTicket(): string | undefined { +/** Read-and-remove a same-tab one-shot; storage that throws reads as absent. */ +const takeSession = (key: string): string | undefined => { try { - const t = sessionStorage.getItem(SUPPORT_TICKET_KEY) ?? undefined - sessionStorage.removeItem(SUPPORT_TICKET_KEY) - return t + const value = sessionStorage.getItem(key) || undefined + sessionStorage.removeItem(key) + return value } catch { return undefined } } +/** The launch ticket, ONCE — consumed by the authorize auth.init starts. */ +export const oidcTakeSupportTicket = () => takeSession(SUPPORT_TICKET_KEY) +/** Ends this tab's support state: the tokens it held and the flag that made it a support tab. */ +export function oidcEndSupportTab() { + clearLocal() + try { + sessionStorage.removeItem(SUPPORT_FLAG) + } catch {} +} /** When the support session's token — and with it the session — ends (ms), for the banner. */ export const oidcSupportEndsAt = (): number | undefined => { const s = stored()?.support @@ -404,7 +410,7 @@ const declared = (): Array<{ // devices.write: "Sign out everywhere" (SecurityPage) — every session of the account, this // one included, ended in one call at the AS (permitteer docs/remoteit-desktop-login.md 4e). { - resource: `${OAUTH_ISSUER}/account/api`, + resource: OAUTH_ACCOUNT_RESOURCE, type: 'permitteer_account', actions: ['apps.read', 'apps.write', 'accounts.read', 'devices.write'], }, @@ -473,7 +479,7 @@ export async function oidcStart( client_id: OAUTH_CLIENT_ID, redirect_uri: flow.redirectUri, response_type: 'code', - code_challenge: b64u(new Uint8Array(digest)), + code_challenge: toBase64url(digest), code_challenge_method: 'S256', // `profile` rides for the account menus: name + the IdP avatar (the AS stamps the // session's picture into the id_token under profile — https-only, its one guard). @@ -629,9 +635,9 @@ const REFRESH_LOCK_WAIT_MS = 10_000 async function refresh(resource: string): Promise { const locks = (navigator as { locks?: { request: Function } } | undefined)?.locks - if (!locks?.request || typeof AbortSignal?.timeout !== 'function') return refreshOnce(resource) + if (!locks?.request) return refreshOnce(resource) try { - return await locks.request(REFRESH_LOCK, { signal: AbortSignal.timeout(REFRESH_LOCK_WAIT_MS) }, () => + return await locks.request(REFRESH_LOCK, { signal: timeoutSignal(REFRESH_LOCK_WAIT_MS) }, () => refreshOnce(resource) ) } catch (error: any) { @@ -829,15 +835,7 @@ export function oidcAccounts(): OidcAccount[] { * the hint names) before falling to the sign-in screen. sessionStorage: dies with the tab, * and it is cleared before the attempt so a failed round can never loop. */ const ACTIVATING_KEY = 'oidc.activating' -export function oidcTakeActivationHint(): string | undefined { - try { - const email = sessionStorage.getItem(ACTIVATING_KEY) ?? undefined - sessionStorage.removeItem(ACTIVATING_KEY) - return email || undefined - } catch { - return undefined - } -} +export const oidcTakeActivationHint = () => takeSession(ACTIVATING_KEY) export function oidcActivateAccount(sub: string): boolean { const entry = readRegistry()[sub] @@ -922,12 +920,6 @@ async function clearDpopKey(): Promise { } } -const dpopB64u = (bytes: ArrayBuffer | Uint8Array) => { - const a = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) - let out = '' - for (let i = 0; i < a.length; i++) out += String.fromCharCode(a[i]) - return btoa(out).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} const utf8 = (s: string) => new TextEncoder().encode(s) async function dpopProof(htm: string, htu: string, accessToken?: string): Promise { @@ -940,17 +932,17 @@ async function dpopProof(htm: string, htu: string, accessToken?: string): Promis y?: string } const u = new URL(htu) - const header = dpopB64u( + const header = toBase64url( utf8(JSON.stringify({ alg: 'ES256', typ: 'dpop+jwt', jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } })) ) - const payload = dpopB64u( + const payload = toBase64url( utf8( JSON.stringify({ htm, htu: u.origin + u.pathname, iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID(), - ...(accessToken ? { ath: dpopB64u(await crypto.subtle.digest('SHA-256', utf8(accessToken))) } : {}), + ...(accessToken ? { ath: toBase64url(await crypto.subtle.digest('SHA-256', utf8(accessToken))) } : {}), }) ) ) @@ -959,7 +951,7 @@ async function dpopProof(htm: string, htu: string, accessToken?: string): Promis pair.privateKey, utf8(`${header}.${payload}`) ) - return `${header}.${payload}.${dpopB64u(sig)}` + return `${header}.${payload}.${toBase64url(sig)}` } /** Auth headers for an API call: the DPoP scheme + an ath proof when this audience's @@ -986,6 +978,23 @@ export async function oidcAuthHeaders( return { authorization: `Bearer ${token}` } } +export type OidcResourceResult = { status: number; body?: T } + +/** One request against an OIDC resource, on the token minted for that audience (DPoP-bound or + * Bearer — the AS decides which we hold). No token answers 401 without a round trip. */ +export async function oidcResourceRequest( + resource: string, + path: string, + init: RequestInit = {} +): Promise> { + const url = resource + path + const auth = await oidcAuthHeaders(init.method ?? 'GET', url, resource) + if (!auth.authorization) return { status: 401 } + const response = await fetch(url, { ...init, headers: { ...auth, ...(init.headers || {}) } }) + const body = (await response.json().catch(() => undefined)) as T | undefined + return { status: response.status, body } +} + async function tokenRequest(params: { [key: string]: string }): Promise { const d = await discover() const proof = await dpopProof('POST', d.token_endpoint) @@ -1009,8 +1018,6 @@ async function tokenRequest(params: { [key: string]: string }): Promise { // account API serves the set from this token's own session. Members this app holds no tokens // for are filed as KNOWN — identity only — and the menu offers them; picking one is a silent // selection (prompt=none + login_hint), which the AS answers for any live set member. -const ACCOUNT_RESOURCE = `${OAUTH_ISSUER}/account/api` - /** Why a refresh did not happen. `refused` is the one that used to be invisible: the menu * kept rendering its cache while the AS was turning the call away, so a stale list and a * broken one looked identical — on screen and in the console. */ @@ -1020,8 +1027,8 @@ export type BrowserAccountsRefresh = export async function oidcRefreshBrowserAccounts(): Promise { if (oidcActor()) return { ok: false, reason: 'support-session' } // no set member, nothing to switch to - const url = `${ACCOUNT_RESOURCE}/accounts` - const headers = await oidcAuthHeaders('GET', url, ACCOUNT_RESOURCE) + const url = `${OAUTH_ACCOUNT_RESOURCE}/accounts` + const headers = await oidcAuthHeaders('GET', url, OAUTH_ACCOUNT_RESOURCE) if (!headers.authorization) return { ok: false, reason: 'no-token' } const r = await fetch(url, { headers }) if (!r.ok) { diff --git a/frontend/src/services/passportSelf.ts b/frontend/src/services/passportSelf.ts index 01e226ccb..a62a70e35 100644 --- a/frontend/src/services/passportSelf.ts +++ b/frontend/src/services/passportSelf.ts @@ -1,4 +1,4 @@ -import { oidcAuthHeaders } from './oidc' +import { oidcResourceRequest } from './oidc' import { OAUTH_PASSPORT_RESOURCE } from '../constants' /** @@ -25,19 +25,16 @@ export type SelfContinuation = { error_description?: string } -const call = async (path: string, body?: Record): Promise => { - const url = OAUTH_PASSPORT_RESOURCE + path - const method = body ? 'POST' : 'GET' - // Scheme-aware (plan D9): a DPoP-bound token presents as `DPoP` + an ath proof. - const auth = await oidcAuthHeaders(method, url, OAUTH_PASSPORT_RESOURCE) - if (!auth.authorization) return { httpStatus: 401, error: 'unauthorized' } - const response = await fetch(url, { - method, - headers: { ...auth, ...(body ? { 'content-type': 'application/json' } : {}) }, - ...(body ? { body: JSON.stringify(body) } : {}), - }) - const parsed = (await response.json().catch(() => ({}))) as SelfContinuation - return { ...parsed, httpStatus: response.status } +const call = async ( + path: string, + body?: Record +): Promise => { + const r = await oidcResourceRequest( + OAUTH_PASSPORT_RESOURCE, + path, + body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {} + ) + return { ...(r.body ?? (r.status === 401 ? { error: 'unauthorized' } : {})), httpStatus: r.status } } export type MfaMethod = 'totp' | 'sms' @@ -57,6 +54,9 @@ export const selfMfaPrefer = (password: string, method: MfaMethod) => call('/mfa export const selfMfaDisable = (password: string, method?: MfaMethod) => call('/mfa/disable', { password, ...(method ? { method } : {}) }) export const selfPasskeyRegister = (password: string) => call('/passkeys/register', { password }) -export const selfPasskeyConfirm = (challenge: string, attestation: { attestationObject: string; clientDataJSON: string }, name: string) => - call('/passkeys/confirm', { challenge, ...attestation, name }) +export const selfPasskeyConfirm = ( + challenge: string, + attestation: { attestationObject: string; clientDataJSON: string }, + name: string +) => call('/passkeys/confirm', { challenge, ...attestation, name }) export const selfPasskeyDelete = (password: string, id: string) => call('/passkeys/delete', { password, id }) diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index 136ad4bb8..e7cdd339d 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -3,12 +3,10 @@ * the unit, and revoking it kills every refresh token minted from it. No graphql gateway: * the deleted Hydra façade is not coming back, and the AS view already carries names, * logos, per-action detail and honest revocation reach. */ -import { oidcAuthHeaders } from './oidc' -import { OAUTH_ISSUER } from '../constants' +import { oidcResourceRequest, OidcResourceResult } from './oidc' +import { OAUTH_ACCOUNT_RESOURCE } from '../constants' -const RESOURCE = `${OAUTH_ISSUER}/account/api` - -export type AccountApiResult = { status: number; body?: T } +export type AccountApiResult = OidcResourceResult /** The legal token targets for THIS client — the AS's allowlist joined to registry names * (D10). The stage picker and the mint-time guardrail read the SAME source, so they can @@ -18,24 +16,8 @@ export async function bindableResources(): Promise(path: string, init: RequestInit = {}): Promise> { - const url = `${RESOURCE}${path}` - // Scheme-aware (plan D9): a DPoP-bound token presents as `DPoP` + an ath proof; an - // unbound one stays Bearer. The AS decides which we hold. - const auth = await oidcAuthHeaders(init.method ?? 'GET', url, RESOURCE) - if (!auth.authorization) return { status: 401 } - const response = await fetch(url, { - ...init, - headers: { ...auth, ...(init.headers || {}) }, - }) - let body: T | undefined - try { - body = (await response.json()) as T - } catch { - body = undefined - } - return { status: response.status, body } -} +const call = (path: string, init: RequestInit = {}) => + oidcResourceRequest(OAUTH_ACCOUNT_RESOURCE, path, init) /** "Sign out everywhere" (permitteer docs/remoteit-desktop-login.md Phase 4e): every session * of the account at the AS — THIS one included — ended in one stroke, each with its refresh From c82f6fd4866d603df1a7b038875fe3d6d4833891 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 15:25:01 -0700 Subject: [PATCH 224/238] refactor: one panel divider, one add-on page loader, one live-turn rule, one chat boot sequence --- frontend/src/components/Chat/ChatPanel.tsx | 3 +- frontend/src/components/Chat/ChatWindow.tsx | 5 +- frontend/src/components/DoublePanel.tsx | 32 +-- frontend/src/components/MFA/MFASettings.tsx | 5 +- .../src/components/MFA/PasskeysSettings.tsx | 5 +- frontend/src/components/PanelHandle.tsx | 4 +- frontend/src/components/TriplePanel.tsx | 37 +--- frontend/src/hooks/useChatSync.ts | 49 +++-- frontend/src/models/adminAddonLicenses.ts | 203 ++++++++---------- frontend/src/models/chat.ts | 15 +- 10 files changed, 144 insertions(+), 214 deletions(-) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 5aad61c45..4056204c1 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -21,6 +21,7 @@ import { ChatHeader, NewChatButton } from './ChatHeader' import { ChatBody } from './ChatBody' import browser from '../../services/browser' import { chatPopoutSupported } from '../../services/chatPopout' +import { selectTurnActive } from '../../models/chat' /* How far the docked column floats off the window edges, in theme spacing units. One knob: the margins and the size subtractions below both derive from it, so a @@ -37,7 +38,7 @@ export const ChatPanel: React.FC = () => { // Popping out hands the conversation to a second window and stop()s this one. While a turn is // still streaming or an approval card is pending, the handoff can't carry/resume it — the popup // couldn't action the approval and the server-side turn would strand — so block it until idle. - const turnActive = useSelector((state: State) => state.chat.streaming || !!state.chat.pendingConfirmation) + const turnActive = useSelector(selectTurnActive) const docked = useChatDocked() const chatWidth = useChatWidth() const maxWidth = useChatMaxWidth() diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 2e85af771..444a7c308 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -2,11 +2,12 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { Box } from '@mui/material' -import { Dispatch, State } from '../../store' +import { Dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { useChatPopoutSync } from '../../hooks/useChatSync' import { ChatHeader, NewChatButton } from './ChatHeader' import { ChatBody } from './ChatBody' +import { selectTurnActive } from '../../models/chat' /* Full-page chat for the popped-out window (?chatPopout boot flag). Display only: the handoff protocol lives in useChatPopoutSync, user actions in the @@ -18,7 +19,7 @@ export const ChatWindow: React.FC = () => { // the handoff carries neither turnId nor the pending approval — so mid-turn it would abort the // stream and strand a confirmation_required turn on the server with no window left able to // answer it. Block it until the turn is idle, exactly as the dock blocks Pop out. - const turnActive = useSelector((state: State) => state.chat.streaming || !!state.chat.pendingConfirmation) + const turnActive = useSelector(selectTurnActive) useChatPopoutSync() diff --git a/frontend/src/components/DoublePanel.tsx b/frontend/src/components/DoublePanel.tsx index 07a594c31..fe528c3dc 100644 --- a/frontend/src/components/DoublePanel.tsx +++ b/frontend/src/components/DoublePanel.tsx @@ -6,6 +6,7 @@ import { REGEX_FIRST_PATH } from '../constants' import { useLocation } from 'react-router-dom' import { Box } from '@mui/material' import { Header } from './Header' +import { PanelHandle } from './PanelHandle' type Props = { left: React.ReactNode @@ -75,36 +76,7 @@ export const DoublePanel: React.FC = ({ left, right, layout, header = tru {left} - ({ - zIndex: 8, - position: 'absolute', - height: '100%', - marginLeft: '-5px', - padding: `0 ${theme.spacing(0.375)}`, - WebkitAppRegion: 'no-drag', - '&:hover': { - cursor: 'col-resize', - }, - '& > div': { - width: '1px', - marginLeft: '1px', - marginRight: '1px', - height: '100%', - backgroundColor: theme.palette.grayLighter.main, - transition: 'background-color 100ms 200ms, width 100ms 200ms, margin 100ms 200ms', - }, - '&:hover > div, & .active': { - width: '3px', - marginLeft: 0, - marginRight: 0, - backgroundColor: theme.palette.primary.main, - }, - })} - > -
- + { 'Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.' )} - - {step.codes.join('\n')} - + diff --git a/frontend/src/components/MFA/PasskeysSettings.tsx b/frontend/src/components/MFA/PasskeysSettings.tsx index e9592ef71..e27e313a3 100644 --- a/frontend/src/components/MFA/PasskeysSettings.tsx +++ b/frontend/src/components/MFA/PasskeysSettings.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Box, Button, Chip, TextField, Typography } from '@mui/material' import { Gutters } from '../Gutters' +import { CopyCodeBlock } from '../CopyCodeBlock' import { selfMe, selfPasskeyRegister, @@ -264,9 +265,7 @@ export const PasskeysSettings: React.FC = () => { 'Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.' )} - - {step.codes.join('\n')} - + ) : null} @@ -109,17 +110,17 @@ export const ChatBody: React.FC = () => { } > - dispatch.chat.send(text)} - onStop={() => dispatch.chat.stop()} - /> + dispatch.chat.send(text)} + onStop={() => dispatch.chat.stop()} + /> ) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index f5221f45c..14f42318b 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -7,10 +7,9 @@ import { fontSizes, radius, scrollbarStyles, SCROLLBAR_WIDTH_NARROW } from '../. import { ChatTranscriptMessage } from '../../models/chat' import { ChatToolCalls } from './ChatToolCalls' -// Links open in a new tab: a bare anchor is a top-level navigation, which in -// Electron replaces the app window with the external site (will-navigate only -// guards auth.remote.it); target=_blank routes through setWindowOpenHandler → -// shell.openExternal instead +// Links open in a new tab: a bare anchor is a top-level navigation — will-navigate sends any +// non-app origin to the system browser, but target=_blank routes through setWindowOpenHandler → +// shell.openExternal without the window ever leaving the app const markdownComponents = { a: ({ node, ...props }: any) => , } diff --git a/frontend/src/components/ConnectedApps/ConnectedApps.tsx b/frontend/src/components/ConnectedApps/ConnectedApps.tsx index eec456672..747e20b47 100644 --- a/frontend/src/components/ConnectedApps/ConnectedApps.tsx +++ b/frontend/src/components/ConnectedApps/ConnectedApps.tsx @@ -26,7 +26,12 @@ export const ConnectedApps: React.FC = () => { 'Sign in again to see your connected apps — your current session started before this page could ask for them.' )} - @@ -54,7 +59,10 @@ export const ConnectedApps: React.FC = () => { - + )} diff --git a/frontend/src/components/MFA/MFASettings.tsx b/frontend/src/components/MFA/MFASettings.tsx index cd3ad8f47..50e0a89c2 100644 --- a/frontend/src/components/MFA/MFASettings.tsx +++ b/frontend/src/components/MFA/MFASettings.tsx @@ -13,7 +13,7 @@ import { selfChallenge, MfaMethod, METHOD_LABEL, - SelfContinuation, + SelfResult, } from '../../services/passportSelf' import { OAUTH_PASSPORT_RESOURCE } from '../../constants' @@ -60,10 +60,7 @@ export const MFASettings: React.FC = () => { refresh() }, []) - const followContinuation = ( - r: SelfContinuation & { httpStatus: number }, - pending: { mode: Mode; method?: MfaMethod } - ): boolean => { + const followContinuation = (r: SelfResult, pending: { mode: Mode; method?: MfaMethod }): boolean => { if (r.status === 'ok') { if (r.recovery_codes?.length) setStep({ at: 'codes', codes: r.recovery_codes }) else refresh() diff --git a/frontend/src/components/MFA/PasskeysSettings.tsx b/frontend/src/components/MFA/PasskeysSettings.tsx index 01857b874..167cfcd82 100644 --- a/frontend/src/components/MFA/PasskeysSettings.tsx +++ b/frontend/src/components/MFA/PasskeysSettings.tsx @@ -12,6 +12,8 @@ import { MfaMethod, METHOD_LABEL, SelfContinuation, + SelfResult, + Passkey, } from '../../services/passportSelf' import { toBase64url, fromBase64url } from '../../helpers/base64url' @@ -22,10 +24,8 @@ import { toBase64url, fromBase64url } from '../../helpers/base64url' * need a code factor first — sign-ins from older apps rely on it, and the copy says so. */ -type Key = { id: string; name: string; createdAt?: string; lastUsedAt?: string } - type Step = - | { at: 'view'; keys: Key[] } + | { at: 'view'; keys: Passkey[] } | { at: 'password'; mode: 'add' | 'remove'; keyId?: string; error?: string } | { at: 'relay' @@ -48,7 +48,7 @@ export const PasskeysSettings: React.FC = () => { const [supported] = useState(() => typeof window !== 'undefined' && !!window.PublicKeyCredential) const refresh = async () => { - const me = (await selfMe()) as SelfContinuation & { passkeys?: Key[]; httpStatus: number } + const me = await selfMe() setStep({ at: 'view', keys: me.httpStatus === 200 ? me.passkeys ?? [] : [] }) } useEffect(() => { @@ -95,10 +95,7 @@ export const PasskeysSettings: React.FC = () => { } } - const follow = async ( - r: SelfContinuation & { httpStatus: number }, - pending: { mode: 'add' | 'remove'; keyId?: string } - ) => { + const follow = async (r: SelfResult, pending: { mode: 'add' | 'remove'; keyId?: string }) => { if (r.status === 'register') return ceremony(r) if (r.status === 'ok') return refresh() if ((r.status === 'mfa' || r.status === 'select') && r.challenge) diff --git a/frontend/src/components/RentANodeForm.tsx b/frontend/src/components/RentANodeForm.tsx index 338e596f1..1246bb2aa 100644 --- a/frontend/src/components/RentANodeForm.tsx +++ b/frontend/src/components/RentANodeForm.tsx @@ -23,7 +23,6 @@ export const RentANodeForm: React.FC = ({ registrationCode }) => { const history = useHistory() const user = useSelector((state: State) => state.user) const organization = useSelector(selectOrganization) - const { AWSUser } = useSelector((state: State) => state.auth) const [submitting, setSubmitting] = useState(false) const [form, setForm] = useState({ deviceName: '', @@ -41,9 +40,9 @@ export const RentANodeForm: React.FC = ({ registrationCode }) => { await rentANode([ new Date().toISOString().slice(0, -1), // timestamp - AWSUser.given_name ? AWSUser.given_name + ' ' + AWSUser.family_name : 'Unknown', // name + 'Unknown', // name user.email, // email - "'" + (form.phone ?? AWSUser.phone_number ?? ''), // phone + "'" + (form.phone ?? ''), // phone organization.name, // org-name user.email, // remoteit-email form.deviceName, // name @@ -181,7 +180,9 @@ export const RentANodeForm: React.FC = ({ registrationCode }) => { color="primary" disabled={!!error.deviceName || !!error.phone || !form.deviceName || !form.sshPublicKey} > - {submitting ? t('rentANodeForm.submitting', 'Submitting...') : t('rentANodeForm.submitRequest', 'Submit Request')} + {submitting + ? t('rentANodeForm.submitting', 'Submitting...') + : t('rentANodeForm.submitRequest', 'Submit Request')} ) diff --git a/frontend/src/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index 3ee3e8841..56f7168bc 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -3,7 +3,7 @@ import { Box, Button, Typography, CircularProgress } from '@mui/material' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { Dispatch, State } from '../store' -import { OidcErrorCode, oidcAutoStartExhausted, oidcIsSupportTab } from '../services/oidc' +import { OidcErrorCode, oidcAutoStartExhausted, oidcIsSupportTab, oidcLeaveRefused } from '../services/oidc' import { MODE } from '../constants' import browser from '../services/browser' import brand from '@common/brand/config' @@ -11,8 +11,8 @@ import brand from '@common/brand/config' /** * The sign-in panel is a LAUNCHER now: the whole journey — email-first with org SSO * routing, password + MFA, Google, signup, forgot — lives at the authorization server - * in the SYSTEM browser (permitteer docs/remoteit-desktop-login.md). The backend owns - * the flow; this panel starts it and waits. + * in the SYSTEM browser (permitteer docs/remoteit-desktop-login.md). The renderer owns + * the flow (services/oidc); this panel starts it and waits. */ /* What a failed sign-in tells the person to DO. Keyed by the reason rather than by the @@ -102,13 +102,13 @@ export function SignInApp() { actually bit — a path that returns without recording the failure — since an automatic authorize renders nothing to a person and the first visible symptom is the AS rate-limiting the address. A click is never counted against it. */ - // A SUPPORT tab (opened by the console's launch — permitteer docs/desktop-support.md) never - // auto-starts a plain sign-in: that would sign the operator in as THEMSELVES and quietly turn - // the support view into their own account. auth.init drives the ticketed authorize; once the - // session has ended, the tab says so and stops. + // A SUPPORT tab (opened by the console's launch — permitteer docs/desktop-support.md) says so + // below instead of offering a sign-in; auth.init drives its ticketed authorize, and oidcStart + // refuses every other start there (oidcLeaveRefused), so it can never sign the operator in as + // themselves. const supportTab = oidcIsSupportTab() const budgetSpent = oidcAutoStartExhausted('boot') - const autoStart = !browser.isElectron && !signingIn && !signInFailed && !budgetSpent && !supportTab + const autoStart = !browser.isElectron && !signingIn && !signInFailed && !budgetSpent && !oidcLeaveRefused() useEffect(() => { if (!autoStart) return auth.signIn({ auto: 'boot' }) diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts index bc3310862..88234784f 100644 --- a/frontend/src/hooks/useChatEnabled.ts +++ b/frontend/src/hooks/useChatEnabled.ts @@ -96,8 +96,11 @@ export const useLayoutBreakpoints = () => { and the components that mirror its sidebar breakpoint (Header, AvatarMenu…) */ export const useHideSidebar = (): boolean => useLayoutBreakpoints().hideSidebar -/* Width of the left chrome (sidebar + org bar) the layout reserves — - shared by App's sidePanelWidth and the chat overlay's left edge */ +/* Width of the left chrome (sidebar + org bar) the layout reserves — App's sidePanelWidth, the + chrome the content panels share their row with, and the chat overlay's left edge. The docked + chat is NOT in that row (it is a column beside the whole app side) and must not be counted: + the panels' own parent already excludes it, and adding it back subtracted the chat twice, + which drove their max width below their minimum and froze the drag. */ export const useSidebarWidth = (): number => { const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) const hideSidebar = useHideSidebar() diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index e04046200..4c1474080 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -42,10 +42,6 @@ export const useChatPopoutScope = (): void => { }, []) } -/* Main-window chat lifecycle — everything ChatPanel needs to happen but that - isn't display: adopting the server's transcript on mount, wiring the popout - handoff protocol, re-checking agent health when the dock opens, and - mirroring the app's active org. */ /* The identity the chat is scoped by. auth.user, NOT the persisted `user` model: auth.user is fetched for the CURRENT tokens at sign-in (it is what lets App mount), while the user model is restored from storage and only catches up when the cloud sync lands. Activating a saved @@ -91,6 +87,9 @@ const useChatBoot = (open: boolean): void => { useAgentHealthOnReconnect(() => dispatch.chat.checkHealth()) } +/* Main-window chat lifecycle — everything ChatPanel needs to happen but that isn't display: + the shared boot (useChatBoot), the popout handoff protocol, and re-checking agent health + when the dock opens. */ export const useChatMainSync = (): void => { const open = useSelector((state: State) => state.chat.open) const dispatch = useDispatch() diff --git a/frontend/src/hooks/useViewportWidth.ts b/frontend/src/hooks/useViewportWidth.ts index 588ed17be..4fabdf274 100644 --- a/frontend/src/hooks/useViewportWidth.ts +++ b/frontend/src/hooks/useViewportWidth.ts @@ -44,13 +44,9 @@ export const subscribeViewport = (listener: () => void) => { } } -const getSnapshot = () => width - /* The width right now, for event handlers that must not subscribe (a drag's own math). */ export const getViewportWidth = (): number => width -export const useViewportWidth = (): number => useSyncExternalStore(subscribeViewport, getSnapshot) - /* Subscribe to a value DERIVED from the width — a breakpoint, a clamp. Same single listener, but the subscriber is the ANSWER rather than the width, so a component that only wants a breakpoint re-renders when the breakpoint FLIPS instead of on every frame diff --git a/frontend/src/models/agents.ts b/frontend/src/models/agents.ts index a050e648b..180ea284f 100644 --- a/frontend/src/models/agents.ts +++ b/frontend/src/models/agents.ts @@ -7,8 +7,9 @@ type IAgentsState = { fetching: boolean updating?: string // the grant id currently being revoked (drives the revoke button spinner) agents: IAuthorizedAgent[] - // The session's token predates the connected-apps permission slice: one fresh sign-in - // (silent SSO — the AS session is alive) re-mints the grant with it. Drives the notice. + // The account API refused this session's token — the grant predates the connected-apps slice, + // or the registry moved under it. The remedy is the chat's: auth.healGrant with force, a silent + // same-account re-authorize that re-mints the grant. Drives the notice. needsReauth: boolean } diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index e233512f4..76b170a14 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -6,15 +6,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // the hoisted vi.mock factory runs. `browser` and the live `store` state are hoisted MUTABLE // objects so individual tests can steer the electron/backend branch and what the effects // re-read from the store after a teardown. -const { oidcStart, signOutEverywhere, oidcGrantStale, oidcMcpDetailReady, oidcActor, browser, storeState } = vi.hoisted(() => ({ - oidcStart: vi.fn(), - signOutEverywhere: vi.fn(), - oidcGrantStale: vi.fn(), - oidcActor: vi.fn(), - oidcMcpDetailReady: vi.fn(), - browser: { isElectron: false, hasBackend: false }, - storeState: { auth: {} as Record }, -})) +const { oidcStart, signOutEverywhere, oidcGrantStale, oidcMcpDetailReady, oidcActor, browser, storeState } = vi.hoisted( + () => ({ + oidcStart: vi.fn(), + signOutEverywhere: vi.fn(), + oidcGrantStale: vi.fn(), + oidcActor: vi.fn(), + oidcMcpDetailReady: vi.fn(), + browser: { isElectron: false, hasBackend: false }, + storeState: { auth: {} as Record }, + }) +) // signInFailure() tests `error instanceof OidcError`, so the mock must export a real class // (an undefined right-hand side of instanceof throws rather than returning false). @@ -38,13 +40,22 @@ vi.mock('../services/remoteit', () => ({ getToken: vi.fn(), apiAuthHeaders: vi.f vi.mock('../selectors/devices', () => ({ selectDeviceModelAttributes: vi.fn() })) vi.mock('../store', () => ({ persistor: { purge: vi.fn() }, store: { getState: () => storeState } })) vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) -vi.mock('../constants', () => ({ API_URL: '', DEVELOPER_KEY: '', SIGN_OUT_BACKEND_TIMEOUT: 1000, SIGN_OUT_EVERYWHERE_TIMEOUT: 50 })) +vi.mock('../constants', () => ({ + API_URL: '', + DEVELOPER_KEY: '', + SIGN_OUT_BACKEND_TIMEOUT: 1000, + SIGN_OUT_EVERYWHERE_TIMEOUT: 50, +})) vi.mock('axios', () => ({ default: {} })) // The effects are `dispatch => ({...})`; build them against a fake dispatch so each auth.* // call is an observable spy rather than a real reducer/effect. function makeDispatch() { - return { auth: { set: vi.fn(), signedOut: vi.fn(), signOut: vi.fn() }, ui: { set: vi.fn() }, chat: { signOut: vi.fn() } } + return { + auth: { set: vi.fn(), signedOut: vi.fn(), signOut: vi.fn() }, + ui: { set: vi.fn() }, + chat: { signOut: vi.fn() }, + } } // The only shape SignInApp renders: it shows a message ONLY while signInFailed is true, and @@ -160,12 +171,6 @@ describe('auth model — a backend rejection survives the sign-out teardown', () dispatch.auth.signedOut.mock.invocationCallOrder[0] ) }) - - it('signInError writes the signInFailure shape, never a bare signInError string', async () => { - const dispatch = makeDispatch() - await effectsFor(dispatch).signInError('locked') - expect(dispatch.auth.set).toHaveBeenCalledWith(aFailureShowing('locked')) - }) }) /* oidcGrantStale() compares against the MCP detail type. On the first load after a rename the diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 03e64b93e..c7cc612ee 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -5,10 +5,12 @@ import network from '../services/Network' import browser from '../services/browser' import analytics from '../services/analytics' import { selectDeviceModelAttributes } from '../selectors/devices' -import { API_URL, DEVELOPER_KEY, SIGN_OUT_BACKEND_TIMEOUT, SIGN_OUT_EVERYWHERE_TIMEOUT } from '../constants' +import { SIGN_OUT_BACKEND_TIMEOUT, SIGN_OUT_EVERYWHERE_TIMEOUT } from '../constants' import { persistor, store } from '../store' import { graphQLLogin } from '../services/graphQLRequest' -import { getToken, apiAuthHeaders } from '../services/remoteit' +import { getToken } from '../services/remoteit' +import { selfChangePassword, selfChallenge } from '../services/passportSelf' +import { signOutEverywhere } from '../services/permitteerAccount' import { oidcConfigured, oidcSignedIn, @@ -17,43 +19,23 @@ import { oidcClearLocal, oidcCompleteFromUrl, oidcActivateAccount, - oidcTakeActivationHint, + oidcActivationHint, invalidateOidcToken, oidcGrantStale, oidcMcpDetailReady, - oidcDeclaration, oidcActor, oidcTakeSupportTicket, - oidcIsSupportTab, oidcSelectKnownAccount, oidcClearAutoStarts, - OidcClaims, OidcError, OidcErrorCode, } from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' -import axios from 'axios' import i18n from '../i18n' import sleep from '../helpers/sleep' -// One re-authorize attempt per browser session, keyed by the declaration it was made from -// (healGrant below). sessionStorage rather than local: the bound is meant to survive reloads of -// this tab and nothing more, so a new tab is always a clean slate. - -export interface AWSUser { - authProvider: string - email?: string - email_verified?: boolean - phone_number?: string - phone_number_verified?: boolean - given_name?: string //first_name - family_name?: string //last_name - gender?: string - 'custom:backup_code'?: string -} - export interface AuthState { initialized: boolean authenticated: boolean @@ -72,7 +54,6 @@ export interface AuthState { signingIn?: boolean passwordChallenge?: { challenge: string; hint?: string } user?: IUser - AWSUser: AWSUser } const defaultState: AuthState = { @@ -85,7 +66,6 @@ const defaultState: AuthState = { signInRetryAfter: undefined, signingIn: false, user: undefined, - AWSUser: { authProvider: '' }, } /* Every sign-in failure lands here, so the screen has exactly one shape to read and a @@ -109,8 +89,8 @@ const signInCleared = { export default createModel()({ state: defaultState, effects: dispatch => ({ - /* The BACKEND owns the OIDC session (permitteer docs/remoteit-desktop-login.md): - init just asks it whether one exists. + /* The RENDERER owns the OIDC session (services/oidc, permitteer docs/remoteit-desktop-login.md + D8): init completes a callback if this boot is one, else restores the stored tokens. This used to take a `silent` flag that skipped RECORDING a failed sign-in, meaning to spare an unattended window a toast. But signInError is not a toast — it is the @@ -134,7 +114,7 @@ export default createModel()({ return } const claims = await oidcCompleteFromUrl() - if (claims) await dispatch.auth.handleSignInSuccess(claims) + if (claims) await dispatch.auth.handleSignInSuccess() else if (oidcSignedIn()) { // Stored tokens are a CLAIM of a session, not proof of one: the AS may have // revoked it (sign-out elsewhere, admin action, family revocation). Force one @@ -142,10 +122,8 @@ export default createModel()({ // instead of rendering an authenticated shell over a corpse. const alive = await getToken() if (alive) { - await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) - // Never re-authorize a SUPPORT session: a plain authorize in this tab would sign - // the operator in as THEMSELVES and quietly turn the support view into their own. - if (!oidcActor()) await dispatch.auth.healGrant() + await dispatch.auth.handleSignInSuccess() + await dispatch.auth.healGrant() // refused by oidcStart in a support tab } else { invalidateOidcToken() // A JUST-ACTIVATED saved account whose refresh family died: one silent @@ -153,7 +131,7 @@ export default createModel()({ // session-set member the hint names (permitteer silent selection), so the // person lands back signed in with zero screens. The marker is one-shot; // a refused silent round falls to the ordinary sign-in screen. - const hint = oidcTakeActivationHint() + const hint = oidcActivationHint() if (hint) await oidcStart({ prompt: 'none', loginHint: hint, auto: `activate:${hint}` }) } } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') @@ -163,8 +141,8 @@ export default createModel()({ // strand a signed-in person on the sign-in screen: the stored session is intact — restore // it, say why, and let the menu re-learn the browser's accounts. Otherwise fall through // to this branch's richer error mapping (signInFailure). - if (String(error?.message || '').includes('login_required') && oidcSignedIn() && (await getToken())) { - await dispatch.auth.handleSignInSuccess(oidcClaims() ?? {}) + if (error?.oauthError === 'login_required' && oidcSignedIn() && (await getToken())) { + await dispatch.auth.handleSignInSuccess() dispatch.ui.set({ errorMessage: 'That account is no longer signed in on this browser.' }) } else dispatch.auth.set(signInFailure(error)) } @@ -179,32 +157,22 @@ export default createModel()({ * unexplained 403 in whichever feature needed the slice, and the only cure they could * find is signing out and in again. * - * ONE attempt per app session. If the re-authorize comes back still short — a client - * whose declaration outruns what the AS will grant it — a second try would return here - * and loop the person through the browser forever. Same loop-breaker the console's - * renew marker uses. */ + * Automatic attempts are bounded by oidcStart's ledger (reason `heal`); a person pressing the + * chat's "Refresh permissions" is their own loop-breaker, so `force` skips both the ledger and + * the stale check — a resource server's refusal is a runtime fact this stamp cannot see. */ async healGrant(options?: { force?: boolean }) { try { // The freshness check compares against the MCP detail type; on the first load after a // rename the cached name is the OLD one until the boot metadata refresh lands. Wait for it // (bounded, resolved instantly thereafter) so this cannot call a renamed-away grant current. await oidcMcpDetailReady() - if (!oidcGrantStale()) return - console.log('AUTH: grant predates this build’s declaration — re-authorizing') - // FORCE is a deliberate human action (the chat's "Refresh permissions" button): a person is - // their own loop-breaker, so it skips the ledger that stops an AUTOMATIC retry cycling the - // tab through the AS. The automatic reason names the account AND the declaration, so a - // deploy that changes what this build asks for gets a fresh attempt, and one account's - // spent attempt never blocks another's in the same tab. A refusal the agent reports later - // clears the stamp (oidcMarkGrantStale) — that, not this, is what makes a retry due. - await oidcStart(options?.force ? {} : { auto: `heal:${oidcClaims()?.sub}:${oidcDeclaration()}` }) + if (!options?.force && !oidcGrantStale()) return + console.log('AUTH: re-authorizing for this build’s declaration') + await oidcStart(options?.force ? {} : { auto: 'heal' }) } catch (error) { console.warn('AUTH: grant heal check failed (leaving the session as it is)', error) } }, - // Leave for the AS (the whole login UX — email-first, org SSO, MFA, signup, forgot — - // lives there). On web the page departs; on desktop the window shows the waiting - // panel until the deep link reloads it with the code. /** Account switch: re-run authorize with select_account — the AS chooser shows the * real session chips; nothing is torn down locally, so a canceled chooser costs * nothing. Completion replaces the session like any sign-in (a SAME-account re-auth @@ -268,7 +236,6 @@ export default createModel()({ // is the proof of possession; accounts whose store challenges (pool MFA) get a code // continuation the ChangePassword form renders. async changePassword(passwordValues: IPasswordValue): Promise { - const { selfChangePassword } = await import('../services/passportSelf') const r = await selfChangePassword(passwordValues.currentPassword ?? '', passwordValues.password ?? '') if (r.status === 'ok') { dispatch.auth.set({ passwordChallenge: undefined }) @@ -295,7 +262,6 @@ export default createModel()({ async completePasswordChallenge(code: string, state): Promise { const pending = state.auth.passwordChallenge if (!pending) return false - const { selfChallenge } = await import('../services/passportSelf') const r = await selfChallenge(pending.challenge, { code }) if (r.status === 'ok') { dispatch.auth.set({ passwordChallenge: undefined }) @@ -311,38 +277,10 @@ export default createModel()({ }) return false }, - /* TODO validate and hook changeEmail up */ - async changeEmail(email: string) { - const mailFormat = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/ - if (mailFormat.test(email)) { - await axios.post( - '/user/email/', - { email }, - { - baseURL: API_URL, - headers: { - 'Content-Type': 'application/json', - developerKey: DEVELOPER_KEY, - ...(await apiAuthHeaders('POST', `${API_URL}/user/email/`)), - }, - } - ) - dispatch.auth.setAWSUserEmail(email) - dispatch.ui.set({ - successMessage: i18n.t('notices:auth.emailModified', { defaultValue: 'Email modified successfully.' }), - }) - } else { - dispatch.ui.set({ errorMessage: i18n.t('notices:auth.invalidFormat', { defaultValue: 'Invalid format.' }) }) - } - }, - async forceRefreshToken(_: void) { - invalidateOidcToken() - await getToken() - }, // The 401 recovery path (services/post.ts): drop the renderer cache and let the // backend refresh on the next token fetch. If the backend says the session is gone // (refresh family revoked / AS session expired), sign the app out. - async checkSession(options: { silent?: boolean; status?: number }, state) { + async checkSession(options: { status?: number }, state) { invalidateOidcToken() // A SUPPORT session cannot be recovered: no refresh token, and a 401 means the session was // ended — by the user, by the operator's relaunch, or by its own expiry. The end is the end @@ -355,25 +293,16 @@ export default createModel()({ } if (!oidcSignedIn() && state.auth.authenticated) { console.error('SESSION ERROR: session gone (refresh family dead or signed out)') - if (!options.silent) - dispatch.ui.set({ errorMessage: oidcIsSupportTab() ? 'Support session ended.' : 'Session expired.' }) await dispatch.auth.signedOut() } }, - async handleSignInSuccess(claims: OidcClaims): Promise { + async handleSignInSuccess(): Promise { // A session — freshly exchanged OR restored from stored tokens — is proof the // automatic path works, so it clears the auto-start budget. Doing it only at the // code exchange left a tab that had spent its budget unable to auto sign-in again // after a perfectly healthy restore. oidcClearAutoStarts() - await dispatch.auth.set({ - authenticated: true, - AWSUser: { - authProvider: Array.isArray(claims.amr) ? claims.amr.join(' ') : String(claims.idp ?? ''), - email: claims.email, - email_verified: claims.email_verified, - }, - }) + await dispatch.auth.set({ authenticated: true }) await dispatch.auth.fetchUser() console.log('AUTHENTICATED SUCCESS') }, @@ -410,13 +339,6 @@ export default createModel()({ dispatch.ui.set({ connected: false }) dispatch.auth.set({ backendAuthenticated: false }) }, - async signInError(signInError: string) { - // Through signInFailure, not a bare signInError set: SignInApp renders the message only - // while signInFailed is true, so a raw string here would never reach the screen. - dispatch.auth.set(signInFailure(new Error(signInError))) - //send message to backend to sign out - emit('user/lock') - }, async backendSignInError(signInError: string) { console.error(signInError) // Tear down FIRST, then record the failure: signedOut() deliberately clears @@ -487,14 +409,9 @@ export default createModel()({ * Gets called when the backend signs the user out */ async signedOut(_: void) { - // Agent (Hydra) session goes with the app session — clears stored - // tokens synchronously, revoke is fire-and-forget so sign-out never - // blocks on it. Runs before the purge (and the transcript reset joins - // the model resets below) so nothing dispatches between purge and a - // signOut-triggered reload — a store write there makes redux-persist - // re-save the pre-signout state for the next user of the machine. - // (The DCR agent session retires with the permitteer chat lane — - // remoteit-ai-agent.md Phase 4; until then both sign-outs run.) + // Runs before the purge (and the transcript reset joins the model resets below) so nothing + // dispatches between purge and a signOut-triggered reload — a store write there makes + // redux-persist re-save the pre-signout state for the next user of the machine. // AWAIT the chat sign-out: it revokes the background-agent grant, whose authenticated DELETE // needs a live token — letting it run unawaited raced the oidcClearLocal() below and left // background AI access alive. chat.signOut bounds itself so this never hangs the sign-out. @@ -583,7 +500,6 @@ export default createModel()({ // because the token service was half-open. Past the bound, the local sign-out proceeds and // the AS is told nothing; that is the failure the mail and the account page can still show. try { - const { signOutEverywhere } = await import('../services/permitteerAccount') const r = await Promise.race([signOutEverywhere(), sleep(SIGN_OUT_EVERYWHERE_TIMEOUT).then(() => null)]) if (!r) console.warn('SIGN OUT EVERYWHERE timed out — signing out locally') else if (r.status === 200) console.log('SIGN OUT EVERYWHERE', r.body) @@ -595,10 +511,6 @@ export default createModel()({ }, }), reducers: { - setAWSUserEmail(state: AuthState, value: string) { - state.AWSUser.email = value - return state - }, set(state: AuthState, params: Partial) { Object.keys(params).forEach(key => (state[key] = params[key])) return state diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index 5afe49973..e90f4645b 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -44,7 +44,6 @@ vi.mock('../services/chatPopout', () => ({ openChatPopout, popIn: vi.fn(), })) -vi.mock('../services/oidc', () => ({ oidcMarkGrantStale: vi.fn() })) vi.mock('../store', () => ({ store: { getState: () => storeState } })) vi.mock('../constants', () => ({ CHAT_PANEL_WIDTH: 400 })) vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 626a75c21..0549cfdb6 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -31,7 +31,6 @@ import { CHAT_PANEL_WIDTH } from '../constants' import i18n from '../i18n' import sleep from '../helpers/sleep' import { formatReset } from '../helpers/dateHelper' -import { oidcMarkGrantStale } from '../services/oidc' export type ChatToolCall = { id: string @@ -129,8 +128,6 @@ function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { return state } -/* The agent service is stateless: resend the transcript as role/content pairs each turn */ - /* Single source of truth for the org the chat is scoped to (null = personal). Membership decides the scope, so the Current Org label and the org sent with each turn can never disagree; the name falls back to the membership @@ -266,7 +263,7 @@ export default createModel()({ // The backend prefixes auth failures so the client knows a retry is pointless // until the grant is renewed (e.g. it expired mid-turn). if (event.type === 'error' && event.message.startsWith('reauth_required')) { - dispatch.chat.unauthorized() + dispatch.chat.unauthorized() // mid-stream, not an HTTP 401 — agentRequest cannot see it dispatch.chat.applyEvent({ type: 'error', message: sessionExpiredError() }) } else dispatch.chat.applyEvent(event) } @@ -274,7 +271,7 @@ export default createModel()({ }) } catch (error) { flushDeltas() - if (error instanceof AgentAuthError) dispatch.chat.unauthorized(authRequiredError()) + if (error instanceof AgentAuthError) dispatch.chat.set({ error: authRequiredError() }) else if (error instanceof UsageLimitError) dispatch.chat.applyEvent({ type: 'error', message: usageLimitMessage(error) }) else if (error instanceof AgentStreamEndedError) @@ -318,10 +315,10 @@ export default createModel()({ }) } catch (error) { // Restore the card so the decision isn't lost with the error - if (error instanceof AgentAuthError) { - dispatch.chat.set({ pendingConfirmation: pending }) - dispatch.chat.unauthorized(authRequiredError()) - } else dispatch.chat.set({ pendingConfirmation: pending, error: (error as Error).message }) + dispatch.chat.set({ + pendingConfirmation: pending, + error: error instanceof AgentAuthError ? authRequiredError() : (error as Error).message, + }) } }, async stop(_: void, state) { @@ -388,18 +385,13 @@ export default createModel()({ // composer until the next reopen or network event, with the agent perfectly reachable. const isLatest = healthProbe() const health = await agentHealth() - if (!isLatest()) return - if (health === 'unauthorized') dispatch.chat.unauthorized() - else dispatch.chat.set({ health }) + if (isLatest()) dispatch.chat.set({ health }) }, /* The agent answering "this grant does not cover me" — a 401 on any endpoint, or reauth_required - mid-stream — is SERVER truth, newer than the client's declaration stamp: the declaration can - be unchanged while the registry behind it moved. The ONE place that fact lands: the stamp is - dropped so the next boot heals on its own and "Refresh permissions" has something to do, and - the composer shows the refusal. Re-authorizing from here would redirect the person mid-turn - and lose whatever they were typing, so it does not. */ + mid-stream. A runtime fact, kept where it was seen: the composer shows the refusal and offers + "Refresh permissions" (auth.healGrant with force — a silent re-authorize). Re-authorizing from + here would redirect the person mid-turn and lose whatever they were typing, so it does not. */ async unauthorized(error?: string) { - oidcMarkGrantStale() dispatch.chat.set({ health: 'unauthorized', ...(error ? { error } : {}) }) }, /* The server owns the transcript now (D11) — adopt its copy when it knows more than @@ -431,9 +423,8 @@ export default createModel()({ const title = remote.title || current.title if (differs) dispatch.chat.set({ messages, title }) else if (title !== current.title) dispatch.chat.set({ title }) - } catch (error) { - if (error instanceof AgentAuthError) dispatch.chat.unauthorized() - /* otherwise offline or deleted — the local display cache stands */ + } catch { + /* offline, refused or deleted — the local display cache stands */ } }, /* The chat has no sign-in of its own anymore — it rides the app session @@ -448,13 +439,11 @@ export default createModel()({ await dispatch.auth.healGrant({ force: true }) await dispatch.chat.checkHealth() }, - /* The history picker's list — refreshed on mount, after a turn, and after a delete. */ - /* Reset the chat when the signed-in IDENTITY changes (not an org switch — that keeps - your account). The conversations, transcript, and usage all belong to the permitteer - subject the agent scopes by; a persisted chat from a previous account must not carry - over (posting to it 404s, and its history isn't yours). Same identity → no-op. */ - /* A different signed-in account drops the persisted chat; the caller reloads the list and the - meter for whoever is signed in (useChatBoot), so a mount never asks twice. */ + /* Reset the chat when the signed-in IDENTITY changes (not an org switch — that keeps your + account): the conversations, transcript and usage all belong to the permitteer subject the + agent scopes by, so a persisted chat from a previous account must not carry over (posting + to it 404s, and its history isn't yours). Same identity → no-op. The caller reloads the + list and the meter for whoever is signed in (useChatBoot), so a mount never asks twice. */ async syncIdentity(userId: string, state) { if (!userId || state.chat.ownerId === userId) return await dispatch.chat.newConversation() @@ -464,21 +453,17 @@ export default createModel()({ on open. Silent on failure; the last-known meter stands. */ async loadUsage() { const isLatest = usageLoad() - try { - const usage = await fetchUsage() - if (usage && isLatest()) dispatch.chat.set({ usage }) - } catch (error) { - if (error instanceof AgentAuthError) dispatch.chat.unauthorized() - } + const usage = await fetchUsage() + if (usage && isLatest()) dispatch.chat.set({ usage }) }, + /* The history picker's list — refreshed when shown, after a turn, and after a delete. */ async loadConversations() { const isLatest = listLoad() try { const conversations = await listConversations() if (isLatest()) dispatch.chat.set({ conversations }) - } catch (error) { - if (error instanceof AgentAuthError) dispatch.chat.unauthorized() - /* otherwise offline — leave the last-known list */ + } catch { + /* offline or refused — leave the last-known list */ } }, /* Switch the panel to an existing conversation: adopt its server transcript, reset the @@ -497,8 +482,8 @@ export default createModel()({ // A service or auth failure is not a deletion: keep the transcript on screen and report, // rather than clearing to a new chat as if the conversation had vanished. Unless the // user has already moved on — then it is only noise about a thread they left. - if (error instanceof AgentAuthError) dispatch.chat.unauthorized(superseded() ? undefined : authRequiredError()) - else if (!superseded()) dispatch.chat.set({ error: (error as Error).message }) + if (superseded()) return + dispatch.chat.set({ error: error instanceof AgentAuthError ? authRequiredError() : (error as Error).message }) return } if (!remote) { @@ -529,8 +514,8 @@ export default createModel()({ let deleted = false try { deleted = await deleteConversation(id) - } catch (error) { - if (error instanceof AgentAuthError) dispatch.chat.unauthorized() + } catch { + /* a rejected request is the same failure as a refused one */ } if (!deleted) { dispatch.chat.set({ @@ -546,10 +531,9 @@ export default createModel()({ if (store.getState().chat.conversationId === id) await dispatch.chat.newConversation() await dispatch.chat.loadConversations() }, - /* App sign-out: nothing agent-specific to revoke — the session's end IS the - chat's end. The transcript reset is dispatched by auth.signedOut alongside - the other model resets — dispatching it here would land in the - purge-to-reload window and re-persist the pre-signout state. */ + /* App sign-out: ends the turn and revokes the background grant (below). The transcript + reset is dispatched by auth.signedOut alongside the other model resets — dispatching it + here would land in the purge-to-reload window and re-persist the pre-signout state. */ async signOut(_: void, state) { broadcastChatSignout() // Aborting covers the STREAM; the generation covers every other load in flight. Without it a diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index 55a10329c..ffea784ee 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -58,8 +58,7 @@ export const AdminUserDetailPage: React.FC = () => { const handleViewAsUser = () => { // Permitteer lane: view-as is a SUPPORT SESSION, not a header (docs/remoteit-desktop- - // login.md Phase 4d). The X-R3-User lane is deliberately dead for these tokens (no - // `delegate` scope is minted), so the eye button is a NAVIGATION into the AS + // login.md Phase 4d), so the eye button is a NAVIGATION into the AS // (permitteer docs/as-elevation.md): the AS runs every launch gate on the operator's own // session — the kill-switch, the operator roster, the target (never an operator), and its // own elevation stamp — then either opens this portal as the user straight away or shows its @@ -70,7 +69,11 @@ export const AdminUserDetailPage: React.FC = () => { // `origin` names THIS portal — the lane the operator is on (app.dev, app.evan, latest) — so // the support session lands here rather than on whichever redirect URI the client lists first // (the AS validates it against the registration). - windowOpen(`${OAUTH_ISSUER}/elevate/launch?user=${encodeURIComponent(user.email || user.id)}&client=remoteit_portal&origin=${encodeURIComponent(window.location.origin)}`) + windowOpen( + `${OAUTH_ISSUER}/elevate/launch?user=${encodeURIComponent( + user.email || user.id + )}&client=remoteit_portal&origin=${encodeURIComponent(window.location.origin)}` + ) } return ( @@ -78,14 +81,16 @@ export const AdminUserDetailPage: React.FC = () => { bodyProps={{ verticalOverflow: true }} header={ - - + + ({}))) as { - error?: string - code?: string - window?: string - resetsAt?: string - } + const body = (await response.json().catch(() => ({}))) as { error?: string; code?: string; resetsAt?: string } if (body.code === 'usage_limit') - throw new UsageLimitError( - body.error || 'Usage limit reached', - (body.window as 'session' | 'weekly' | 'global') ?? 'session', - body.resetsAt ?? null - ) + throw new UsageLimitError(body.error || 'Usage limit reached', body.resetsAt ?? null) } if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`) @@ -229,15 +223,13 @@ export type UsageWindow = { } export type Usage = { session: UsageWindow; weekly: UsageWindow } -/* The user's two usage windows in dollars — drives the header meter. null on any failure but a - refusal, which is the caller's to act on. */ +/* The user's two usage windows in dollars — drives the header meter. null on any failure. */ export async function fetchUsage(): Promise { try { const response = await agentRequest('GET', '/api/usage') if (!response.ok) return null return (await response.json()) as Usage - } catch (error) { - if (error instanceof AgentAuthError) throw error + } catch { return null } } diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 4c9c25a21..91130be6c 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -1,4 +1,5 @@ import browser, { windowOpen } from './browser' +import { isChatPopout } from './chatPopout' import i18n from '../i18n' import { OAUTH_ISSUER, @@ -68,13 +69,12 @@ const ACCOUNTS_KEY = 'oidc.accounts' through it is invisible from the app and its only outward symptom is the AS rate-limiting the whole address, which then locks out everyone behind it. - ONE ledger for every automatic start, keyed by the reason (`boot`, `heal::`, - `recover:`, `activate:`), and the brake sits inside oidcStart itself: a reason - already spent is refused there, so no caller can forget its own one-shot. Session-scoped — - it rides the round trip through the AS and dies with the tab. A completed exchange forgets - the ledger, except a grant heal: an authorize that came back with the grant STILL stale must - not be retried by the next boot, or the tab loops through the AS once per reload. A click is - never counted — a person is their own loop-breaker. */ + ONE ledger for every automatic start, keyed by the reason (`boot`, `heal`, `recover:`, + `activate:`), and the brake sits inside oidcStart itself: a reason already spent is + refused there, so no caller can forget its own one-shot. Session-scoped — it rides the round + trip through the AS and dies with the tab — and a completed exchange forgets it: a healthy + session is proof the automatic path works. A click is never counted — a person is their own + loop-breaker. */ const AUTO_START_KEY = 'oidc.autoStarts' // Two boots, because one legitimate retry (a token that died mid-session) is normal and a third // in one tab never is; every other reason is one. @@ -105,8 +105,11 @@ const spendAutoStart = (reason: string): boolean => { return true } export const oidcClearAutoStarts = (): void => { - const kept = Object.fromEntries(Object.entries(readAutoStarts()).filter(([reason]) => reason.startsWith('heal:'))) - writeAutoStarts(kept) + try { + window.sessionStorage.removeItem(AUTO_START_KEY) + } catch { + /* non-fatal */ + } } // A support TAB keeps its tokens in sessionStorage — per-tab — never in the shared // localStorage. The first cut CLEARED localStorage instead, and localStorage is @@ -218,6 +221,9 @@ export class OidcError extends Error { code: OidcErrorCode /** Seconds to wait, when the server told us (429). */ retryAfter?: number + /** The AS's own error code (`login_required`, `invalid_grant`…) when it sent one — the thing to + * branch on; the message is its wording, which may change. */ + oauthError?: string constructor(code: OidcErrorCode, message: string, retryAfter?: number) { super(message) this.name = 'OidcError' @@ -337,8 +343,6 @@ async function discover() { const redirectUri = () => browser.isElectron || browser.isMobile ? PROTOCOL + 'authCallback' : window.location.origin + '/authCallback' -/** Leave for the AS. On web the page departs; on desktop the main process bounces the - * issuer origin to the system browser and the window stays on the waiting panel. */ /** What this build asks for, per audience. ONE source of truth: the authorize request is * built from it AND the boot check measures tokens against it, so a slice added in a deploy * cannot end up requested-but-never-checked (or checked-but-never-requested). @@ -377,7 +381,12 @@ const timeoutSignal = (ms: number): AbortSignal => { setTimeout(() => controller.abort(), ms) return controller.signal } +// The boot warm-up (mcpDetailReady) and the authorize that follows it seconds later asked for the +// same document twice, and every authorize waited on the fetch (up to its bound) before leaving. +let mcpRefreshedAt = 0 +const MCP_REFRESH_TTL_MS = 60_000 async function refreshMcpDetailType(): Promise { + if (Date.now() - mcpRefreshedAt < MCP_REFRESH_TTL_MS) return mcpDetailType() try { const r = new URL(OAUTH_MCP_RESOURCE) const prm = `${r.origin}/.well-known/oauth-protected-resource${r.pathname}` @@ -398,6 +407,7 @@ async function refreshMcpDetailType(): Promise { names.find(n => !n.endsWith('_org')) if (picked) { mcpTypeMemo = picked + mcpRefreshedAt = Date.now() try { tokenStore().setItem(MCP_TYPE_KEY, picked) } catch { @@ -479,32 +489,15 @@ export function oidcGrantStale(): boolean { } } -/** The fingerprint itself, for a caller that must record WHICH declaration an attempt was made - * from rather than merely that one was (the grant-heal ledger key in models/auth.ts). Exported - * rather than recomputed there, so the two can never disagree about what "the same request" means. */ -export const oidcDeclaration = declarationFingerprint +/** A window that must never leave for the AS on its own account. The chat POPOUT is a helper of + * the main window, which owns the session. A SUPPORT tab holds an operator's acted session, and + * any authorize but the ticketed launch would sign the operator in as THEMSELVES and quietly turn + * the support view into their own account. Enforced where every authorize starts, so no lane — + * boot, heal, recover, activate, a button — needs a guard of its own. */ +export const oidcLeaveRefused = (): boolean => isChatPopout || oidcIsSupportTab() -/** A resource server answering "this grant does not cover me" is SERVER truth, newer than the - * stamp: the declaration can be unchanged while the registry behind it moved (2026-09-06: app.ai - * was repointed at a new MCP resource hours before the actor was registered to act toward it). - * Drop the stamp — active AND this account's registry entry — so oidcGrantStale() answers true - * and the next boot heals, or the person's "Refresh permissions" does, with nothing else to reset. */ -export function oidcMarkGrantStale(): void { - try { - tokenStore().removeItem(DECLARATION_KEY) - const sub = oidcClaims()?.sub - const reg = readRegistry() - if (sub && reg[sub]?.declaration) { - delete reg[sub].declaration - writeRegistry(reg) - } - } catch { - /* non-fatal — worst case is one redundant re-authorize */ - } -} - -/** Leave for the AS. `auto` names an authorize nobody clicked for (see the ledger above); it - * resolves false, without leaving, when that reason has been spent. */ +/** Leave for the AS. `auto` names an authorize nobody clicked for (see the ledger above). Resolves + * false, without leaving, when that reason has been spent or this window may not leave. */ export async function oidcStart( opts: { prompt?: 'login' | 'select_account' | 'none' @@ -513,6 +506,10 @@ export async function oidcStart( auto?: string } = {} ): Promise { + if (oidcLeaveRefused() && !opts.supportTicket) { + console.warn('OIDC: this window does not sign in on its own account') + return false + } if (opts.auto && !spendAutoStart(opts.auto)) { console.warn(`OIDC: automatic sign-in (${opts.auto}) already attempted this session — not retrying`) return false @@ -581,12 +578,13 @@ export async function oidcCompleteFromUrl(): Promise { const flow = takeFlow(state) cleanUrl() - // takeFlow already matches on `state` — the session copy explicitly, the shared copy by key — - // so the old flow.state check is now inside it. The typed error stays: the chat UI distinguishes - // an expired flow from a refused one. if (!flow) throw new OidcError('expired', 'Sign-in state mismatch') const error = query.get('error') - if (error) throw new OidcError('refused', query.get('error_description') || error) + if (error) { + const refused = new OidcError('refused', query.get('error_description') || error) + refused.oauthError = error + throw refused + } const body = await tokenRequest({ grant_type: 'authorization_code', @@ -624,6 +622,7 @@ export async function oidcCompleteFromUrl(): Promise { } else { persist({ refresh_token: body.refresh_token, id_token: body.id_token }) } + clearActivationHint() // The authorize that just completed asked for DECLARED, and a skipConsent first-party grant // is merged from exactly that — so the grant now covers this build. Stamp it — active AND // this account's registry entry, so a later activation restores the right measurement. @@ -669,7 +668,7 @@ export async function oidcAccessToken(resource: string = OAUTH_GRAPHQL_RESOURCE) return next } -// CROSS-TAB single-flight. The `refreshing` guard above stops a tab racing itself, but the refresh +// CROSS-TAB single-flight. The `minting` queue above stops a tab racing itself, but the refresh // token lives in localStorage and every tab of this origin shares it — so two tabs redeem the SAME // token, the AS sees a double-spend and answers as theft: the family is revoked and the person is // mailed "A sign-in was ended as a precaution". Not hypothetical — dev logged two reuse_detected @@ -770,6 +769,7 @@ export function invalidateOidcToken() { export { clearLocal as oidcClearLocal } function clearLocal() { + clearActivationHint() const activeSub = oidcClaims()?.sub const reg = readRegistry() if (activeSub && reg[activeSub]) { @@ -867,16 +867,30 @@ export function oidcAccounts(): OidcAccount[] { .sort((a, b) => Number(b.active) - Number(a.active) || (a.email ?? a.sub).localeCompare(b.email ?? b.sub)) } +/** WHO was just activated, so a boot that finds the saved tokens dead can try one silent recovery + * (prompt=none + login_hint — the AS serves any live set member the hint names) before falling to + * the sign-in screen. Data, not a brake: the ledger in oidcStart bounds the attempt; the hint is + * cleared with the tokens (clearLocal) and by the exchange that completes. sessionStorage: dies + * with the tab. */ +const ACTIVATING_KEY = 'oidc.activating' +export const oidcActivationHint = (): string | undefined => { + try { + return sessionStorage.getItem(ACTIVATING_KEY) || undefined + } catch { + return undefined + } +} +const clearActivationHint = () => { + try { + sessionStorage.removeItem(ACTIVATING_KEY) + } catch { + /* nothing to clear */ + } +} + /** Make a saved account the ACTIVE one. Storage-only — the caller reloads the app so * every model boots as the new identity (a soft swap would bleed one account's data * into the other's view). Returns false when the account is unknown. */ -/** One-shot marker: WHO was just activated, so a boot that finds the saved tokens dead can - * try ONE silent recovery (prompt=none + login_hint — the AS serves any live set member - * the hint names) before falling to the sign-in screen. sessionStorage: dies with the tab, - * and it is cleared before the attempt so a failed round can never loop. */ -const ACTIVATING_KEY = 'oidc.activating' -export const oidcTakeActivationHint = () => takeSession(ACTIVATING_KEY) - export function oidcActivateAccount(sub: string): boolean { const entry = readRegistry()[sub] if (!entry?.refresh_token) return false @@ -1054,7 +1068,7 @@ async function tokenRequest(params: { [key: string]: string }): Promise { const body: any = await response.json().catch(() => ({})) if (!response.ok || !body.access_token) { const detail = body.error_description || body.error || `token endpoint ${response.status}` - const error: any = responseError(response, detail) + const error = responseError(response, detail) error.oauthError = body.error throw error } @@ -1066,28 +1080,9 @@ async function tokenRequest(params: { [key: string]: string }): Promise { // account API serves the set from this token's own session. Members this app holds no tokens // for are filed as KNOWN — identity only — and the menu offers them; picking one is a silent // selection (prompt=none + login_hint), which the AS answers for any live set member. -/** Why a refresh did not happen. `refused` is the one that used to be invisible: the menu - * kept rendering its cache while the AS was turning the call away, so a stale list and a - * broken one looked identical — on screen and in the console. */ -export type BrowserAccountsRefresh = - | { ok: true; multi: boolean; authoritative: boolean } - | { ok: false; reason: 'support-session' | 'no-token' | 'refused'; status?: number } - -export async function oidcRefreshBrowserAccounts(): Promise { - if (oidcActor()) return { ok: false, reason: 'support-session' } // no set member, nothing to switch to - const url = `${OAUTH_ACCOUNT_RESOURCE}/accounts` - const headers = await oidcAuthHeaders('GET', url, OAUTH_ACCOUNT_RESOURCE) - if (!headers.authorization) return { ok: false, reason: 'no-token' } - const r = await fetch(url, { headers }) - if (!r.ok) { - // Never silently: an unreconciled menu is showing accounts that may not exist and hiding - // ones that do, and the person has no way to tell. Say so where a bug report can find it. - console.warn( - `AUTH: the browser's accounts could not be refreshed (${r.status}) — the menu is showing its last known list` - ) - return { ok: false, reason: 'refused', status: r.status } - } - const body = (await r.json()) as { +export async function oidcRefreshBrowserAccounts(): Promise { + if (oidcActor()) return // a support session is no set member — nothing to switch to + const { status, body } = await oidcResourceRequest<{ multi?: boolean authoritative?: boolean accounts?: { @@ -1097,6 +1092,14 @@ export async function oidcRefreshBrowserAccounts(): Promise(OAUTH_ACCOUNT_RESOURCE, '/accounts') + if (status !== 200 || !body) { + // Never silently: an unreconciled menu is showing accounts that may not exist and hiding + // ones that do, and the person has no way to tell. Say so where a bug report can find it. + console.warn( + `AUTH: the browser's accounts could not be refreshed (${status}) — the menu is showing its last known list` + ) + return } const listed = new Set() const reg = readRegistry() @@ -1144,7 +1147,6 @@ export async function oidcRefreshBrowserAccounts(): Promise { diff --git a/frontend/src/services/passportSelf.ts b/frontend/src/services/passportSelf.ts index e7fcbba36..fd51394ee 100644 --- a/frontend/src/services/passportSelf.ts +++ b/frontend/src/services/passportSelf.ts @@ -9,6 +9,13 @@ import { OAUTH_PASSPORT_RESOURCE } from '../constants' * console's re-authentication (permitteer docs/remoteit-desktop-login.md Phase 2b). */ +export type MfaMethod = 'totp' | 'sms' +// English fallbacks for the `mfa.method.` catalog keys, shared by every surface that lists factors. +export const METHOD_LABEL: Record = { totp: 'Authenticator app', sms: 'Text message' } +export type Passkey = { id: string; name: string } + +/* Every self-API answer in one shape: the continuation of a write (status/challenge/…), the MFA + standing (methods/preferred/available), the account (passkeys), or an error. */ export type SelfContinuation = { status?: 'ok' | 'mfa' | 'confirm' | 'select' | 'register' challenge?: string @@ -18,32 +25,27 @@ export type SelfContinuation = { delivery?: 'sms' options?: string[] | Record name?: string - methods?: string[] - preferred?: string + methods?: MfaMethod[] + preferred?: MfaMethod + available?: MfaMethod[] + passkeys?: Passkey[] recovery_codes?: string[] error?: string error_description?: string } +export type SelfResult = SelfContinuation & { httpStatus: number } -const call = async ( - path: string, - body?: Record -): Promise => { +const call = async (path: string, body?: Record): Promise => { const r = await oidcResourceRequest( OAUTH_PASSPORT_RESOURCE, path, body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {} ) - return { ...(r.body ?? (r.status === 401 ? { error: 'unauthorized' } : {})), httpStatus: r.status } + return { ...r.body, httpStatus: r.status } } -export type MfaMethod = 'totp' | 'sms' -// English fallbacks for the `mfa.method.` catalog keys, shared by every surface that lists factors. -export const METHOD_LABEL: Record = { totp: 'Authenticator app', sms: 'Text message' } -export type MfaStanding = { methods: MfaMethod[]; preferred?: MfaMethod; available: MfaMethod[] } - export const selfMe = () => call('') -export const selfMfaStanding = () => call('/mfa') as Promise +export const selfMfaStanding = () => call('/mfa') export const selfChangePassword = (current_password: string, new_password: string) => call('/password', { current_password, new_password }) /** Answer a pending challenge: a code — or, for a factor CHOICE (select), the method. */ diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts index e7cdd339d..df4cc530e 100644 --- a/frontend/src/services/permitteerAccount.ts +++ b/frontend/src/services/permitteerAccount.ts @@ -6,8 +6,6 @@ import { oidcResourceRequest, OidcResourceResult } from './oidc' import { OAUTH_ACCOUNT_RESOURCE } from '../constants' -export type AccountApiResult = OidcResourceResult - /** The legal token targets for THIS client — the AS's allowlist joined to registry names * (D10). The stage picker and the mint-time guardrail read the SAME source, so they can * never disagree; adding a stage to the tf allowlist puts it here on the next fetch. */ @@ -25,17 +23,17 @@ const call = (path: string, init: RequestInit = {}) => * tokens for the person revoked as well (the legacy apps' sessions). `pool` reports that half: * skipped (no pool on this stage), none, revoked, or failed. The token that makes this call is * dead by the time the answer is read; the caller tears the app down right after. */ -export async function signOutEverywhere(): Promise> { +export async function signOutEverywhere(): Promise> { return await call('/devices/sign-out-all', { method: 'POST' }) } /** The person's connected apps — the AS account API's own view rows, unreshaped. */ -export async function accountApps(): Promise> { +export async function accountApps(): Promise> { return await call('/apps') } /** Revoke one grant. Instant at the AS — the grant dies and every refresh token with it. */ -export async function revokeAccountApp(grantId: string): Promise { +export async function revokeAccountApp(grantId: string): Promise { return await call(`/apps/${encodeURIComponent(grantId)}`, { method: 'DELETE' }) } @@ -47,7 +45,7 @@ export async function updateAccountApp( keep: string[], keepScope: string[], reach?: { all?: boolean; accounts?: string[] } -): Promise { +): Promise { return await call(`/apps/${encodeURIComponent(grantId)}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index e2c731e39..cd4690e66 100644 --- a/frontend/src/services/post.ts +++ b/frontend/src/services/post.ts @@ -78,7 +78,7 @@ export async function apiError(error: unknown) { await sleep(1000 * errorCount * errorCount) // The status rides along: under a SUPPORT session a 401 is terminal (no refresh token, the // session is gone) while a 403 is an ordinary refused write — checkSession tells them apart. - auth.checkSession({ silent: true, status: error.response?.status }) + auth.checkSession({ status: error.response?.status }) } } From ecb0650bbb34f3a21b7e70e87fcdf1ad614453ee Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 17:39:21 -0700 Subject: [PATCH 230/238] refactor(chat): the org is a selector over the active account; the popout's scope is store state; one latest-wins ticket; one empty state --- frontend/src/components/App.tsx | 2 -- frontend/src/components/Chat/ChatBody.tsx | 34 +++++++++++------- frontend/src/components/Chat/ChatPanel.tsx | 2 +- frontend/src/helpers/latestWins.ts | 13 +++++++ frontend/src/helpers/sleep.ts | 4 +++ frontend/src/hooks/useChatSync.ts | 32 +++-------------- frontend/src/models/adminAddonLicenses.ts | 19 +++++----- frontend/src/models/auth.ts | 4 +-- frontend/src/models/chat.test.ts | 11 ++++-- frontend/src/models/chat.ts | 42 +++++++++------------- frontend/src/services/chatPopout.ts | 16 +++++---- frontend/src/store.ts | 16 ++++++--- 12 files changed, 102 insertions(+), 93 deletions(-) create mode 100644 frontend/src/helpers/latestWins.ts diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 7100a6756..679b6c631 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -19,7 +19,6 @@ import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' import { useChatEnabled, useSidebarWidth, useLayoutBreakpoints } from '../hooks/useChatEnabled' -import { useChatPopoutScope } from '../hooks/useChatSync' import { Router } from '../routers/Router' import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' @@ -77,7 +76,6 @@ export const App: React.FC = () => { // Before the popout's entitlement gate below can be read, the window must run under the // account scope that opened it (otherwise it reads the personal account's license) - useChatPopoutScope() useEffect(() => { hideSplashScreen() diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index 8e6fc63c2..bb8b6fae5 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -17,6 +17,17 @@ import { CHAT_GUIDE_DATE } from '../../constants' /* Everything below the chat header — shared by the docked panel and the popout window */ +/* The chat's empty states: the agent mark, a sentence, and whatever the state offers. */ +const ChatEmpty: React.FC<{ message: React.ReactNode; children?: React.ReactNode }> = ({ message, children }) => ( + + + + {message} + + {children} + +) + export const ChatBody: React.FC = () => { const { t } = useTranslation() const messages = useSelector((state: State) => state.chat.messages) @@ -51,25 +62,22 @@ export const ChatBody: React.FC = () => { )} {signedOut ? ( - - - - {t('chat.signInNeeded', 'The AI agent needs permissions your session doesn\u2019t carry yet.')} - {isChatPopout && ` ${t('chat.signInFromMain', 'Refresh permissions from the main app window.')}`} - + + {t('chat.signInNeeded', 'The AI agent needs permissions your session doesn\u2019t carry yet.')} + {isChatPopout && ` ${t('chat.signInFromMain', 'Refresh permissions from the main app window.')}`} + + } + > {!oidcLeaveRefused() && ( )} - + ) : unreachable && !messages.length ? ( - - - - {unavailableMessage} - - + ) : !messages.length && !pendingConfirmation && !error ? ( ) : ( diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 3328f9825..9b1ad50b4 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -33,8 +33,8 @@ const INSET = 1 export const ChatPanel: React.FC = () => { const { t } = useTranslation() const open = useSelector((state: State) => state.chat.open) - const insets = useSelector((state: State) => state.ui.layout.insets) const layout = useSelector((state: State) => state.ui.layout) + const insets = layout.insets // Popping out hands the conversation to a second window and stop()s this one. While a turn is // still streaming or an approval card is pending, the handoff can't carry/resume it — the popup // couldn't action the approval and the server-side turn would strand — so block it until idle. diff --git a/frontend/src/helpers/latestWins.ts b/frontend/src/helpers/latestWins.ts new file mode 100644 index 000000000..b449a389b --- /dev/null +++ b/frontend/src/helpers/latestWins.ts @@ -0,0 +1,13 @@ +/* A latest-wins ticket for one kind of request: `take()` before the call, and apply the answer + only if the ticket it returns still says it is the newest. `invalidate()` retires everything in + flight without starting anything (a sign-out, a switch of target). */ +export const latestWins = () => { + let current = 0 + return { + take: () => { + const ticket = ++current + return () => ticket === current + }, + invalidate: () => void ++current, + } +} diff --git a/frontend/src/helpers/sleep.ts b/frontend/src/helpers/sleep.ts index 3fc7edd75..6d22fb5c0 100644 --- a/frontend/src/helpers/sleep.ts +++ b/frontend/src/helpers/sleep.ts @@ -1,3 +1,7 @@ export default function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) } + +/** The promise, or null once `ms` has passed without it — a bounded wait on a best-effort call. */ +export const withTimeout = (promise: Promise, ms: number): Promise => + Promise.race([promise, sleep(ms).then(() => null)]) diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index 4c1474080..629be5812 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -8,8 +8,6 @@ import { initChatPopoutWindow, checkPopoutPresence, PopoutMainHandlers, - isChatPopout, - popoutScopeId, } from '../services/chatPopout' import network from '../services/Network' @@ -28,20 +26,6 @@ const useAgentHealthOnReconnect = (check: () => void): void => { const currentHandoff = () => toChatHandoff(store.getState().chat) -/* Popout boot: run under the account scope of the window that opened it. Everything org-scoped - — App's chat entitlement gate above all — resolves through accounts.activeId, which the - popout's no-op persistence leaves unset, i.e. the PERSONAL account: a chat licensed only for - an organization would be refused in its own popout. This must run OUTSIDE that gate - (ChatWindow's own hooks sit behind it), so App calls it unconditionally; it is a no-op in - the main window. accounts.parse clears a scope the user is no member of, so a hand-edited - URL can only ever land back on the personal account. */ -export const useChatPopoutScope = (): void => { - const dispatch = useDispatch() - useEffect(() => { - if (isChatPopout && popoutScopeId) dispatch.accounts.set({ activeId: popoutScopeId }) - }, []) -} - /* The identity the chat is scoped by. auth.user, NOT the persisted `user` model: auth.user is fetched for the CURRENT tokens at sign-in (it is what lets App mount), while the user model is restored from storage and only catches up when the cloud sync lands. Activating a saved @@ -55,15 +39,8 @@ const useChatIdentity = (): string => useSelector((state: State) => state.auth.u dock closed asks the agent for nothing: no list, no meter, no transcript. */ const useChatBoot = (open: boolean): void => { const userId = useChatIdentity() - const activeId = useSelector((state: State) => state.accounts.activeId) const dispatch = useDispatch() - // The chat follows the app's active org: the sidebar selector here, the scope it was opened - // under in the popout (useChatPopoutScope sets it before this runs) - useEffect(() => { - dispatch.chat.syncOrg() - }, [activeId]) - // Declared first so a persisted chat from a previous account is dropped before anything loads // it (the identity sync no-ops for the same account, so it is safe to chase on every open). The // list, the meter and the server's copy of the transcript follow: once per account per opening. @@ -84,6 +61,10 @@ const useChatBoot = (open: boolean): void => { dispatch.chat.resetTransient() }, []) + useEffect(() => { + if (open) dispatch.chat.checkHealth() + }, [open]) + useAgentHealthOnReconnect(() => dispatch.chat.checkHealth()) } @@ -132,10 +113,6 @@ export const useChatMainSync = (): void => { dispatch.chat.stop() } }, []) - - useEffect(() => { - if (open) dispatch.chat.checkHealth() - }, [open]) } /* Popout-window chat lifecycle: adopt the handed-off conversation, answer @@ -148,7 +125,6 @@ export const useChatPopoutSync = (): void => { useEffect(() => { document.title = t('chat.windowTitle', 'remote.it chat') - dispatch.chat.checkHealth() const unsubscribe = initChatPopoutWindow({ adopt: payload => dispatch.chat.adoptHandoff(payload), getHandoff: currentHandoff, diff --git a/frontend/src/models/adminAddonLicenses.ts b/frontend/src/models/adminAddonLicenses.ts index a4c212743..05a7f0162 100644 --- a/frontend/src/models/adminAddonLicenses.ts +++ b/frontend/src/models/adminAddonLicenses.ts @@ -1,4 +1,5 @@ import { createModel } from '@rematch/core' +import { latestWins } from '../helpers/latestWins' import { graphQLAdminAddonCustomers, graphQLAdminAddonProducts } from '../services/graphQLRequest' import { getApiURL } from '../helpers/apiHelper' import type { RootModel } from '.' @@ -77,8 +78,8 @@ type Page = { customers: AdminAddonCustomer[]; total: number; hasMore: boolean } the list it replaced. (Comparing the response's product and search to the store at resolve time would let exactly that through — they still match.) The product list has its own, invalidated only by sign-out: it is not scoped to a selection, and two of its responses say the same thing. */ -let listRequest = 0 -let productsRequest = 0 +const listRequest = latestWins() +const productsRequest = latestWins() const emptiedList = { customers: [], total: 0, hasMore: false, listStatus: 'idle' as const } @@ -113,7 +114,7 @@ export const adminAddonLicenses = createModel()({ effects: dispatch => { // One page of the current product's customers: `from` 0 replaces the list, anything else appends. const loadPage = async (state: AdminAddonLicensesState, from: number) => { - const ticket = ++listRequest + const isLatest = listRequest.take() dispatch.adminAddonLicenses.setListStatus('loading') const result = await graphQLAdminAddonCustomers( @@ -124,7 +125,7 @@ export const adminAddonLicenses = createModel()({ // Superseded: a newer request, or an event that retired this one, owns the list (and its // status) now — this response describes a list nobody is looking at. - if (ticket !== listRequest) return + if (!isLatest()) return const data = result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonCustomers if (!data) { @@ -141,10 +142,10 @@ export const adminAddonLicenses = createModel()({ /* The product catalogue. Resolves to the fresh list, or undefined when nothing answered — the products held stay, marked failed. */ async fetchProducts(): Promise { - const ticket = ++productsRequest + const isLatest = productsRequest.take() dispatch.adminAddonLicenses.setProductsStatus('loading') const result = await graphQLAdminAddonProducts() - if (ticket !== productsRequest) return undefined + if (!isLatest()) return undefined // No response at all (offline, no auth header yet) is not an empty list. const products: AdminAddonProduct[] | undefined = @@ -173,7 +174,7 @@ export const adminAddonLicenses = createModel()({ const target = getApiURL() // A page still in flight from the other target is retired with its rows — it would otherwise // pass the ticket check and refill the emptied list while the catalogue is awaited. - if (target !== rootState.adminAddonLicenses.target) ++listRequest + if (target !== rootState.adminAddonLicenses.target) listRequest.invalidate() dispatch.adminAddonLicenses.setTarget(target) const products = await dispatch.adminAddonLicenses.fetchProducts() if (!products) return @@ -206,8 +207,8 @@ export const adminAddonLicenses = createModel()({ // Sign-out: nothing in flight may land in the next session's state. async reset() { - ++listRequest - ++productsRequest + listRequest.invalidate() + productsRequest.invalidate() dispatch.adminAddonLicenses.resetState() }, } diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index c7cc612ee..ff9d7297f 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -34,7 +34,7 @@ import { createModel } from '@rematch/core' import { RootModel } from '.' import zendesk from '../services/zendesk' import i18n from '../i18n' -import sleep from '../helpers/sleep' +import { withTimeout } from '../helpers/sleep' export interface AuthState { initialized: boolean @@ -500,7 +500,7 @@ export default createModel()({ // because the token service was half-open. Past the bound, the local sign-out proceeds and // the AS is told nothing; that is the failure the mail and the account page can still show. try { - const r = await Promise.race([signOutEverywhere(), sleep(SIGN_OUT_EVERYWHERE_TIMEOUT).then(() => null)]) + const r = await withTimeout(signOutEverywhere(), SIGN_OUT_EVERYWHERE_TIMEOUT) if (!r) console.warn('SIGN OUT EVERYWHERE timed out — signing out locally') else if (r.status === 200) console.log('SIGN OUT EVERYWHERE', r.body) else console.warn('SIGN OUT EVERYWHERE refused', r.status, r.body) diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index e90f4645b..4e58f0b1f 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -44,6 +44,12 @@ vi.mock('../services/chatPopout', () => ({ openChatPopout, popIn: vi.fn(), })) +// The org selector's real dependency chain (selectors/state → services/browser) is the app's; +// the model test needs only its answer. +vi.mock('../selectors/accounts', () => ({ + selectActiveAccountId: (s: any) => s.accounts?.activeId || s.auth?.user?.id || '', + isUserAccount: (s: any) => !s.accounts?.activeId || s.accounts.activeId === s.auth?.user?.id, +})) vi.mock('../store', () => ({ store: { getState: () => storeState } })) vi.mock('../constants', () => ({ CHAT_PANEL_WIDTH: 400 })) vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) @@ -69,8 +75,9 @@ const makeDispatch = () => ({ // The wider snapshot send() reads (resolveChatOrg looks at the user and memberships) const sendable = (chat: Record = {}) => ({ ...current(chat), - user: { id: 'u' }, - accounts: { membership: [] }, + auth: { user: { id: 'u' } }, + accounts: { activeId: '', membership: [] }, + organization: { accounts: {} }, }) // A fetch the test resolves by hand, to interleave user actions with an in-flight request. diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 0549cfdb6..6ffbfe8d2 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -29,8 +29,10 @@ import { store } from '../store' import type { State } from '../store' import { CHAT_PANEL_WIDTH } from '../constants' import i18n from '../i18n' -import sleep from '../helpers/sleep' +import { withTimeout } from '../helpers/sleep' +import { latestWins } from '../helpers/latestWins' import { formatReset } from '../helpers/dateHelper' +import { selectActiveAccountId, isUserAccount } from '../selectors/accounts' export type ChatToolCall = { id: string @@ -57,7 +59,6 @@ export type IChatState = { /** The signed-in user id this chat belongs to — reset the chat when it changes. */ ownerId: string /** Org the agent is scoped to; null = uninitialized, user id = personal */ - orgId: string | null /** Conversation currently lives in the popout window (main window only) */ poppedOut: boolean streaming: boolean @@ -76,7 +77,6 @@ export const defaultChatState: IChatState = { conversations: [], usage: null, ownerId: '', - orgId: null, poppedOut: false, streaming: false, pendingConfirmation: null, @@ -128,13 +128,14 @@ function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { return state } -/* Single source of truth for the org the chat is scoped to (null = personal). - Membership decides the scope, so the Current Org label and the org sent - with each turn can never disagree; the name falls back to the membership - record when organization.accounts hasn't loaded. */ +/* The org the chat is scoped to (null = personal): the app's active account, read from the same + selector the rest of the app uses — no copy of it to keep in step, in either window (the popout + boots under the scope it was opened with). Membership decides, so the Current Org label and the + org sent with each turn can never disagree; the name falls back to the membership record when + organization.accounts hasn't loaded. */ export function resolveChatOrg(state: State): OrgSelection | null { - const orgId = state.chat.orgId - if (!orgId || orgId === state.user.id) return null + if (isUserAccount(state)) return null + const orgId = selectActiveAccountId(state) const membership = state.accounts.membership.find(m => m.account.id === orgId) if (!membership) return null const name = (state.organization.accounts[orgId]?.name || membership.name || '').trim() @@ -198,13 +199,6 @@ const nextGeneration = () => ++generation /* Independent probes — health, the history list, the usage meter — are not scoped to the conversation, so they get their own latest-wins tickets instead: an older response that lands last must not overwrite a newer one. */ -const latestWins = () => { - let current = 0 - return () => { - const ticket = ++current - return () => ticket === current - } -} const healthProbe = latestWins() const listLoad = latestWins() const usageLoad = latestWins() @@ -296,11 +290,6 @@ export default createModel()({ dispatch.chat.loadUsage() } }, - /* The chat follows the app's active org (the sidebar selector) — both windows mirror it - whenever it changes; the popout's is the scope it was opened under (useChatPopoutScope). */ - async syncOrg(_: void, state) { - dispatch.chat.set({ orgId: state.accounts.activeId || state.user.id }) - }, async confirm(approved: boolean, state) { const pending = state.chat.pendingConfirmation if (!pending) return @@ -383,7 +372,7 @@ export default createModel()({ // Latest probe wins: a slow probe started while connectivity was failing must not land after // the reconnect-triggered one and flip a fresh `ok` back to `unreachable` — which disabled the // composer until the next reopen or network event, with the agent perfectly reachable. - const isLatest = healthProbe() + const isLatest = healthProbe.take() const health = await agentHealth() if (isLatest()) dispatch.chat.set({ health }) }, @@ -452,13 +441,13 @@ export default createModel()({ /* The usage meter (docs/usage-limits.md D6) — refreshed on mount, after each turn, and on open. Silent on failure; the last-known meter stands. */ async loadUsage() { - const isLatest = usageLoad() + const isLatest = usageLoad.take() const usage = await fetchUsage() if (usage && isLatest()) dispatch.chat.set({ usage }) }, /* The history picker's list — refreshed when shown, after a turn, and after a delete. */ async loadConversations() { - const isLatest = listLoad() + const isLatest = listLoad.take() try { const conversations = await listConversations() if (isLatest()) dispatch.chat.set({ conversations }) @@ -551,7 +540,10 @@ export default createModel()({ const who = state?.auth?.user?.id if (who && backgroundRevokedFor === who) return backgroundRevokedFor = who ?? null - await Promise.race([backgroundDisable().catch(() => {}), sleep(3000)]) + await withTimeout( + backgroundDisable().catch(() => {}), + 3000 + ) }, }), reducers: { diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts index 28e2d2275..9fcf1f197 100644 --- a/frontend/src/services/chatPopout.ts +++ b/frontend/src/services/chatPopout.ts @@ -23,12 +23,11 @@ export const isChatPopout = bootQuery.has(CHAT_POPOUT_PARAM) // it. Every main tab hears the shared channel, so directed messages carry // this id and non-owner tabs ignore them. const popoutId = bootQuery.get(CHAT_POPOUT_PARAM) || '' -/* The account scope of the window that opened this popout, for the boot to adopt - (useChatPopoutScope). The popout persists nothing, so accounts.activeId starts unset and - every org-scoped read — the chat entitlement gate above all — falls back to the PERSONAL - account, refusing a chat that is licensed only for an organization. User-controlled like - the rest of the URL, and safe that way: a scope the user is no member of is cleared again - by accounts.parse, back to the personal account the gate would have read anyway. */ +/* The account scope of the window that opened this popout — the store's initial accounts.activeId + (store.ts). The popout persists nothing, so without it every org-scoped read — the chat + entitlement gate above all — would fall back to the PERSONAL account, refusing a chat that is + licensed only for an organization. User-controlled like the rest of the URL, and safe that way: + a scope the user is no member of is cleared again by accounts.parse. */ export const popoutScopeId = bootQuery.get(SCOPE_PARAM) || '' // Per-tab (sessionStorage survives a reload of the owning tab, but no other @@ -145,7 +144,10 @@ export function initChatPopoutMain(handlers: PopoutMainHandlers): () => void { } } channel.addEventListener('message', listener) - return () => channel.removeEventListener('message', listener) + return () => { + channel.removeEventListener('message', listener) + stopPolling() + } } /* Ask whether a popout survives from a previous page load; corrects a stale diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 407867aaa..b89c1bf64 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,7 +1,7 @@ import { numericVersion } from './helpers/versionHelper' import { models, RootModel } from './models' import { defaultChatState, IChatState } from './models/chat' -import { isChatPopout } from './services/chatPopout' +import { isChatPopout, popoutScopeId } from './services/chatPopout' import { createLogger, ReduxLoggerOptions } from 'redux-logger' import { init, RematchDispatch, RematchRootState } from '@rematch/core' import { createTransform, PersistConfig } from 'redux-persist' @@ -23,7 +23,6 @@ const chatTransform = createTransform( messages: inbound.messages, conversationId: inbound.conversationId, title: inbound.title, - orgId: inbound.orgId, ownerId: inbound.ownerId, open: inbound.open, width: inbound.width, @@ -74,8 +73,17 @@ const persistConfig: PersistConfig = { export const store = init({ models, plugins: [immerPlugin(), persistPlugin(persistConfig)], - // @ts-ignore - redux: { middlewares: [createLogger(loggerConfig)] }, + redux: { + // @ts-ignore + middlewares: [createLogger(loggerConfig)], + // The popout persists nothing, so its account scope — the one the opening window handed it on + // the URL — is its INITIAL state, exactly where a rehydrated main window's would come from. + // Every org-scoped read (the chat entitlement gate above all) then resolves the same way in + // both windows; accounts.parse still clears a scope the user is no member of. + ...(isChatPopout && popoutScopeId + ? { initialState: { accounts: { ...models.accounts.state, activeId: popoutScopeId } } } + : {}), + }, }) export const { dispatch } = store From 8385d2be31aabfcd9574cd20258419d13ffeb746 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 17:43:05 -0700 Subject: [PATCH 231/238] =?UTF-8?q?refactor:=20one=20REST=20header=20prolo?= =?UTF-8?q?gue,=20one=20https=20guard,=20one=20route=20constant,=20one=20t?= =?UTF-8?q?oggle=20chip=20=E2=80=94=20and=20the=20layout=20read=20from=20w?= =?UTF-8?q?here=20it=20is=20published?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buttons/RefreshButton/RefreshButton.tsx | 4 +- frontend/src/components/AdminSidebarNav.tsx | 5 +- frontend/src/components/Chat/ChatPanel.tsx | 10 +- frontend/src/components/Header/Header.tsx | 8 +- frontend/src/components/LimitSetting.tsx | 3 +- frontend/src/constants.ts | 1 + frontend/src/helpers/utilHelper.ts | 6 +- .../AdminAddonLicensesListPage.tsx | 3 +- frontend/src/pages/ConnectedAppDetailPage.tsx | 177 +++++++++--------- frontend/src/routers/Router.tsx | 3 +- frontend/src/services/agent.ts | 3 +- frontend/src/services/get.ts | 10 +- frontend/src/services/jobLogs.ts | 12 +- frontend/src/services/oidc.ts | 5 +- frontend/src/services/post.ts | 10 +- frontend/src/services/remoteit.ts | 18 +- types.d.ts | 17 +- 17 files changed, 143 insertions(+), 152 deletions(-) diff --git a/frontend/src/buttons/RefreshButton/RefreshButton.tsx b/frontend/src/buttons/RefreshButton/RefreshButton.tsx index 9e5ded271..7a53dcaef 100644 --- a/frontend/src/buttons/RefreshButton/RefreshButton.tsx +++ b/frontend/src/buttons/RefreshButton/RefreshButton.tsx @@ -5,7 +5,7 @@ import cloudController from '../../services/cloudController' import cloudSync from '../../services/CloudSync' import { emit } from '../../services/Controller' import { Dispatch, State } from '../../store' -import { VALID_JOB_ID_LENGTH, GUIDE_START_DATE } from '../../constants' +import { VALID_JOB_ID_LENGTH, GUIDE_START_DATE, ADMIN_ADDONS_ROUTE } from '../../constants' import { useParams, useRouteMatch } from 'react-router-dom' import { selectDeviceModelAttributes, selectDevice } from '../../selectors/devices' import { useDispatch, useSelector } from 'react-redux' @@ -39,7 +39,7 @@ export const RefreshButton: React.FC = props => { const adminUsersPage = useRouteMatch('/admin/users') const adminPartnersPage = useRouteMatch('/admin/partners') const adminEnterpriseLicensesPage = useRouteMatch('/admin/enterprise-licenses') - const adminAddonLicensesPage = useRouteMatch<{ productId?: string }>('/admin/add-ons/:productId?') + const adminAddonLicensesPage = useRouteMatch<{ productId?: string }>(`${ADMIN_ADDONS_ROUTE}/:productId?`) const adminNoticesPage = useRouteMatch('/admin/notices') const scriptingPage = useRouteMatch(['/script', '/scripts', '/runs']) const runsPage = useRouteMatch<{ fileID?: string }>('/runs/:fileID?') diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index ff400be87..0f0a626c9 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -5,6 +5,7 @@ import { useSelector } from 'react-redux' import { List, ListItemButton, ListItemIcon, ListItemText } from '@mui/material' import { ListItemLocation } from './ListItemLocation' import { Icon } from './Icon' +import { ADMIN_ADDONS_ROUTE } from '../constants' export const AdminSidebarNav: React.FC = () => { const history = useHistory() @@ -101,8 +102,8 @@ export const AdminSidebarNav: React.FC = () => { handleNavClick('/admin/add-ons')} + selected={currentPath.includes(ADMIN_ADDONS_ROUTE)} + onClick={() => handleNavClick(ADMIN_ADDONS_ROUTE)} > diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 9b1ad50b4..1d9fd1b4d 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -5,13 +5,7 @@ import { Box, Theme } from '@mui/material' import { State, Dispatch } from '../../store' import { CHAT_PANEL_WIDTH_MIN } from '../../constants' import { radius } from '../../styling' -import { - useChatDocked, - useChatWidth, - useSidebarWidth, - layoutBreakpoints, - chatMaxWidth, -} from '../../hooks/useChatEnabled' +import { useChatDocked, useChatWidth, layoutBreakpoints, chatMaxWidth } from '../../hooks/useChatEnabled' import { getViewportWidth } from '../../hooks/useViewportWidth' import { useChatMainSync } from '../../hooks/useChatSync' import { usePanelDrag } from '../../hooks/usePanelDrag' @@ -41,7 +35,7 @@ export const ChatPanel: React.FC = () => { const turnActive = useSelector(selectTurnActive) const docked = useChatDocked() const chatWidth = useChatWidth() - const sidebarWidth = useSidebarWidth() + const sidebarWidth = layout.sidePanelWidth const dispatch = useDispatch() useChatMainSync() diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 650d7dc8f..af089e26e 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,6 +1,6 @@ -import { REGEX_FIRST_PATH, CHAT_GUIDE_DATE } from '../../constants' +import { REGEX_FIRST_PATH, CHAT_GUIDE_DATE, ADMIN_ADDONS_ROUTE } from '../../constants' import { useMobile } from '../../hooks/useMobile' -import { useChatEnabled, useHideSidebar } from '../../hooks/useChatEnabled' +import { useChatEnabled } from '../../hooks/useChatEnabled' import { GuideBubble } from '../GuideBubble' import React, { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -40,7 +40,7 @@ export const Header: React.FC = ({ panels = 1 }) => { const navigateUp = useNavigationUp(panels) const [showSearch, setShowSearch] = useState(false) - const sidebarHidden = useHideSidebar() + const sidebarHidden = layout.hideSidebar const mobile = useMobile() const inputRef = useRef(null) const dispatch = useDispatch() @@ -61,7 +61,7 @@ export const Header: React.FC = ({ panels = 1 }) => { ] // The add-ons page keys its product into the URL (/admin/add-ons/:productId): that is its root // list, not a detail with a level above it. - const isAdminRootPage = adminRootPages.includes(location.pathname) || location.pathname.startsWith('/admin/add-ons') + const isAdminRootPage = adminRootPages.includes(location.pathname) || location.pathname.startsWith(ADMIN_ADDONS_ROUTE) const isRootMenu = menu === location.pathname || isAdminRootPage return ( diff --git a/frontend/src/components/LimitSetting.tsx b/frontend/src/components/LimitSetting.tsx index e9a728038..ccd7dceb6 100644 --- a/frontend/src/components/LimitSetting.tsx +++ b/frontend/src/components/LimitSetting.tsx @@ -3,6 +3,7 @@ import { humanizeDays } from '../models/plans' import { LinearProgress, Typography, Box } from '@mui/material' import { useTranslation } from 'react-i18next' import { spacing } from '../styling' +import { CHAT_FEATURE } from '../constants' export const LimitSetting: React.FC<{ limit: ILimit }> = ({ limit }) => { const { t } = useTranslation() @@ -38,7 +39,7 @@ export const LimitSetting: React.FC<{ limit: ILimit }> = ({ limit }) => { ? t('limitSetting.rolesAvailable', 'Custom roles are available') : t('limitSetting.rolesUnavailable', 'Custom roles are unavailable') break - case 'ai-agent': + case CHAT_FEATURE: // An alpha granted per account (graphql-api docs/AI-AGENT-LICENSE.md, decision 1): accounts // that lack it are shown nothing, so there is no "unavailable" line — false renders no row. if (limit.value) { diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 9dc23ee20..84c153986 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -12,6 +12,7 @@ export const MODE = env.MODE || 'development' AI portal defaulted the flag on ahead of the licence (PENDING_FEATURES / CHAT_ALWAYS_ON); now an account without the licence — a developer's included — sees no chat anywhere. */ export const CHAT_FEATURE = 'ai-agent' +export const ADMIN_ADDONS_ROUTE = '/admin/add-ons' // Renderer-owned OIDC (permitteer docs/remoteit-desktop-login.md, D8) — identical on // web and desktop; the backend never touches auth. diff --git a/frontend/src/helpers/utilHelper.ts b/frontend/src/helpers/utilHelper.ts index 460fa8684..a63172bdb 100644 --- a/frontend/src/helpers/utilHelper.ts +++ b/frontend/src/helpers/utilHelper.ts @@ -96,7 +96,6 @@ export function removeObjectAttribute(obj: T, key: K): Omi return rest } - export function createMemoDebugger(componentName) { return (prevProps, nextProps) => { Object.keys(prevProps).forEach(key => { @@ -123,3 +122,8 @@ export function containsNonPrintableChars(text: string): boolean { // Calculate the ratio of non-printable characters in the text return nonPrintableCount / text.length > nonPrintableCharLimit } + +/** The value when it is an https URL, else undefined — the only scheme an external picture, + * an agent override or a CSP-bound fetch may carry. */ +export const httpsOnly = (value: unknown): string | undefined => + typeof value === 'string' && /^https:\/\//i.test(value) ? value : undefined diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index dc52a6c55..69706ec89 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -12,6 +12,7 @@ import { TextField, Typography, } from '@mui/material' +import { ADMIN_ADDONS_ROUTE } from '../../constants' import React, { useEffect, useMemo, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory, useLocation, useParams } from 'react-router-dom' @@ -34,8 +35,6 @@ import { Dispatch, State } from '../../store' and the sidebar's remembered route all land on the same list. ai-agent is the first product; the next one is a product row on the API and shows up in the selector with no change here. */ -export const ADMIN_ADDONS_ROUTE = '/admin/add-ons' - const productLabel = (product?: AdminAddonProduct) => product ? `${product.description || product.name}${product.enabled ? '' : ' (disabled)'}` : 'add-on' diff --git a/frontend/src/pages/ConnectedAppDetailPage.tsx b/frontend/src/pages/ConnectedAppDetailPage.tsx index 8ff32679a..9588790fd 100644 --- a/frontend/src/pages/ConnectedAppDetailPage.tsx +++ b/frontend/src/pages/ConnectedAppDetailPage.tsx @@ -25,6 +25,39 @@ const toggled = (set: Set, key: string) => { } const sameSet = (set: Set, list: string[]) => set.size === list.length && list.every(i => set.has(i)) +/* A permission as a chip: filled while on, dashed when taking it would ADD access the grant + never carried, dimmed when the choice is made elsewhere (every account is already covered). */ +const ToggleChip: React.FC<{ + on: boolean + editable: boolean + dashed?: boolean + muted?: boolean + label: React.ReactNode + title?: string + onClick: () => void +}> = ({ on, editable, dashed, muted, label, title, onClick }) => ( + +) + +const RowLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +) + export const ConnectedAppDetailPage: React.FC = () => { const { t } = useTranslation() const { clientId } = useParams<{ clientId: string }>() @@ -79,12 +112,14 @@ export const ConnectedAppDetailPage: React.FC = () => { const name = agent.app || agent.clientId const reach = agent.revokeReach - const allActions = (agent.groups ?? []).flatMap(g => g.actions) + const groups = agent.groups ?? [] + const scopes = agent.scopes ?? [] + const allActions = groups.flatMap(g => g.actions) const actions = allActions.filter(a => a.enabled) const kept = keepEdit ?? new Set(actions.map(a => a.key)) - const scopesKept = scopeEdit ?? new Set(agent.scopes ?? []) + const scopesKept = scopeEdit ?? new Set(scopes) // The grant's reach (one scope constraint per grant; every scoped group carries the same) - const reachGroup = (agent.groups ?? []).find(gr => gr.reach)?.reach ?? null + const reachGroup = groups.find(gr => gr.reach)?.reach ?? null const reachNow = reachEdit ?? (reachGroup ? { all: reachGroup.all, ids: new Set(reachGroup.accounts.map(a => a.id)) } : null) const reachDirty = @@ -101,7 +136,7 @@ export const ConnectedAppDetailPage: React.FC = () => { keepEdit, actions.map(a => a.key) )) || - (scopeEdit !== null && !sameSet(scopeEdit, agent.scopes ?? [])) || + (scopeEdit !== null && !sameSet(scopeEdit, scopes)) || reachDirty const toggleAction = (key: string) => { if (!agent.active || saving) return @@ -135,6 +170,19 @@ export const ConnectedAppDetailPage: React.FC = () => { // delegate today and asks for a recent sign-in before it lands. setReachEdit({ all: false, ids: toggled(reachNow.ids, id) }) } + // The accounts the reach chips offer, their labels, and which of them lie OUTSIDE what was + // consented — still offerable, but turning one on shares it with this app for the first time. + const reachIds = reachGroup + ? [ + ...new Set([ + ...(reachGroup.options ?? []).map(o => o.id), + ...reachGroup.accounts.map(a => a.id), + ...(!reachGroup.ceilingAll ? reachGroup.ceilingIds : []), + ]), + ] + : [] + const reachLabel = (id: string) => (reachGroup?.options ?? []).find(o => o.id === id)?.label ?? id + const outsideCeiling = (id: string) => !!reachGroup && !reachGroup.ceilingAll && !reachGroup.ceilingIds.includes(id) // What this save would ADD beyond what was consented — an offered permission being taken // up, or an account this grant never reached. Everything else on this page removes access; // these are the only choices that create it, so they are named before they are made. @@ -143,12 +191,14 @@ export const ConnectedAppDetailPage: React.FC = () => { ...(reachGroup && reachNow?.all && !reachGroup.ceilingAll ? [t('connectedAppDetailPage.allAccountsPlain', 'every account, including ones you join later')] : []), - ...(reachGroup && reachNow && !reachNow.all - ? [...reachNow.ids] - .filter(id => !reachGroup.ceilingAll && !reachGroup.ceilingIds.includes(id)) - .map(id => (reachGroup.options ?? []).find(o => o.id === id)?.label ?? id) - : []), + ...(reachNow && !reachNow.all ? [...reachNow.ids].filter(outsideCeiling).map(reachLabel) : []), ] + const discardEdits = () => { + setKeepEdit(null) + setScopeEdit(null) + setReachEdit(null) + setError(null) + } const save = async () => { setSaving(true) @@ -175,11 +225,8 @@ export const ConnectedAppDetailPage: React.FC = () => { ) return } - setError(null) await dispatch.agents.fetch() - setKeepEdit(null) - setScopeEdit(null) - setReachEdit(null) + discardEdits() setSaving(false) } @@ -224,7 +271,7 @@ export const ConnectedAppDetailPage: React.FC = () => { 'This access was revoked — shown for the record. {{name}} can request access again by signing in.', })} - {(agent.groups ?? []).map((group, i) => { + {groups.map((group, i) => { if (!group.actions.length) return null const where = group.resourceLabel && group.resourceLabel !== '(all resources)' ? ` — ${group.resourceLabel}` : '' @@ -243,12 +290,11 @@ export const ConnectedAppDetailPage: React.FC = () => { const offered = !!action.offered const base = !sharedLimit && action.limit ? `${action.label} (${action.limit})` : action.label return ( - toggleAction(action.key)} label={ offered @@ -266,7 +312,6 @@ export const ConnectedAppDetailPage: React.FC = () => { ) : action.description || undefined } - sx={{ mr: 1, mb: 0.5, opacity: on ? 1 : 0.6, ...(offered ? { borderStyle: 'dashed' } : {}) }} /> ) }) @@ -292,33 +337,20 @@ export const ConnectedAppDetailPage: React.FC = () => { key={piece ?? 'general'} sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5, marginBottom: 0.5 }} > - - {piece ?? 'General'} - + {piece ?? 'General'} {chips(group.actions.filter(a => (a.piece ?? null) === piece))} )) : chips(group.actions)} {group.reach && reachNow ? ( - - {t('connectedAppDetailPage.accounts', 'Accounts')} - + {t('connectedAppDetailPage.accounts', 'Accounts')} {group.reach.ceilingAll || group.reach.offerAll ? ( - { 'All accounts, including ones added later — add' ) } - sx={{ - mr: 1, - mb: 0.5, - opacity: reachNow.all ? 1 : 0.6, - ...(group.reach.ceilingAll ? {} : { borderStyle: 'dashed' }), - }} /> ) : null} - {[ - ...new Set([ - ...(group.reach.options ?? []).map(o => o.id), - ...group.reach.accounts.map(a => a.id), - ...(!group.reach.ceilingAll ? group.reach.ceilingIds : []), - ]), - ].map(id => { - const label = (group.reach!.options ?? []).find(o => o.id === id)?.label ?? id - const on = reachNow.all || reachNow.ids.has(id) - const editable = agent.active && !reachNow.all - // Outside what was consented: still offerable, but say so — turning - // it on shares that account with this app for the first time. - const adding = !group.reach!.ceilingAll && !group.reach!.ceilingIds.includes(id) + {reachIds.map(id => { + const label = reachLabel(id) + const outside = outsideCeiling(id) return ( - toggleReachId(id)} label={ - adding + outside ? t('connectedAppDetailPage.addAccount', { label, defaultValue: '{{label}} — add' }) : label } - sx={{ - mr: 1, - mb: 0.5, - opacity: on ? (reachNow.all ? 0.7 : 1) : 0.6, - ...(adding ? { borderStyle: 'dashed' } : {}), - }} /> ) })} @@ -382,24 +392,15 @@ export const ConnectedAppDetailPage: React.FC = () => { ) })} - {(agent.scopes ?? []).length ? ( + {scopes.length ? ( <> {t('connectedAppDetailPage.signInScopes', 'Sign-in scopes')} - {(agent.scopes ?? []).map(sc => { + {scopes.map(sc => { const on = scopesKept.has(sc) return ( - toggleScope(sc)} - label={sc} - sx={{ mr: 1, mb: 0.5, opacity: on ? 1 : 0.6 }} - /> + toggleScope(sc)} label={sc} /> ) })} @@ -440,17 +441,7 @@ export const ConnectedAppDetailPage: React.FC = () => { ), }} /> - diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 0f0591204..51b83e4ac 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -69,6 +69,7 @@ import { PartnerStatsPage } from '../pages/PartnerStatsPage/PartnerStatsPage' import browser, { getOs } from '../services/browser' import analytics from '../services/analytics' import { AdminRouteGuard } from './AdminRouteGuard' +import { ADMIN_ADDONS_ROUTE } from '../constants' export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { const history = useHistory() @@ -440,7 +441,7 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { - + diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 34b41142e..42e3bd511 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -11,12 +11,13 @@ * No agent-specific credentials exist anywhere anymore. */ import { store } from '../store' +import { httpsOnly } from '../helpers/utilHelper' import { oidcAuthHeaders } from './oidc' import { OAUTH_AGENT_RESOURCE, AGENT_URL } from '../constants' /* The override must be https — the app's CSP blocks plain http. Shared with the Test Settings validation so what saves is exactly what engages. */ -export const isSecureAgentURL = (url: string): boolean => /^https:\/\//i.test(url) +export const isSecureAgentURL = (url: string): boolean => !!httpsOnly(url) /* Base URL for the agent service, resolved per request. A Test UI override wins (Test Settings → Agent service URL, https only); otherwise the build's AGENT_URL. */ diff --git a/frontend/src/services/get.ts b/frontend/src/services/get.ts index ae97b426b..134fb51a6 100644 --- a/frontend/src/services/get.ts +++ b/frontend/src/services/get.ts @@ -1,6 +1,6 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { apiAuthHeaders } from './remoteit' +import { getApiURL } from '../helpers/apiHelper' +import { apiHeaders } from './remoteit' import { apiError } from './post' import { store } from '../store' @@ -8,14 +8,12 @@ export async function get(path: string = '') { if (store.getState().ui.offline) return const url = getApiURL() + path - const auth = await apiAuthHeaders('GET', url) - if (!auth.authorization) { + const headers = await apiHeaders('GET', url) + if (!headers) { console.warn('Unable to get token for API request.') return } - const headers: any = { ...auth, ...getTestHeader() } - const request = { url, method: 'get', diff --git a/frontend/src/services/jobLogs.ts b/frontend/src/services/jobLogs.ts index 89786c0b2..7854a52c8 100644 --- a/frontend/src/services/jobLogs.ts +++ b/frontend/src/services/jobLogs.ts @@ -1,6 +1,6 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { apiAuthHeaders } from './remoteit' +import { getApiURL } from '../helpers/apiHelper' +import { apiHeaders } from './remoteit' export type DeviceLogEntry = { jobDeviceId: string @@ -36,12 +36,8 @@ export type GetJobLogsResult = */ export async function getJobLogs(jobId: string): Promise { const url = `${getApiURL()}/job/log/all/${jobId}` - const auth = await apiAuthHeaders('GET', url) - if (!auth.authorization) { - return { kind: 'error', status: 401, message: 'Not signed in' } - } - - const headers: any = { ...auth, ...getTestHeader() } + const headers = await apiHeaders('GET', url) + if (!headers) return { kind: 'error', status: 401, message: 'Not signed in' } try { const response = await axios.get(url, { headers }) return { kind: 'ok', data: response?.data as JobLogsResponse } diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 91130be6c..899a7a6eb 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -13,6 +13,7 @@ import { PROTOCOL, } from '../constants' import { toBase64url, decodeBase64url } from '../helpers/base64url' +import { httpsOnly } from '../helpers/utilHelper' /** * The renderer-owned OIDC client (permitteer docs/remoteit-desktop-login.md, D8): @@ -823,7 +824,7 @@ function fileAccount(tokens: Stored) { email: claims?.email, name: claims?.name, // Belt on the AS's own https-only guard — this string lands in an . - picture: typeof claims?.picture === 'string' && /^https:\/\//i.test(claims.picture) ? claims.picture : undefined, + picture: httpsOnly(claims?.picture), declaration: reg[sub]?.declaration, } writeRegistry(reg) @@ -1111,7 +1112,7 @@ export async function oidcRefreshBrowserAccounts(): Promise { ...(prev ?? {}), email: a.email ?? prev?.email, name: a.name ?? prev?.name, - picture: typeof a.picture === 'string' && /^https:\/\//i.test(a.picture) ? a.picture : prev?.picture, + picture: httpsOnly(a.picture) ?? prev?.picture, } } // The AS has just told us who is signed in on this browser, so that answer WINS: a member it diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index cd4690e66..42195b5c5 100644 --- a/frontend/src/services/post.ts +++ b/frontend/src/services/post.ts @@ -1,6 +1,6 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { apiAuthHeaders } from './remoteit' +import { getApiURL } from '../helpers/apiHelper' +import { apiHeaders } from './remoteit' import { store } from '../store' import network from './Network' import sleep from '../helpers/sleep' @@ -15,14 +15,12 @@ export async function post(data: ILookup = {}, path: string = '') { if (store.getState().ui.offline) return const url = getApiURL() + path - const auth = await apiAuthHeaders('POST', url) - if (!auth.authorization) { + const headers = await apiHeaders('POST', url) + if (!headers) { console.warn('Unable to get token for API request.', data) return } - const headers: any = { ...auth, ...getTestHeader() } - const request = { url, method: 'post' as 'post', diff --git a/frontend/src/services/remoteit.ts b/frontend/src/services/remoteit.ts index 426211bc6..bf74759e0 100644 --- a/frontend/src/services/remoteit.ts +++ b/frontend/src/services/remoteit.ts @@ -1,9 +1,9 @@ import { oidcAccessToken, oidcAuthHeaders } from './oidc' -import { getApiResource } from '../helpers/apiHelper' +import { getApiResource, getTestHeader } from '../helpers/apiHelper' /** - * The single token choke point every authenticated graphql call flows through. The token's - * audience FOLLOWS the switcher (D10, permitteer docs/remoteit-desktop-login.md Phase 4c): + * The in-band bearer for liveness probes and the events subscribe envelope (apiAuthHeaders is + * what a graphql/REST call carries). The token's audience FOLLOWS the switcher (D10, permitteer docs/remoteit-desktop-login.md Phase 4c): * pointing the app at another stage mints for that stage instead of replaying a * wrong-audience token into ambient 403s. Resolves to 'Bearer …' or '' (callers no-op on * empty). @@ -13,10 +13,6 @@ export async function getToken(): Promise { return token ? 'Bearer ' + token : '' } -export async function hasCredentials() { - return !!(await oidcAccessToken()) -} - /** Scheme-aware auth headers for a graphql/REST call (permitteer docs — the container now * ENFORCES the DPoP binding: a bound token must arrive as `DPoP ` with a proof over * this exact method+URL, and presenting it as Bearer is refused). Same machinery the account @@ -28,3 +24,11 @@ export async function hasCredentials() { export async function apiAuthHeaders(method: string, url: string): Promise> { return await oidcAuthHeaders(method, url, getApiResource()) } + +/** The headers a REST call carries — the scheme-aware auth plus the Test Settings header — or + * undefined when there is no token to carry, so the caller can no-op. */ +export async function apiHeaders(method: string, url: string): Promise | undefined> { + const auth = await apiAuthHeaders(method, url) + if (!auth.authorization) return undefined + return { ...auth, ...getTestHeader() } +} diff --git a/types.d.ts b/types.d.ts index 2b161fd4b..47141a181 100644 --- a/types.d.ts +++ b/types.d.ts @@ -676,8 +676,8 @@ declare global { jobId?: string tag?: ITagFilter file?: File - argumentDefinitions?: IArgumentDefinition[] // For script creation/edit - argumentValues?: IArgumentValue[] // For running scripts + argumentDefinitions?: IArgumentDefinition[] // For script creation/edit + argumentValues?: IArgumentValue[] // For running scripts } type IJob = { @@ -840,8 +840,9 @@ declare global { givenAt?: string updatedAt?: string lastUsedAt?: string | null - scopes: string[] - groups: { + // The AS shape: every list below may be absent on an older deployment + scopes?: string[] + groups?: { typeLabel?: string resourceLabel?: string api?: string | null @@ -852,9 +853,9 @@ declare global { all: boolean accounts: { id: string; filter: string | null }[] ceilingAll: boolean - // "All accounts" is still on offer (consent would have offered it), even if this grant - // never took it — so the editor can present it as an addition. - offerAll?: boolean + // "All accounts" is still on offer (consent would have offered it), even if this grant + // never took it — so the editor can present it as an addition. + offerAll?: boolean ceilingIds: string[] options: { id: string; label: string }[] | null } | null @@ -863,7 +864,7 @@ declare global { // The SCOPE lane, read-only: APIs the client may bind whose granted scopes they declare // (a first-party app's device access lives here, never in the detail groups). scopeGroups?: { api: string; actions: { key: string; label: string; description: string | null }[] }[] - links: { name: string; url: string }[] + links?: { name: string; url: string }[] revokeReach: { immediate: string[]; delayed: string[]; delayMinutes: number } } From 5497f5eb07c50bda44e9a08e7010c5fff6da1ea8 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 17:46:12 -0700 Subject: [PATCH 232/238] refactor(mfa): the credential-change steps are four shared components --- .../ChangePassword/ChangePassword.tsx | 49 ++--- frontend/src/components/MFA/MFASettings.tsx | 184 ++++++------------ .../src/components/MFA/PasskeysSettings.tsx | 147 +++++--------- frontend/src/components/MFA/steps.tsx | 162 +++++++++++++++ 4 files changed, 285 insertions(+), 257 deletions(-) create mode 100644 frontend/src/components/MFA/steps.tsx diff --git a/frontend/src/components/ChangePassword/ChangePassword.tsx b/frontend/src/components/ChangePassword/ChangePassword.tsx index 070aba832..e9e50b866 100644 --- a/frontend/src/components/ChangePassword/ChangePassword.tsx +++ b/frontend/src/components/ChangePassword/ChangePassword.tsx @@ -7,6 +7,7 @@ import { useDispatch, useSelector } from 'react-redux' import { ConfirmButton } from '../../buttons/ConfirmButton' import { Dispatch, State } from '../../store' import { Gutters } from '../Gutters' +import { CodeStep } from '../MFA/steps' export const ChangePassword = () => { const { t } = useTranslation() @@ -52,37 +53,23 @@ export const ChangePassword = () => { {t('changePassword.title', 'Change Password')} - - - {passwordChallenge.hint - ? t('mfa.relayHint', 'Enter the code sent to {{hint}}.', { hint: passwordChallenge.hint }) - : t( - 'changePassword.mfaPrompt', - 'Enter the 6-digit code from your authenticator to finish changing your password.' - )} - - setCode(e.target.value.trim())} - /> - - - - - + + {passwordChallenge.hint + ? t('mfa.relayHint', 'Enter the code sent to {{hint}}.', { hint: passwordChallenge.hint }) + : t( + 'changePassword.mfaPrompt', + 'Enter the 6-digit code from your authenticator to finish changing your password.' + )} + + } + code={code} + onCode={setCode} + busy={saving} + onSubmit={verifyCode} + onCancel={() => auth.set({ passwordChallenge: undefined })} + /> ) diff --git a/frontend/src/components/MFA/MFASettings.tsx b/frontend/src/components/MFA/MFASettings.tsx index 50e0a89c2..3da49d9a3 100644 --- a/frontend/src/components/MFA/MFASettings.tsx +++ b/frontend/src/components/MFA/MFASettings.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react' import { QRCodeSVG } from 'qrcode.react' import { useTranslation } from 'react-i18next' -import { Box, Button, Chip, Radio, RadioGroup, FormControlLabel, TextField, Typography } from '@mui/material' +import { Box, Button, Chip, TextField, Typography } from '@mui/material' import { Gutters } from '../Gutters' -import { CopyCodeBlock } from '../CopyCodeBlock' +import { PasswordStep, CodeStep, ChoiceStep, RecoveryCodes } from './steps' import { selfMfaStanding, selfMfaEnroll, @@ -223,13 +223,15 @@ export const MFASettings: React.FC = () => { return ( <> {title} - - - {t( - 'mfa.confirmPassword', - 'Confirm your password to continue — changing a credential re-proves the one you hold.' - )} - + submitPassword(step.mode, step.method)} + onCancel={() => refresh()} + > {needsPhone && ( { onChange={e => setPhone(e.target.value.trim())} /> )} - setPassword(e.target.value)} - /> - {step.error && ( - - {step.error} - - )} - - - - - + ) } @@ -276,29 +251,14 @@ export const MFASettings: React.FC = () => { return ( <> {title} - - - {t('mfa.choose', 'How would you like to get your code?')} - - setChoice(e.target.value as MfaMethod)}> - {step.options.map(o => ( - } - label={t(`mfa.method.${o}`, METHOD_LABEL[o] ?? o)} - /> - ))} - - - - - - + refresh()} + /> ) @@ -306,81 +266,55 @@ export const MFASettings: React.FC = () => { return ( <> {title} - - {step.at === 'scan' ? ( - step.delivery === 'sms' ? ( - - {t('mfa.smsSent', 'We texted a code to your phone — enter it to finish turning on text-message codes.')} - - ) : ( - <> + - {t('mfa.scan', 'Scan with your authenticator app, then enter its 6-digit code.')} + {t( + 'mfa.smsSent', + 'We texted a code to your phone — enter it to finish turning on text-message codes.' + )} - {step.otpauth && ( - - - - )} - {step.secret && ( - - {t('mfa.secret', 'Or enter the key manually:')} {step.secret} + ) : ( + <> + + {t('mfa.scan', 'Scan with your authenticator app, then enter its 6-digit code.')} - )} - + {step.otpauth && ( + + + + )} + {step.secret && ( + + {t('mfa.secret', 'Or enter the key manually:')} {step.secret} + + )} + + ) + ) : ( + + {step.hint + ? t('mfa.relayHint', 'Enter the code sent to {{hint}}.', { hint: step.hint }) + : t('mfa.relay', 'Enter the 6-digit code from your current second factor.')} + ) - ) : ( - - {step.hint - ? t('mfa.relayHint', 'Enter the code sent to {{hint}}.', { hint: step.hint }) - : t('mfa.relay', 'Enter the 6-digit code from your current second factor.')} - - )} - setCode(e.target.value.trim())} - /> - {step.error && ( - - {step.error} - - )} - - - - - + } + code={code} + onCode={setCode} + error={step.error} + busy={busy} + onSubmit={submitCode} + onCancel={() => refresh()} + /> ) return ( <> {title} - - - {t( - 'mfa.codesTitle', - 'Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.' - )} - - - - + refresh()} /> ) } diff --git a/frontend/src/components/MFA/PasskeysSettings.tsx b/frontend/src/components/MFA/PasskeysSettings.tsx index 167cfcd82..ddc608a10 100644 --- a/frontend/src/components/MFA/PasskeysSettings.tsx +++ b/frontend/src/components/MFA/PasskeysSettings.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Box, Button, Chip, Radio, RadioGroup, FormControlLabel, TextField, Typography } from '@mui/material' +import { Box, Button, Chip, Typography } from '@mui/material' import { Gutters } from '../Gutters' -import { CopyCodeBlock } from '../CopyCodeBlock' +import { PasswordStep, CodeStep, ChoiceStep, RecoveryCodes } from './steps' import { selfMe, selfPasskeyRegister, @@ -10,7 +10,6 @@ import { selfPasskeyDelete, selfChallenge, MfaMethod, - METHOD_LABEL, SelfContinuation, SelfResult, Passkey, @@ -175,41 +174,14 @@ export const PasskeysSettings: React.FC = () => { return ( <> {title} - - - {t( - 'mfa.confirmPassword', - 'Confirm your password to continue — changing a credential re-proves the one you hold.' - )} - - setPassword(e.target.value)} - /> - {step.error && ( - - {step.error} - - )} - - - - - + submitPassword(step.mode, step.keyId)} + onCancel={() => refresh()} + /> ) @@ -217,55 +189,33 @@ export const PasskeysSettings: React.FC = () => { return ( <> {title} - - - {step.isSelect - ? t('mfa.choose', 'How would you like to get your code?') - : step.hint - ? t('mfa.relayHint', 'Enter the code sent to {{hint}}.', { hint: step.hint }) - : t('mfa.relay', 'Enter the 6-digit code from your current second factor.')} - - {step.isSelect ? ( - // The AS names the factors this account may choose from; offer exactly those. - setChoice(e.target.value as MfaMethod)}> - {(step.options ?? []).map(o => ( - } - label={t(`mfa.method.${o}`, METHOD_LABEL[o] ?? o)} - /> - ))} - - ) : ( - setCode(e.target.value.trim())} - /> - )} - {step.error && ( - - {step.error} - - )} - - - - - + {step.isSelect ? ( + refresh()} + /> + ) : ( + + {step.hint + ? t('mfa.relayHint', 'Enter the code sent to {{hint}}.', { hint: step.hint }) + : t('mfa.relay', 'Enter the 6-digit code from your current second factor.')} + + } + code={code} + onCode={setCode} + error={step.error} + busy={busy} + onSubmit={submitCode} + onCancel={() => refresh()} + /> + )} ) @@ -276,21 +226,16 @@ export const PasskeysSettings: React.FC = () => { {t('passkeys.added', 'Passkey added — next sign-in, use it instead of typing a code.')} - {step.codes?.length ? ( - <> - - {t( - 'mfa.codesTitle', - 'Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.' - )} - - - - ) : null} - + {step.codes?.length ? ( + refresh()} /> + ) : ( + + + + )} ) } diff --git a/frontend/src/components/MFA/steps.tsx b/frontend/src/components/MFA/steps.tsx new file mode 100644 index 000000000..fa76bc800 --- /dev/null +++ b/frontend/src/components/MFA/steps.tsx @@ -0,0 +1,162 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Box, Button, Radio, RadioGroup, FormControlLabel, TextField, Typography } from '@mui/material' +import { Gutters } from '../Gutters' +import { CopyCodeBlock } from '../CopyCodeBlock' +import { MfaMethod, METHOD_LABEL } from '../../services/passportSelf' + +/* The steps every credential change walks through — re-proving the password, answering a + relayed code, choosing a factor, keeping the recovery codes — rendered the same way whether + the change is an MFA method, a passkey or the password itself. Each surface keeps its own + step machine and hands these the state. */ + +const Buttons: React.FC<{ primary: string; disabled: boolean; onPrimary: () => void; onCancel: () => void }> = ({ + primary, + disabled, + onPrimary, + onCancel, +}) => { + const { t } = useTranslation() + return ( + + + + + ) +} + +const ErrorLine: React.FC<{ error?: string }> = ({ error }) => + error ? ( + + {error} + + ) : null + +/** Re-prove the password. `children` are fields the change needs first (a phone number). */ +export const PasswordStep: React.FC<{ + password: string + onPassword: (value: string) => void + error?: string + busy: boolean + incomplete?: boolean + onSubmit: () => void + onCancel: () => void + children?: React.ReactNode +}> = ({ password, onPassword, error, busy, incomplete, onSubmit, onCancel, children }) => { + const { t } = useTranslation() + return ( + + + {t( + 'mfa.confirmPassword', + 'Confirm your password to continue — changing a credential re-proves the one you hold.' + )} + + {children} + onPassword(e.target.value)} + /> + + + + ) +} + +/** Answer a relayed second-factor code. `prompt` says where the code comes from. */ +export const CodeStep: React.FC<{ + prompt: React.ReactNode + code: string + onCode: (value: string) => void + error?: string + busy: boolean + onSubmit: () => void + onCancel: () => void +}> = ({ prompt, code, onCode, error, busy, onSubmit, onCancel }) => { + const { t } = useTranslation() + return ( + + {prompt} + onCode(e.target.value.trim())} + /> + + + + ) +} + +/** Choose a factor from the ones the AS offers this account. */ +export const ChoiceStep: React.FC<{ + options: MfaMethod[] + choice: MfaMethod + onChoice: (value: MfaMethod) => void + error?: string + busy: boolean + onSubmit: () => void + onCancel: () => void +}> = ({ options, choice, onChoice, error, busy, onSubmit, onCancel }) => { + const { t } = useTranslation() + return ( + + + {t('mfa.choose', 'How would you like to get your code?')} + + onChoice(e.target.value as MfaMethod)}> + {options.map(o => ( + } + label={t(`mfa.method.${o}`, METHOD_LABEL[o] ?? o)} + /> + ))} + + + + + + + ) +} + +/** The recovery codes, shown once. */ +export const RecoveryCodes: React.FC<{ codes: string[]; onDone: () => void }> = ({ codes, onDone }) => { + const { t } = useTranslation() + return ( + + + {t( + 'mfa.codesTitle', + 'Save your recovery codes — each can be used once if you lose your authenticator. They will not be shown again.' + )} + + + + + ) +} From 96d0c981fae7d9fd9f2b41d7954cbb50b44045b3 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 17:50:14 -0700 Subject: [PATCH 233/238] refactor: one resize-measure hook, the platform registry merge in one expression, the events audience rule beside the API's, the native shell named once --- frontend/src/components/Body.tsx | 14 +++----- frontend/src/components/Chat/ChatHeader.tsx | 11 ++----- frontend/src/components/DevicesActionBar.tsx | 5 ++- frontend/src/components/LicensingIcon.tsx | 33 +++++-------------- frontend/src/constants.ts | 5 +++ frontend/src/hooks/useContainerWidth.ts | 21 +++--------- frontend/src/hooks/useResizeMeasure.ts | 23 +++++++++++++ frontend/src/models/auth.ts | 1 + .../AdminAddonLicensesListPage.tsx | 6 +--- .../AdminDevicesWithDetailPage.tsx | 6 ++-- frontend/src/pages/TestPage.tsx | 7 +--- frontend/src/platforms/README.md | 3 +- frontend/src/platforms/index.ts | 32 ++++++++---------- frontend/src/platforms/remoteit/index.tsx | 1 - frontend/src/platforms/text.ts | 13 +++----- frontend/src/platforms/this/index.tsx | 1 - frontend/src/services/browser.ts | 12 +++++++ frontend/src/services/cloudController.ts | 24 ++++---------- frontend/src/services/oidc.ts | 17 +++------- scripts/platforms-generate.mjs | 13 +++++--- 20 files changed, 106 insertions(+), 142 deletions(-) create mode 100644 frontend/src/hooks/useResizeMeasure.ts diff --git a/frontend/src/components/Body.tsx b/frontend/src/components/Body.tsx index 2f0e256e2..1276b0e40 100644 --- a/frontend/src/components/Body.tsx +++ b/frontend/src/components/Body.tsx @@ -2,6 +2,7 @@ import React from 'react' import browser from '../services/browser' import { Box, SxProps, Theme } from '@mui/material' import { spacing, toSxArray, scrollbarStyles, SCROLLBAR_WIDTH } from '../styling' +import { useResizeMeasure } from '../hooks/useResizeMeasure' const FADE_SIZE = 30 @@ -29,16 +30,9 @@ const fadeMask = (direction: 'to bottom' | 'to right', fadeStart: boolean, gutte and that changes the content box, which is what ResizeObserver reports by default. */ const useScrollbarGutter = (ref: React.RefObject): number => { const [gutter, setGutter] = React.useState(0) - React.useLayoutEffect(() => { - const element = ref.current - if (!element) return - const measure = () => - setGutter(Math.max(element.offsetWidth - element.clientWidth, element.offsetHeight - element.clientHeight)) - measure() - const observer = new ResizeObserver(measure) - observer.observe(element) - return () => observer.disconnect() - }, []) + useResizeMeasure(ref, element => + setGutter(Math.max(element.offsetWidth - element.clientWidth, element.offsetHeight - element.clientHeight)) + ) return gutter } diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx index 5e46bc37c..424ed8d99 100644 --- a/frontend/src/components/Chat/ChatHeader.tsx +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -20,6 +20,7 @@ import { Notice } from '../Notice' import { isChatPopout } from '../../services/chatPopout' import { ConversationSummary } from '../../services/agent' import { CHAT_GUIDE_DATE } from '../../constants' +import { useResizeMeasure } from '../../hooks/useResizeMeasure' /* Control row shared by the docked panel and the popout window — the conversation's name sits at the left and doubles as the history picker; window-specific actions @@ -107,15 +108,7 @@ export const HistoryButton: React.FC = () => { /* Fade the trailing edge only while the name is ACTUALLY cut off — an unconditional mask would dissolve the last characters of a name that fits. Observed rather than measured once, so dragging the panel narrower re-evaluates it. */ - React.useEffect(() => { - const element = labelRef.current - if (!element) return - const measure = () => setCropped(element.scrollWidth > element.clientWidth + 1) - measure() - const observer = new ResizeObserver(measure) - observer.observe(element) - return () => observer.disconnect() - }, [label]) + useResizeMeasure(labelRef, element => setCropped(element.scrollWidth > element.clientWidth + 1), [label]) const fade = 'linear-gradient(90deg, #000 calc(100% - 20px), transparent)' diff --git a/frontend/src/components/DevicesActionBar.tsx b/frontend/src/components/DevicesActionBar.tsx index f527df58a..b3a5a9fad 100644 --- a/frontend/src/components/DevicesActionBar.tsx +++ b/frontend/src/components/DevicesActionBar.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { selectLimitsLookup, selectPermissions } from '../selectors/organizations' import { selectActiveAccountId } from '../selectors/accounts' import { getSelectedTags } from '../helpers/selectedHelper' -import { useContainerWidth } from '../hooks/useContainerWidth' +import { useContainerNarrowerThan } from '../hooks/useContainerWidth' import { canEditTags } from '../models/tags' import { IconButton } from '../buttons/IconButton' import { useHistory } from 'react-router-dom' @@ -29,8 +29,7 @@ export const DevicesActionBar: React.FC = ({ devices }) => { const removing = useSelector((state: State) => state.tags.removing) const permissions = useSelector(selectPermissions) const canEdit = useSelector((state: State) => canEditTags(state, accountId)) - const { containerRef, containerWidth } = useContainerWidth() - const mobile = containerWidth < MOBILE_WIDTH + const { containerRef, narrow: mobile } = useContainerNarrowerThan(MOBILE_WIDTH) const dispatch = useDispatch() const history = useHistory() const { t } = useTranslation() diff --git a/frontend/src/components/LicensingIcon.tsx b/frontend/src/components/LicensingIcon.tsx index 1d048d0e5..d49f67f47 100644 --- a/frontend/src/components/LicensingIcon.tsx +++ b/frontend/src/components/LicensingIcon.tsx @@ -1,27 +1,12 @@ import React from 'react' -import { REMOTEIT_PRODUCT_ID, AWS_PRODUCT_ID, AI_AGENT_PRODUCT_ID } from '../models/plans' +import { AI_AGENT_PRODUCT_ID } from '../models/plans' import { Icon } from './Icon' -export const LicensingIcon: React.FC<{ license: ILicense }> = ({ license }) => { - let type: IconType = 'brands' - let name: string = '' - - // The add-on's card gets the feature's own mark rather than the remote.it brand mark. Keyed on - // the product, unlike the switch below, which compares a licence id to product ids and so only - // ever lands on its default. - if (license.plan.product.id === AI_AGENT_PRODUCT_ID) return - - switch (license.id) { - case AWS_PRODUCT_ID: - name = 'aws' - break - case REMOTEIT_PRODUCT_ID: - default: - name = 'r3' - break - } - - if (!name) return null - - return -} +// The add-on's card gets the feature's own mark; everything else the brand mark. (The switch this +// replaced compared a licence id to product ids, so it only ever landed on its default.) +export const LicensingIcon: React.FC<{ license: ILicense }> = ({ license }) => + license.plan.product.id === AI_AGENT_PRODUCT_ID ? ( + + ) : ( + + ) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 84c153986..9dffc390f 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -41,6 +41,11 @@ export const cloudTreeUrls = (tree: string) => ({ * On the unified front one identifier covers graphql, the REST surface and the socket as PATHS * inside it; asking the AS for the leaf answers invalid_target, correctly, so the leaf comes off. */ export const resourceForApiURL = (url: string): string => url.match(CLOUD_GRAPHQL_RE)?.[1] ?? url +/** The audience the events socket is minted for where it is its OWN resource — the legacy per-stage + * hosts, whose ws URL is the identifier. Undefined where the socket rides the API's token: the + * unified front (the socket is a path inside the /api resource — minting for the socket URL there + * answers invalid_target) and the legacy shared-domain URL (not a registered resource at all). */ +export const resourceForEventsURL = (url: string): string | undefined => (LEGACY_EVENTS_RE.test(url) ? url : undefined) export const OAUTH_PASSPORT_RESOURCE = env.VITE_OAUTH_PASSPORT_RESOURCE || 'https://passport.dev.remote.it/account/api' // The AI agent lane (permitteer docs/remoteit-ai-agent.md D1/D5): chat requests carry // tokens ADDRESSED to the agent service, and the sign-in declares the stage's MCP detail diff --git a/frontend/src/hooks/useContainerWidth.ts b/frontend/src/hooks/useContainerWidth.ts index ea63630ec..4b7cc31a3 100644 --- a/frontend/src/hooks/useContainerWidth.ts +++ b/frontend/src/hooks/useContainerWidth.ts @@ -1,4 +1,5 @@ -import { useRef, useState, useEffect } from 'react' +import { useRef, useState } from 'react' +import { useResizeMeasure } from './useResizeMeasure' /** * Hook to track the width of a container element using ResizeObserver @@ -8,7 +9,7 @@ import { useRef, useState, useEffect } from 'react' export function useContainerWidth() { const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(1000) - useContainerObserver(containerRef, setContainerWidth) + useResizeMeasure(containerRef, element => setContainerWidth(element.offsetWidth)) return { containerRef, containerWidth } } @@ -20,20 +21,6 @@ export function useContainerWidth() { export function useContainerNarrowerThan(threshold: number) { const containerRef = useRef(null) const [narrow, setNarrow] = useState(false) - useContainerObserver(containerRef, width => setNarrow(width < threshold)) + useResizeMeasure(containerRef, element => setNarrow(element.offsetWidth < threshold)) return { containerRef, narrow } } - -function useContainerObserver(containerRef: React.RefObject, onWidth: (width: number) => void) { - const latest = useRef(onWidth) - latest.current = onWidth - useEffect(() => { - const updateWidth = () => { - if (containerRef.current) latest.current(containerRef.current.offsetWidth) - } - updateWidth() - const resizeObserver = new ResizeObserver(updateWidth) - if (containerRef.current) resizeObserver.observe(containerRef.current) - return () => resizeObserver.disconnect() - }, []) -} diff --git a/frontend/src/hooks/useResizeMeasure.ts b/frontend/src/hooks/useResizeMeasure.ts new file mode 100644 index 000000000..113a37233 --- /dev/null +++ b/frontend/src/hooks/useResizeMeasure.ts @@ -0,0 +1,23 @@ +import { DependencyList, RefObject, useLayoutEffect, useRef } from 'react' + +/* Run `measure` against the element now and on every resize of it. Layout-phase, so state + derived from a size never paints a frame late; the latest `measure` is always the one that + runs, so callers need not memoise it. */ +export function useResizeMeasure( + ref: RefObject, + measure: (element: E) => void, + deps: DependencyList = [] +) { + const latest = useRef(measure) + latest.current = measure + useLayoutEffect(() => { + const element = ref.current + if (!element) return + const run = () => latest.current(element) + run() + const observer = new ResizeObserver(run) + observer.observe(element) + return () => observer.disconnect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps) +} diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index ff9d7297f..f21923f8e 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -428,6 +428,7 @@ export default createModel()({ session. */ await dispatch.auth.set({ user: undefined, ...signInCleared }) dispatch.chat.reset() + dispatch.agents.reset() dispatch.user.reset() dispatch.organization.reset() dispatch.networks.reset() diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index 69706ec89..263121fd3 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -144,7 +144,6 @@ export const AdminAddonLicensesListPage: React.FC = () => { const [granting, setGranting] = useState(false) const [removeTarget, setRemoveTarget] = useState(null) const [removing, setRemoving] = useState(false) - const [searchInput, setSearchInput] = useState('') const products = useSelector((state: State) => state.adminAddonLicenses.products) const productsStatus = useSelector((state: State) => state.adminAddonLicenses.productsStatus) @@ -155,6 +154,7 @@ export const AdminAddonLicensesListPage: React.FC = () => { const total = useSelector((state: State) => state.adminAddonLicenses.total) const hasMore = useSelector((state: State) => state.adminAddonLicenses.hasMore) const searchValue = useSelector((state: State) => state.adminAddonLicenses.searchValue) + const [searchInput, setSearchInput] = useState(searchValue) const product = products.find(p => p.id === productId) const label = productLabel(product) @@ -197,10 +197,6 @@ export const AdminAddonLicensesListPage: React.FC = () => { ) const [required, attributes] = removeObject(listAttributes, a => a.required === true) - useEffect(() => { - setSearchInput(searchValue) - }, []) - // The URL is the selection, and refresh is the one way in: on mount and on every move of the // product it re-reads the catalogue, checks the product against it, and fetches the list afresh // — so a stale link never fires a list request that can only be refused, and a remount never diff --git a/frontend/src/pages/AdminDevicesPage/AdminDevicesWithDetailPage.tsx b/frontend/src/pages/AdminDevicesPage/AdminDevicesWithDetailPage.tsx index 956b81cf4..60debf8fd 100644 --- a/frontend/src/pages/AdminDevicesPage/AdminDevicesWithDetailPage.tsx +++ b/frontend/src/pages/AdminDevicesPage/AdminDevicesWithDetailPage.tsx @@ -2,7 +2,7 @@ import { Box } from '@mui/material' import React, { useEffect, useRef } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory, useLocation, useParams } from 'react-router-dom' -import { useContainerWidth } from '../../hooks/useContainerWidth' +import { useContainerNarrowerThan } from '../../hooks/useContainerWidth' import { useResizablePanel } from '../../hooks/useResizablePanel' import { Dispatch, State } from '../../store' import { AdminDeviceDetailPage } from './AdminDeviceDetailPage' @@ -51,11 +51,11 @@ export const AdminDevicesWithDetailPage: React.FC = () => { const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) const hasRestoredRef = useRef(false) - const { containerRef, containerWidth } = useContainerWidth() + const { containerRef, narrow } = useContainerNarrowerThan(MIN_WIDTH * 2) const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { minWidth: MIN_WIDTH }) // Below two panels' worth of width the detail takes over the whole area. - const twoPanel = !layout.singlePanel && containerWidth >= MIN_WIDTH * 2 + const twoPanel = !layout.singlePanel && !narrow // Restore the previously selected device ONLY on initial mount useEffect(() => { diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 3a5536bda..2d797551b 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -103,12 +103,7 @@ export const TestPage: React.FC = () => { const cloud = target.identifier.match(CLOUD_TREE_RE) if (cloud) { const key = `cloud:${cloud[1] || 'prod'}` - pairs.set(key, { - ...at(key, target.name), - name: target.name, - ...cloudTreeUrls(target.identifier), - resources: [target.identifier], - }) + pairs.set(key, { key, name: target.name, ...cloudTreeUrls(target.identifier), resources: [target.identifier] }) continue } const gql = target.identifier.match(LEGACY_GRAPHQL_RE) diff --git a/frontend/src/platforms/README.md b/frontend/src/platforms/README.md index dd044d4ea..d69a17bd2 100644 --- a/frontend/src/platforms/README.md +++ b/frontend/src/platforms/README.md @@ -32,8 +32,7 @@ A `platforms//index.tsx` registers its **id and its code** — component, ov from the catalogue. Any **defined** field a local file sets wins over the catalogue, so a hot-fix in a local file -takes effect; an undefined one (a capability flag that is off on this OS) falls through to the -catalogue value. A route the catalogue has no row for — the hidden `android-screenview` deep link +takes effect; an undefined one falls through to the catalogue value. A route the catalogue has no row for — the hidden `android-screenview` deep link — supplies all of its own data. ## Catalogue data vs client capability diff --git a/frontend/src/platforms/index.ts b/frontend/src/platforms/index.ts index 731b1751e..c93ba907d 100644 --- a/frontend/src/platforms/index.ts +++ b/frontend/src/platforms/index.ts @@ -39,8 +39,8 @@ function defined(value?: T): Partial { class Platforms { platforms: ILookup = {} - lookup: INumberLookup = {} - nameLookup: INumberLookup = {} + private lookup: INumberLookup = {} + private nameLookup: INumberLookup = {} installed: string[] = [ 'advantech', 'alpine', @@ -105,13 +105,12 @@ class Platforms { // '[CODE]' and a template are substituted client-side; `true` shows the API's command. command: data.kind === 'command' ? data.commandTemplate ?? true : data.kind === 'code' ? '[CODE]' : undefined, // A code row WITH a link is a download too: install the app, the code is the fallback. - download: data.kind === 'download' || (data.kind === 'code' && !!data.link) || undefined, + download: data.kind === 'download' || (data.kind === 'code' && !!data.link), description: data.description, instructions: data.instructions, link: data.link, } - const hasInstallation = Object.values(installation).some(value => value !== undefined) - return { name: data.name, types, services: data.services, installation: hasInstallation ? installation : undefined } + return { name: data.name, types, services: data.services, installation } } register(local: IPlatformLocal) { @@ -122,15 +121,14 @@ class Platforms { `platforms: "${local.id}" has no catalogue row and supplies no types — regenerate the snapshot (npm run platforms:generate)` ) } - const catalogue = data && this.fromCatalogue(data) - const platform: IPlatform = catalogue - ? { - ...base, - ...defined(catalogue), - ...defined(local), - installation: { ...catalogue.installation, ...defined(local.installation) }, - } - : base + const catalogue = data ? this.fromCatalogue(data) : undefined + // The catalogue's row underneath, the module's own fields on top: a local field wins only + // where it is set, so a page can override one thing without restating the rest. + const platform: IPlatform = { + ...base, + ...defined(catalogue), + installation: { ...catalogue?.installation, ...defined(local.installation) }, + } platform.types = platform.types || {} platform.hasScreenView = platform.services?.some(s => s.application === 48) this.platforms[platform.id] = platform @@ -149,14 +147,10 @@ class Platforms { return Object.fromEntries(Object.keys(this.lookup).map(type => [type, this.nameLookup[type]])) } - type(type: number): IPlatform { - return this.get(this.lookup[type]) - } - // A page covers several types, so the type's own label beats the page name: 10 is // "Windows Server", not "Windows". A type with no page still has a label. name(type: number): string { - return this.nameLookup[type] || this.type(type).name + return this.nameLookup[type] || this.get(this.lookup[type]).name } get(id: string = 'unknown'): IPlatform { diff --git a/frontend/src/platforms/remoteit/index.tsx b/frontend/src/platforms/remoteit/index.tsx index f3be0873f..e1c826e5e 100644 --- a/frontend/src/platforms/remoteit/index.tsx +++ b/frontend/src/platforms/remoteit/index.tsx @@ -34,6 +34,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'remoteit', - name: 'Remote.It', component: Component, }) diff --git a/frontend/src/platforms/text.ts b/frontend/src/platforms/text.ts index 296597d9b..80bdffa51 100644 --- a/frontend/src/platforms/text.ts +++ b/frontend/src/platforms/text.ts @@ -19,15 +19,12 @@ export function platformText(t: TFunction, platform: IPlatform): PlatformText { // platform whose module has not loaded yet has no id — both would render as ".name". if (!platform.id) return { name: platform.name ?? '', description, instructions } + // A field is translated only when it carries text — an empty default would render as its key. + const tr = (field: string, value?: string) => (value ? t(key(platform, field), value) : undefined) return { - name: platform.name ? t(key(platform, 'name'), platform.name) : '', - description: description ? t(key(platform, 'description'), description) : undefined, - instructions: - typeof instructions === 'string' - ? instructions - ? t(key(platform, 'instructions'), instructions) - : undefined - : instructions, + name: tr('name', platform.name) ?? '', + description: tr('description', description), + instructions: typeof instructions === 'string' ? tr('instructions', instructions) : instructions, } } diff --git a/frontend/src/platforms/this/index.tsx b/frontend/src/platforms/this/index.tsx index 20f853b5a..6cc0c15b7 100644 --- a/frontend/src/platforms/this/index.tsx +++ b/frontend/src/platforms/this/index.tsx @@ -25,6 +25,5 @@ const Component = ({ darkMode, ...props }) => { platforms.register({ id: 'this', - name: 'This system', component: Component, }) diff --git a/frontend/src/services/browser.ts b/frontend/src/services/browser.ts index 2c0da6b26..09fbaa463 100644 --- a/frontend/src/services/browser.ts +++ b/frontend/src/services/browser.ts @@ -37,6 +37,8 @@ class Environment { isApple: boolean = false hasBackend: boolean = false hasBilling: boolean = false + // A NATIVE shell — Electron or a Capacitor build — with a private-use URL scheme of its own + isNative: boolean = false constructor() { this.isElectron = isElectron() @@ -51,6 +53,7 @@ class Environment { this.isWindows = isWindows() this.isApple = this.isIOS || this.isMac + this.isNative = this.isElectron || this.isMobile this.hasBackend = !this.isPortal && !this.isMobile this.hasBilling = this.isPortal @@ -178,6 +181,15 @@ export async function windowOpen(url?: string, windowName?: string, external?: b } } +/** A top-level departure to another origin — the sign-in journey. On web the page goes; on + * desktop the page goes and the main process bounces it to the system browser; a native mobile + * app opens the system browser itself, since its WebView cannot follow a redirect back to the + * app's private-use scheme (the deep link reloads the WebView — hooks/useCapacitor). */ +export async function leaveTo(url: string) { + if (browser.isMobile) await windowOpen(url) + else window.location.assign(url) +} + export async function windowClose() { if (browser.isMobile) { try { diff --git a/frontend/src/services/cloudController.ts b/frontend/src/services/cloudController.ts index 7a5062b5c..12aa85ebd 100644 --- a/frontend/src/services/cloudController.ts +++ b/frontend/src/services/cloudController.ts @@ -10,7 +10,7 @@ import { getWebSocketURL, getTestHeader } from '../helpers/apiHelper' import { DEVICE_TYPE } from '@common/applications' import { getToken } from './remoteit' import { oidcAccessToken } from './oidc' -import { LEGACY_EVENTS_RE } from '../constants' +import { resourceForEventsURL } from '../constants' import { version } from '../helpers/versionHelper' import { store } from '../store' import { notify } from './Notifications' @@ -31,23 +31,13 @@ const stateTimes = new CloudTimes() const connectTimes = new CloudTimes() // D11a (permitteer docs/remoteit-desktop-login.md Phase 4c): the events stream is SOMETIMES its own -// audience. Where it is, the WS URL IS the resource identifier (wss://ws..remote.it/v1; -// prod's bare wss://ws.remote.it/v1) and we mint for it. Where it is NOT, we present the graphql -// token instead — and there are now two such cases, for different reasons: -// -// * the legacy shared-domain URL (wss://ws.remote.it/), which is not a registered resource -// at all, admitted by the authorizer's dual-accept window until that contract retires; -// * the UNIFIED FRONT (wss://cloud..remote.it/api/ws), where the socket lives INSIDE the -// merged /api resource — so the graphql token is not a stand-in, it is the right audience -// (graphql-permitteer docs/CLOUD-EDGE.md). -// -// Do NOT widen this pattern to match the unified front. Minting for the socket URL there asks the AS -// for a resource that does not exist and fails `invalid_target` — which is exactly how the e2e -// suite discovered the same assumption on its own side. +// audience (resourceForEventsURL says when); otherwise it presents the graphql token — on the +// unified front that is the right audience, not a stand-in, and on the legacy shared-domain URL the +// authorizer's dual-accept window admits it until that contract retires. async function wsAuthorization(): Promise { - const url = getWebSocketURL() || '' - if (!LEGACY_EVENTS_RE.test(url)) return await getToken() - const token = await oidcAccessToken(url) + const resource = resourceForEventsURL(getWebSocketURL() || '') + if (!resource) return await getToken() + const token = await oidcAccessToken(resource) return token ? 'Bearer ' + token : '' } diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 899a7a6eb..2ba2945cb 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -1,4 +1,4 @@ -import browser, { windowOpen } from './browser' +import browser, { leaveTo } from './browser' import { isChatPopout } from './chatPopout' import i18n from '../i18n' import { @@ -338,11 +338,9 @@ async function discover() { return discovery! } -// A NATIVE app — Electron or a Capacitor build — comes back through its private-use scheme, which -// the registry lists for the desktop client; the WebView's own origin (capacitor://localhost) is -// registered nowhere. On web the page's own /authCallback is the registered one. -const redirectUri = () => - browser.isElectron || browser.isMobile ? PROTOCOL + 'authCallback' : window.location.origin + '/authCallback' +// A native shell comes back through its private-use scheme, which the registry lists for the +// desktop client; on web the page's own /authCallback is the registered one. +const redirectUri = () => (browser.isNative ? PROTOCOL + 'authCallback' : window.location.origin + '/authCallback') /** What this build asks for, per audience. ONE source of truth: the authorize request is * built from it AND the boot check measures tokens against it, so a slice added in a deploy @@ -560,12 +558,7 @@ export async function oidcStart( params.prompt = opts.prompt } for (const key in params) url.searchParams.set(key, params[key]) - // On web the page departs; on desktop the main process bounces the issuer origin to the system - // browser and the window stays on the waiting panel. A native mobile app opens the system - // browser itself — the WebView cannot follow the AS's redirect to a private-use scheme — and - // the deep link brings the code back (hooks/useCapacitor reloads the WebView with it). - if (browser.isMobile) await windowOpen(url.toString()) - else window.location.assign(url.toString()) + await leaveTo(url.toString()) return true } diff --git a/scripts/platforms-generate.mjs b/scripts/platforms-generate.mjs index e68b7aebd..ad30b20db 100644 --- a/scripts/platforms-generate.mjs +++ b/scripts/platforms-generate.mjs @@ -112,9 +112,7 @@ function fromCli() { 'Check that the remote.it CLI is installed and signed in, or set R3_API_TOKEN and drop --cli.' ) } - const data = unwrapCli(stdout) - if (!data.platformTypes) throw new Error('exec-gql returned no platformTypes') - return normalise(data.platformTypes, data.platformInstallations || []) + return fromData(unwrapCli(stdout), 'exec-gql') } async function fromApi() { @@ -138,8 +136,13 @@ async function fromApi() { throw new Error(`${API} → HTTP ${res.status} but not JSON (edge/WAF page?): ${text.slice(0, 80)}`) } if (body.errors?.length) throw new Error(body.errors.map(e => e.message).join('; ')) - if (!body.data?.platformTypes) throw new Error(`${API} returned no platformTypes`) - return normalise(body.data.platformTypes, body.data.platformInstallations || []) + return fromData(body.data, API) +} + +// Either lane's answer: the two lists, or a clear complaint about which source came up empty. +function fromData(data, source) { + if (!data?.platformTypes) throw new Error(`${source} returned no platformTypes`) + return normalise(data.platformTypes, data.platformInstallations || []) } // Drop null/undefined recursively; keep [] (it means "none", distinct from unset). From 44bc409ce9e91f5da9cf0dd7e608e87075a281ff Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 18:18:20 -0700 Subject: [PATCH 234/238] =?UTF-8?q?refactor(admin):=20one=20refresh=20both?= =?UTF-8?q?=20selects=20and=20loads=20the=20add-on=20=E2=80=94=20the=20pag?= =?UTF-8?q?e=20aligns=20the=20URL=20instead=20of=20asking=20twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/models/adminAddonLicenses.test.ts | 8 ++--- frontend/src/models/adminAddonLicenses.ts | 5 +++- .../AdminAddonLicensesListPage.tsx | 30 ++++++++++++------- frontend/src/services/oidc.ts | 7 ++--- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/frontend/src/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts index 00913d1bb..8182d903a 100644 --- a/frontend/src/models/adminAddonLicenses.test.ts +++ b/frontend/src/models/adminAddonLicenses.test.ts @@ -158,14 +158,14 @@ describe('adminAddonLicenses effects', () => { expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) }) - it('refresh clears a selection the catalogue no longer lists and asks for no list — the page picks a product that exists', async () => { + it('refresh replaces a selection the catalogue no longer lists with the first add-on, and loads it', async () => { const dispatch = makeDispatch() const effects = withRealEffects(dispatch, { productId: 'gone' }) graphQLAdminAddonProducts.mockResolvedValueOnce(catalogue('A')) + graphQLAdminAddonCustomers.mockResolvedValueOnce(page([], 0, false)) await effects.refresh('gone', stateWith({ productId: 'gone' })) - expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith(undefined) - expect(dispatch.adminAddonLicenses.fetch).not.toHaveBeenCalled() - expect(graphQLAdminAddonCustomers).not.toHaveBeenCalled() + expect(dispatch.adminAddonLicenses.setProductId).toHaveBeenCalledWith('A') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) }) it('refresh touches neither the selection nor the list when the catalogue did not answer', async () => { diff --git a/frontend/src/models/adminAddonLicenses.ts b/frontend/src/models/adminAddonLicenses.ts index 05a7f0162..a737ca576 100644 --- a/frontend/src/models/adminAddonLicenses.ts +++ b/frontend/src/models/adminAddonLicenses.ts @@ -179,9 +179,12 @@ export const adminAddonLicenses = createModel()({ const products = await dispatch.adminAddonLicenses.fetchProducts() if (!products) return + // The selection: the URL's product when the catalogue lists it, else the one held, else the + // first add-on — chosen HERE so one refresh both selects and loads; the page then aligns the + // URL to the choice rather than asking for the catalogue a second time. const held = rootState.adminAddonLicenses.productId const listed = (id?: string) => !!id && products.some(p => p.id === id) - const productId = listed(preferredProductId) ? preferredProductId : listed(held) ? held : undefined + const productId = listed(preferredProductId) ? preferredProductId : listed(held) ? held : products[0]?.id dispatch.adminAddonLicenses.setProductId(productId) if (productId) await dispatch.adminAddonLicenses.fetch() }, diff --git a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx index 263121fd3..7764a3caf 100644 --- a/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -13,7 +13,7 @@ import { Typography, } from '@mui/material' import { ADMIN_ADDONS_ROUTE } from '../../constants' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory, useLocation, useParams } from 'react-router-dom' import { Attribute } from '../../components/Attributes' @@ -200,20 +200,28 @@ export const AdminAddonLicensesListPage: React.FC = () => { // The URL is the selection, and refresh is the one way in: on mount and on every move of the // product it re-reads the catalogue, checks the product against it, and fetches the list afresh // — so a stale link never fires a list request that can only be refused, and a remount never - // shows rows fetched from another API target. + // shows rows fetched from another API target. With no product in the URL the one last looked + // at is preferred (the sidebar's memory); the model falls back to the first add-on. + const saved = defaultSelection['admin']?.[ADMIN_ADDONS_ROUTE] + const remembered = saved?.startsWith(`${ADMIN_ADDONS_ROUTE}/`) + ? saved.slice(ADMIN_ADDONS_ROUTE.length + 1) + : undefined + const aligning = useRef(false) useEffect(() => { - dispatch.adminAddonLicenses.refresh(urlProductId) + // The URL just caught up with the model's own choice (below): the list is already loading. + if (aligning.current) { + aligning.current = false + return + } + dispatch.adminAddonLicenses.refresh(urlProductId ?? remembered) }, [urlProductId]) - // No product in the URL, or one the API no longer lists: go to the product last looked at, else - // the first add-on. Waits for the catalogue so a deep link to a real product is never bounced. + // No product in the URL, or one the API no longer lists: the URL follows the model's choice. useEffect(() => { - if (productsStatus !== 'loaded' || !products.length) return - if (urlProductId && products.some(p => p.id === urlProductId)) return - const saved = defaultSelection['admin']?.[ADMIN_ADDONS_ROUTE] - const remembered = products.find(p => saved === `${ADMIN_ADDONS_ROUTE}/${p.id}`) - history.replace(`${ADMIN_ADDONS_ROUTE}/${(remembered || products[0]).id}`) - }, [urlProductId, productsStatus, products]) + if (productsStatus !== 'loaded' || !productId || urlProductId === productId) return + aligning.current = true + history.replace(`${ADMIN_ADDONS_ROUTE}/${productId}`) + }, [urlProductId, productsStatus, productId]) // Remember the product for the sidebar's Add-ons entry (AdminSidebarNav.handleNavClick) useEffect(() => { diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index 2ba2945cb..ec2579aa0 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -646,11 +646,8 @@ export async function oidcAccessToken(resource: string = OAUTH_GRAPHQL_RESOURCE) // A support session's token IS the session: served until it expires (a reload restores it from // the tab store), never refreshed, and '' — the end — once it is gone. Other audiences have // nothing to mint from; their features fail closed, as writes do under `act`. - const support = stored()?.support - if (support) - return resource === OAUTH_GRAPHQL_RESOURCE && support.exp - Math.floor(Date.now() / 1000) > 0 - ? support.access_token - : '' + const s = stored() + if (s?.support) return resource === OAUTH_GRAPHQL_RESOURCE && supportLive(s) ? s.support.access_token : '' const fresh = () => { const cached = access[resource] return cached && cached.exp - Math.floor(Date.now() / 1000) > 30 ? cached.token : undefined From 0ff248a675d65f25cefba4c3cb333c7a793b526b Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 18:24:45 -0700 Subject: [PATCH 235/238] =?UTF-8?q?perf(chat):=20the=20reply=20in=20flight?= =?UTF-8?q?=20lives=20outside=20the=20persisted=20slice=20=E2=80=94=20the?= =?UTF-8?q?=20token=20stream=20no=20longer=20rewrites=20the=20store=20ever?= =?UTF-8?q?y=20second?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Chat/ChatBody.tsx | 8 +- frontend/src/models/chat.test.ts | 57 +++++++++-- frontend/src/models/chat.ts | 109 +++++++++------------- frontend/src/models/chatLive.ts | 39 ++++++++ frontend/src/models/index.ts | 3 + 5 files changed, 139 insertions(+), 77 deletions(-) create mode 100644 frontend/src/models/chatLive.ts diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx index bb8b6fae5..4d2bb07a1 100644 --- a/frontend/src/components/Chat/ChatBody.tsx +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useSelector, useDispatch } from 'react-redux' import { Button, Typography } from '@mui/material' @@ -30,7 +30,11 @@ const ChatEmpty: React.FC<{ message: React.ReactNode; children?: React.ReactNode export const ChatBody: React.FC = () => { const { t } = useTranslation() - const messages = useSelector((state: State) => state.chat.messages) + const transcript = useSelector((state: State) => state.chat.messages) + const reply = useSelector((state: State) => state.chatLive.reply) + // The transcript plus the reply in flight (models/chatLive): one list for the screen, rebuilt + // only when either changes — during a turn that is the tail, and the tail alone re-renders. + const messages = useMemo(() => (reply ? [...transcript, reply] : transcript), [transcript, reply]) const streaming = useSelector((state: State) => state.chat.streaming) const health = useSelector((state: State) => state.chat.health) const pendingConfirmation = useSelector((state: State) => state.chat.pendingConfirmation) diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index 4e58f0b1f..591850903 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -68,9 +68,10 @@ const makeDispatch = () => ({ newConversation: vi.fn(), // what send() touches around its (mocked, instantly-resolving) streamChat addUserMessage: vi.fn(), - applyEvent: vi.fn(), + endTurn: vi.fn(), loadUsage: vi.fn(), }, + chatLive: { append: vi.fn(), toolStart: vi.fn(), toolResult: vi.fn(), clear: vi.fn() }, }) // The wider snapshot send() reads (resolveChatOrg looks at the user and memberships) const sendable = (chat: Record = {}) => ({ @@ -113,6 +114,7 @@ beforeEach(() => { agentHealth.mockReset() openChatPopout.mockReset() storeState.chat = { conversationId: 'a', streaming: false, messages: [], title: '' } + ;(storeState as any).chatLive = { reply: null } }) // What openConversation writes for a loaded conversation — the shape the out-of-order tests @@ -312,7 +314,7 @@ describe('chat model — stop() denies a pending approval', () => { const dispatch = makeDispatch() await effectsFor(dispatch).stop(undefined, current({ turnId: 'turn-1', pendingConfirmation: pending })) expect(confirmTool).toHaveBeenCalledWith({ turnId: 'turn-1', toolUseId: 'tool-9', approved: false }) - expect(dispatch.chat.set).toHaveBeenCalledWith({ streaming: false, pendingConfirmation: null }) + expect(dispatch.chat.endTurn).toHaveBeenCalled() // turnEnded clears the pending approval }) it('sends nothing when no approval is pending', async () => { @@ -327,21 +329,18 @@ describe('chat model — stop() denies a pending approval', () => { await expect( effectsFor(dispatch).stop(undefined, current({ turnId: 'turn-1', pendingConfirmation: pending })) ).resolves.toBeUndefined() - expect(dispatch.chat.set).toHaveBeenCalledWith({ streaming: false, pendingConfirmation: null }) + expect(dispatch.chat.endTurn).toHaveBeenCalled() }) }) /* A stream the server closed cleanly mid-answer must end the turn as an interruption — not resolve like a completion with a truncated reply on screen and the composer open. */ describe('chat model — send() treats a cut-off stream as an interrupted turn', () => { - it('maps AgentStreamEndedError to an error event (which marks the reply Interrupted)', async () => { + it('ends the turn on AgentStreamEndedError with the cut-off as its error (which marks the reply Interrupted)', async () => { streamChat.mockRejectedValue(new AgentStreamEndedError()) const dispatch = makeDispatch() await effectsFor(dispatch).send('hello', sendable()) - expect(dispatch.chat.applyEvent).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', message: 'notices:chat.streamEnded' }) - ) - expect(dispatch.chat.set).toHaveBeenCalledWith({ streaming: false }) + expect(dispatch.chat.endTurn).toHaveBeenCalledWith('notices:chat.streamEnded') }) }) @@ -449,3 +448,45 @@ describe('chat model — the background grant is revoked once per identity', () reducers.reset({}) }) }) + +/* The reply in flight lives in models/chatLive so the token stream never touches the persisted + chat slice; it joins the transcript exactly once, when the turn ends. */ +describe('chat model — the reply in flight lands once, when the turn ends', () => { + const partial = { role: 'assistant' as const, text: 'so far', toolCalls: [] } + + it('endTurn folds the live reply into the transcript — Interrupted when an error ended the turn', async () => { + ;(storeState as any).chatLive = { reply: partial } + const dispatch = { ...makeDispatch(), chat: { ...makeDispatch().chat, turnEnded: vi.fn() } } + await effectsFor(dispatch).endTurn('cut off') + expect(dispatch.chatLive.clear).toHaveBeenCalled() + expect(dispatch.chat.turnEnded).toHaveBeenCalledWith({ reply: { ...partial, interrupted: true }, error: 'cut off' }) + }) + + it('a completed turn folds the reply as it is; a second endTurn finds nothing in flight', async () => { + ;(storeState as any).chatLive = { reply: partial } + const dispatch = { ...makeDispatch(), chat: { ...makeDispatch().chat, turnEnded: vi.fn() } } + await effectsFor(dispatch).endTurn() + expect(dispatch.chat.turnEnded).toHaveBeenCalledWith({ reply: partial, error: undefined }) + ;(storeState as any).chatLive = { reply: null } + await effectsFor(dispatch).endTurn() + expect(dispatch.chat.turnEnded).toHaveBeenLastCalledWith({ reply: null, error: undefined }) + expect(dispatch.chatLive.clear).toHaveBeenCalledTimes(1) + }) + + it('turnEnded appends the reply and clears the turn state', () => { + const reducers = (chatModel as any).reducers + const before = { + ...reducers.reset({}), + messages: [{ role: 'user', text: 'hi' }], + streaming: true, + pendingConfirmation: { toolUseId: 't', toolName: 'n', input: {} }, + } + const after = reducers.turnEnded(before, { reply: partial, error: undefined }) + expect(after.messages).toEqual([{ role: 'user', text: 'hi' }, partial]) + expect(after.streaming).toBe(false) + expect(after.pendingConfirmation).toBeNull() + const untouched = reducers.turnEnded({ ...before, messages: [] }, { reply: null, error: 'x' }) + expect(untouched.messages).toEqual([]) + expect(untouched.error).toBe('x') + }) +}) diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 6ffbfe8d2..50d217acf 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -14,7 +14,6 @@ import { type Usage, agentHealth, AgentAuthError, - AgentEvent, AgentHealth, OrgSelection, } from '../services/agent' @@ -42,9 +41,8 @@ export type ChatToolCall = { result?: string } -export type ChatTranscriptMessage = - | { role: 'user'; text: string } - | { role: 'assistant'; text: string; toolCalls: ChatToolCall[]; interrupted?: boolean } +export type ChatAssistantMessage = { role: 'assistant'; text: string; toolCalls: ChatToolCall[]; interrupted?: boolean } +export type ChatTranscriptMessage = { role: 'user'; text: string } | ChatAssistantMessage export type IChatState = { open: boolean @@ -84,50 +82,6 @@ export const defaultChatState: IChatState = { health: 'unknown', } -/* Reduce one agent stream event into chat state. Mutates the immer draft. */ -function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { - const last = state.messages[state.messages.length - 1] - let assistant = last?.role === 'assistant' ? last : undefined - const ensureAssistant = () => { - if (!assistant) { - assistant = { role: 'assistant', text: '', toolCalls: [] } - state.messages.push(assistant) - } - return assistant - } - - switch (event.type) { - case 'text_delta': - ensureAssistant().text += event.text - break - case 'tool_call_start': - ensureAssistant().toolCalls.push({ id: event.id, name: event.name, input: event.input, status: 'running' }) - break - case 'tool_call_result': { - const call = assistant?.toolCalls.find(c => c.id === event.id) - if (call) { - call.status = event.isError ? 'error' : 'done' - call.result = event.result - } - break - } - case 'confirmation_required': - state.pendingConfirmation = { toolUseId: event.id, toolName: event.name, input: event.input } - break - case 'done': - state.streaming = false - state.pendingConfirmation = null - break - case 'error': - state.error = event.message - state.streaming = false - state.pendingConfirmation = null - if (assistant) assistant.interrupted = true - break - } - return state -} - /* The org the chat is scoped to (null = personal): the app's active account, read from the same selector the rest of the app uses — no copy of it to keep in step, in either window (the popout boots under the scope it was opened with). Membership decides, so the Current Org label and the @@ -235,7 +189,7 @@ export default createModel()({ if (flushTimer !== null) window.clearTimeout(flushTimer) flushTimer = null if (deltaBuffer) { - dispatch.chat.applyEvent({ type: 'text_delta', text: deltaBuffer }) + dispatch.chatLive.append(deltaBuffer) deltaBuffer = '' } } @@ -254,36 +208,41 @@ export default createModel()({ } else { // Buffered text must land before the next non-text event flushDeltas() + if (event.type === 'tool_call_start') dispatch.chatLive.toolStart(event) + else if (event.type === 'tool_call_result') dispatch.chatLive.toolResult(event) + else if (event.type === 'confirmation_required') + dispatch.chat.set({ + pendingConfirmation: { toolUseId: event.id, toolName: event.name, input: event.input }, + }) + else if (event.type === 'done') dispatch.chat.endTurn() // The backend prefixes auth failures so the client knows a retry is pointless // until the grant is renewed (e.g. it expired mid-turn). - if (event.type === 'error' && event.message.startsWith('reauth_required')) { + else if (event.message.startsWith('reauth_required')) { dispatch.chat.unauthorized() // mid-stream, not an HTTP 401 — agentRequest cannot see it - dispatch.chat.applyEvent({ type: 'error', message: sessionExpiredError() }) - } else dispatch.chat.applyEvent(event) + dispatch.chat.endTurn(sessionExpiredError()) + } else dispatch.chat.endTurn(event.message) } }, }) } catch (error) { flushDeltas() if (error instanceof AgentAuthError) dispatch.chat.set({ error: authRequiredError() }) - else if (error instanceof UsageLimitError) - dispatch.chat.applyEvent({ type: 'error', message: usageLimitMessage(error) }) + else if (error instanceof UsageLimitError) dispatch.chat.endTurn(usageLimitMessage(error)) else if (error instanceof AgentStreamEndedError) - // The error event marks the answer Interrupted and ends the turn — a cut-off must not - // leave a truncated reply looking complete with the composer open for another send. - dispatch.chat.applyEvent({ - type: 'error', - message: i18n.t('notices:chat.streamEnded', { + // An interruption, not a completion — a cut-off must not leave a truncated reply + // looking complete with the composer open for another send. + dispatch.chat.endTurn( + i18n.t('notices:chat.streamEnded', { defaultValue: 'The connection to the agent closed before it finished — the answer may be incomplete. Try again.', - }), - }) - else if ((error as Error).name !== 'AbortError') - dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message }) + }) + ) + else if ((error as Error).name !== 'AbortError') dispatch.chat.endTurn((error as Error).message) } finally { flushDeltas() abortController = null - dispatch.chat.set({ streaming: false }) + // Whatever ended the turn — done, an error, an abort — the reply in flight lands once. + dispatch.chat.endTurn() // A finished turn may have created (and titled) a new conversation — refresh the // picker; and the spend just moved, so refresh the usage meter too. dispatch.chat.loadConversations() @@ -322,7 +281,18 @@ export default createModel()({ confirmTool({ turnId, toolUseId: pendingConfirmation.toolUseId, approved: false }).catch(() => {}) abortController?.abort() abortController = null - dispatch.chat.set({ streaming: false, pendingConfirmation: null }) + // Folded HERE, synchronously: the aborted send's own endTurn runs a microtask later, by which + // time a New Chat has cleared the conversation — the partial reply would land in the new one. + dispatch.chat.endTurn() + }, + /* The turn is over: the reply in flight (models/chatLive) joins the transcript — marked + Interrupted when an error ended it — and the turn state clears. Safe to repeat: a second + call finds nothing in flight. */ + async endTurn(error?: string) { + // The LIVE store, not the invocation snapshot: the reply grew after the effect was dispatched + const reply = store.getState().chatLive.reply + if (reply) dispatch.chatLive.clear() + dispatch.chat.turnEnded({ reply: reply && error !== undefined ? { ...reply, interrupted: true } : reply, error }) }, /* Discard the current conversation AND any in-flight turn together. clearConversation is a reducer, so it cannot abort the streamChat request on its own: a turn left running would @@ -532,6 +502,7 @@ export default createModel()({ nextGeneration() abortController?.abort() abortController = null + dispatch.chatLive.clear() // Explicit sign-out ends the background relationship (plan D8): revoke the agent's stored // grant BEFORE the session tokens vanish. AWAITED but BOUNDED — an unawaited revoke raced // oidcClearLocal(), so its authenticated DELETE minted no token and background AI access @@ -555,8 +526,12 @@ export default createModel()({ state.messages.push({ role: 'user', text }) return state }, - applyEvent(state: IChatState, event: AgentEvent) { - return applyAgentEvent(state, event) + turnEnded(state: IChatState, end: { reply: ChatAssistantMessage | null; error?: string }) { + if (end.reply) state.messages.push(end.reply) + if (end.error !== undefined) state.error = end.error + state.streaming = false + state.pendingConfirmation = null + return state }, // Streaming state must not survive a reload — called when the panel mounts resetTransient(state: IChatState) { diff --git a/frontend/src/models/chatLive.ts b/frontend/src/models/chatLive.ts new file mode 100644 index 000000000..0e389eae4 --- /dev/null +++ b/frontend/src/models/chatLive.ts @@ -0,0 +1,39 @@ +import { createModel } from '@rematch/core' +import { RootModel } from '.' +import type { ChatAssistantMessage } from './chat' + +/* The assistant's reply IN FLIGHT, kept out of `chat`. `chat` is persisted, and redux-persist + re-serialises every whitelisted slice and rewrites the whole store on any change to one of + them — which the token stream was causing twenty times a second for the length of every turn, + buying nothing: the server owns the transcript and a reload reconciles from it + (chat.syncTranscript). This slice is never persisted; chat.endTurn folds the reply into + chat.messages once, when the turn ends. */ +export type IChatLiveState = { reply: ChatAssistantMessage | null } + +export default createModel()({ + state: { reply: null } as IChatLiveState, + reducers: { + append(state: IChatLiveState, text: string) { + state.reply ??= { role: 'assistant', text: '', toolCalls: [] } + state.reply.text += text + return state + }, + toolStart(state: IChatLiveState, call: { id: string; name: string; input: Record }) { + state.reply ??= { role: 'assistant', text: '', toolCalls: [] } + state.reply.toolCalls.push({ ...call, status: 'running' }) + return state + }, + toolResult(state: IChatLiveState, outcome: { id: string; result: string; isError: boolean }) { + const call = state.reply?.toolCalls.find(c => c.id === outcome.id) + if (call) { + call.status = outcome.isError ? 'error' : 'done' + call.result = outcome.result + } + return state + }, + clear(state: IChatLiveState) { + state.reply = null + return state + }, + }, +}) diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index ac59a7e12..5948a90eb 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -15,6 +15,7 @@ import billing from './billing' import binaries from './binaries' import bluetooth from './bluetooth' import chat from './chat' +import chatLive from './chatLive' import connections from './connections' import contacts from './contacts' import devices from './devices' @@ -53,6 +54,7 @@ export interface RootModel extends Models { binaries: typeof binaries bluetooth: typeof bluetooth chat: typeof chat + chatLive: typeof chatLive connections: typeof connections contacts: typeof contacts devices: typeof devices @@ -92,6 +94,7 @@ export const models: RootModel = { binaries, bluetooth, chat, + chatLive, connections, contacts, devices, From f867937f9a7dd49b9f589b4bbf3984902effc19c Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 21 Sep 2026 22:49:38 -0700 Subject: [PATCH 236/238] fix(auth,chat): a support tab keeps the shared DPoP key and its token scheme, a stopped stream folds once, an unknown agent event is ignored, desktop view-as runs in the portal --- frontend/src/constants.ts | 2 +- frontend/src/models/chat.test.ts | 52 +++++++++++++++++++ frontend/src/models/chat.ts | 48 ++++++++++------- .../AdminUsersPage/AdminUserDetailPage.tsx | 10 ++-- frontend/src/pages/TestPage.tsx | 2 +- frontend/src/services/agent.ts | 4 +- frontend/src/services/oidc.ts | 41 ++++++++++----- 7 files changed, 119 insertions(+), 40 deletions(-) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 9dffc390f..f4dab6adf 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -46,7 +46,7 @@ export const resourceForApiURL = (url: string): string => url.match(CLOUD_GRAPHQ * unified front (the socket is a path inside the /api resource — minting for the socket URL there * answers invalid_target) and the legacy shared-domain URL (not a registered resource at all). */ export const resourceForEventsURL = (url: string): string | undefined => (LEGACY_EVENTS_RE.test(url) ? url : undefined) -export const OAUTH_PASSPORT_RESOURCE = env.VITE_OAUTH_PASSPORT_RESOURCE || 'https://passport.dev.remote.it/account/api' +export const OAUTH_PASSPORT_RESOURCE = env.VITE_OAUTH_PASSPORT_RESOURCE || 'https://passport.remote.it/account/api' // The AI agent lane (permitteer docs/remoteit-ai-agent.md D1/D5): chat requests carry // tokens ADDRESSED to the agent service, and the sign-in declares the stage's MCP detail // delegated onward to the service actor — which is what makes those tokens exchangeable. diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts index 591850903..ca0d5ce6b 100644 --- a/frontend/src/models/chat.test.ts +++ b/frontend/src/models/chat.test.ts @@ -69,6 +69,7 @@ const makeDispatch = () => ({ // what send() touches around its (mocked, instantly-resolving) streamChat addUserMessage: vi.fn(), endTurn: vi.fn(), + unauthorized: vi.fn(), loadUsage: vi.fn(), }, chatLive: { append: vi.fn(), toolStart: vi.fn(), toolResult: vi.fn(), clear: vi.fn() }, @@ -333,6 +334,57 @@ describe('chat model — stop() denies a pending approval', () => { }) }) +/* A stream whose abort lands AFTER stop() folded the reply: the delta still buffered (and the + flush timer still pending) must not re-open a reply that then lands as a stray message. */ +describe('chat model — a stop mid-stream folds the reply exactly once', () => { + // The stream as send() sees it: events arrive by hand, and an abort rejects the way fetch does. + const abortableStream = () => { + let onEvent!: (event: unknown) => void + streamChat.mockImplementation( + (options: any) => + new Promise((_, reject) => { + onEvent = options.onEvent + options.signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + ) + }) + ) + return { deliver: (event: unknown) => onEvent(event) } + } + + it('a delta buffered at the abort is dropped — not appended after the fold — and the finally asks the agent for nothing', async () => { + vi.useFakeTimers() + try { + const stream = abortableStream() + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const sending = fx.send('hello', sendable()) + stream.deliver({ type: 'text_delta', text: 'tail' }) // buffered behind the 50ms flush timer + await fx.stop(undefined, current({ streaming: true })) + await sending + vi.runAllTimers() // the flush timer fires after the fold + expect(dispatch.chatLive.append).not.toHaveBeenCalled() + expect(dispatch.chat.endTurn).toHaveBeenCalledTimes(1) // stop()'s own fold + expect(dispatch.chat.loadConversations).not.toHaveBeenCalled() + expect(dispatch.chat.loadUsage).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('an unknown event type is ignored rather than read as an error event', async () => { + const stream = abortableStream() + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const sending = fx.send('hello', sendable()) + expect(() => stream.deliver({ type: 'ping', at: 1 })).not.toThrow() + stream.deliver({ type: 'error', message: 'reauth_required: expired' }) + expect(dispatch.chat.endTurn).toHaveBeenCalledWith('notices:chat.sessionExpired') + await fx.stop(undefined, current({ streaming: true })) + await sending + }) +}) + /* A stream the server closed cleanly mid-answer must end the turn as an interruption — not resolve like a completion with a truncated reply on screen and the composer open. */ describe('chat model — send() treats a cut-off stream as an interrupted turn', () => { diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts index 50d217acf..ebfed8c55 100644 --- a/frontend/src/models/chat.ts +++ b/frontend/src/models/chat.ts @@ -176,7 +176,8 @@ export default createModel()({ // title, and loadConversations reconciles after the turn. ...(state.chat.title ? {} : { title: text.replace(/\s+/g, ' ').trim().slice(0, 80) }), }) - abortController = new AbortController() + const controller = new AbortController() + abortController = controller // Same resolution the Current Org label renders, so the scope shown is // always the scope sent — membership decides, name falls back const resolved = resolveChatOrg(state) @@ -188,6 +189,9 @@ export default createModel()({ const flushDeltas = () => { if (flushTimer !== null) window.clearTimeout(flushTimer) flushTimer = null + // An abort (stop, sign-out) has already folded or cleared the reply; a delta still + // buffered here would open a second one, which landed as a stray message of its own. + if (controller.signal.aborted) deltaBuffer = '' if (deltaBuffer) { dispatch.chatLive.append(deltaBuffer) deltaBuffer = '' @@ -198,7 +202,7 @@ export default createModel()({ conversationId, text, org, - signal: abortController.signal, + signal: controller.signal, onEvent: event => { if (event.type === 'turn') { dispatch.chat.set({ turnId: event.turnId }) @@ -215,18 +219,21 @@ export default createModel()({ pendingConfirmation: { toolUseId: event.id, toolName: event.name, input: event.input }, }) else if (event.type === 'done') dispatch.chat.endTurn() - // The backend prefixes auth failures so the client knows a retry is pointless - // until the grant is renewed (e.g. it expired mid-turn). - else if (event.message.startsWith('reauth_required')) { - dispatch.chat.unauthorized() // mid-stream, not an HTTP 401 — agentRequest cannot see it - dispatch.chat.endTurn(sessionExpiredError()) - } else dispatch.chat.endTurn(event.message) + else if (event.type === 'error') { + const message = String(event.message ?? 'Agent error') + // The backend prefixes auth failures so the client knows a retry is pointless + // until the grant is renewed (e.g. it expired mid-turn). + if (message.startsWith('reauth_required')) { + dispatch.chat.unauthorized() // mid-stream, not an HTTP 401 — agentRequest cannot see it + dispatch.chat.endTurn(sessionExpiredError()) + } else dispatch.chat.endTurn(message) + } } }, }) } catch (error) { flushDeltas() - if (error instanceof AgentAuthError) dispatch.chat.set({ error: authRequiredError() }) + if (error instanceof AgentAuthError) dispatch.chat.endTurn(authRequiredError()) else if (error instanceof UsageLimitError) dispatch.chat.endTurn(usageLimitMessage(error)) else if (error instanceof AgentStreamEndedError) // An interruption, not a completion — a cut-off must not leave a truncated reply @@ -240,13 +247,17 @@ export default createModel()({ else if ((error as Error).name !== 'AbortError') dispatch.chat.endTurn((error as Error).message) } finally { flushDeltas() - abortController = null - // Whatever ended the turn — done, an error, an abort — the reply in flight lands once. - dispatch.chat.endTurn() - // A finished turn may have created (and titled) a new conversation — refresh the - // picker; and the spend just moved, so refresh the usage meter too. - dispatch.chat.loadConversations() - dispatch.chat.loadUsage() + if (abortController === controller) abortController = null + // An aborted turn was ended by whoever aborted it — stop() folded the reply, a sign-out + // cleared it — and nothing more is asked of the agent on its behalf. + if (!controller.signal.aborted) { + // Whatever else ended the turn — done or an error — the reply in flight lands once. + dispatch.chat.endTurn() + // A finished turn may have created (and titled) a new conversation — refresh the + // picker; and the spend just moved, so refresh the usage meter too. + dispatch.chat.loadConversations() + dispatch.chat.loadUsage() + } } }, async confirm(approved: boolean, state) { @@ -281,8 +292,9 @@ export default createModel()({ confirmTool({ turnId, toolUseId: pendingConfirmation.toolUseId, approved: false }).catch(() => {}) abortController?.abort() abortController = null - // Folded HERE, synchronously: the aborted send's own endTurn runs a microtask later, by which - // time a New Chat has cleared the conversation — the partial reply would land in the new one. + // Folded HERE, synchronously — the aborted send folds nothing of its own. Its catch and + // finally run a microtask later, by which time a New Chat has cleared the conversation, and + // a fold there would land the partial reply in the new one. dispatch.chat.endTurn() }, /* The turn is over: the reply in flight (models/chatLive) joins the transcript — marked diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index ffea784ee..01b3f8cf8 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -10,9 +10,9 @@ import { Body } from '../../components/Body' import { LoadingMessage } from '../../components/LoadingMessage' import { IconButton } from '../../buttons/IconButton' import { spacing } from '../../styling' -import { OAUTH_ISSUER } from '../../constants' +import { OAUTH_ISSUER, PORTAL_URL } from '../../constants' import { Dispatch, State } from '../../store' -import { windowOpen } from '../../services/browser' +import browser, { windowOpen } from '../../services/browser' export const AdminUserDetailPage: React.FC = () => { const { userId } = useParams<{ userId: string }>() @@ -68,11 +68,13 @@ export const AdminUserDetailPage: React.FC = () => { // the authorizer joins them by email — and the AS resolves the user by email or id. // `origin` names THIS portal — the lane the operator is on (app.dev, app.evan, latest) — so // the support session lands here rather than on whichever redirect URI the client lists first - // (the AS validates it against the registration). + // (the AS validates it against the registration). The desktop app shows local backend data + // and its 127.0.0.1 origin is no portal's, so from there the session runs in the web portal. + const origin = browser.isElectron ? new URL(PORTAL_URL).origin : window.location.origin windowOpen( `${OAUTH_ISSUER}/elevate/launch?user=${encodeURIComponent( user.email || user.id - )}&client=remoteit_portal&origin=${encodeURIComponent(window.location.origin)}` + )}&client=remoteit_portal&origin=${encodeURIComponent(origin)}` ) } diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 2d797551b..a9ab8fe01 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -48,7 +48,7 @@ export const TestPage: React.FC = () => { emit('preferences', values) } - // --- the stage-pair switcher (D10+D11a, permitteer docs/remoteit-desktop-login.md 4c) ---- + // The stage-pair switcher (D10+D11a, permitteer docs/remoteit-desktop-login.md 4c). // The options come FROM the AS: the client's own allowlist joined to registry names, so the // picker and the mint-time guardrail can never disagree. Identifiers group into stage pairs // (graphql + events); one selection sets BOTH URLs and mints BOTH audiences immediately, so diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts index 42e3bd511..9c9bc6343 100644 --- a/frontend/src/services/agent.ts +++ b/frontend/src/services/agent.ts @@ -213,7 +213,7 @@ export async function deleteConversation(conversationId: string): Promise { } } -// --- Background work (permitteer docs/remoteit-ai-agent.md D6/Phase 6) ----------------- +// Background work (permitteer docs/remoteit-ai-agent.md D6/Phase 6). /* Where the enrollment ceremony starts — a top-level navigation to the agent, which redirects into the AS consent screen. Who enrolled is the AS's answer at the diff --git a/frontend/src/services/oidc.ts b/frontend/src/services/oidc.ts index ec2579aa0..8ad33bf82 100644 --- a/frontend/src/services/oidc.ts +++ b/frontend/src/services/oidc.ts @@ -271,6 +271,11 @@ const decodeJwt = (jwt?: string): any => { } } +// The payload's exp when the access token is a JWT, else the response's expires_in. A token +// cached with exp 0 is never fresh, and every call would rotate the refresh family to mint again. +const tokenExpiry = (body: { access_token: string; expires_in?: number }): number => + decodeJwt(body.access_token)?.exp ?? (body.expires_in ? Math.floor(Date.now() / 1000) + Number(body.expires_in) : 0) + const stored = (): Stored | undefined => { try { const raw = tokenStore().getItem(TOKENS_KEY) @@ -604,14 +609,13 @@ export async function oidcCompleteFromUrl(): Promise { }).catch(() => {}) } - const at = decodeJwt(body.access_token) if (claims?.act) { // A SUPPORT session (docs/desktop-support.md): the AS mints no refresh token, and the access // token lives exactly as long as the session — so it is stored (tab-scoped) and used until // it expires; that expiry IS the end of the support session. Never filed as an account. persist({ id_token: body.id_token, - support: { access_token: body.access_token, exp: at?.exp ?? 0, type: body.token_type }, + support: { access_token: body.access_token, exp: tokenExpiry(body), type: body.token_type }, }) } else { persist({ refresh_token: body.refresh_token, id_token: body.id_token }) @@ -632,7 +636,7 @@ export async function oidcCompleteFromUrl(): Promise { } catch { /* non-fatal */ } - access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } + access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: tokenExpiry(body), type: body.token_type } return claims } @@ -647,7 +651,13 @@ export async function oidcAccessToken(resource: string = OAUTH_GRAPHQL_RESOURCE) // the tab store), never refreshed, and '' — the end — once it is gone. Other audiences have // nothing to mint from; their features fail closed, as writes do under `act`. const s = stored() - if (s?.support) return resource === OAUTH_GRAPHQL_RESOURCE && supportLive(s) ? s.support.access_token : '' + if (s?.support) { + if (resource !== OAUTH_GRAPHQL_RESOURCE || !supportLive(s)) return '' + // Cached with its scheme: oidcAuthHeaders reads the type from here, and a reloaded tab + // that served the bound token without it presented it as Bearer — which is refused. + access[resource] = { token: s.support.access_token, exp: s.support.exp, type: s.support.type } + return s.support.access_token + } const fresh = () => { const cached = access[resource] return cached && cached.exp - Math.floor(Date.now() / 1000) > 30 ? cached.token : undefined @@ -721,12 +731,14 @@ async function refreshOnce(resource: string): Promise { return '' } persist({ refresh_token: body.refresh_token || current.refresh_token, id_token: body.id_token || current.id_token }) - const at = decodeJwt(body.access_token) - access[resource] = { token: body.access_token, exp: at?.exp ?? 0, type: body.token_type } + access[resource] = { token: body.access_token, exp: tokenExpiry(body), type: body.token_type } return body.access_token } catch (error: any) { console.error('OIDC REFRESH FAILED', error?.message) - if (error?.oauthError === 'invalid_grant') { + // The same guard as the success path: a store that moved mid-flight belongs to another + // account (or to nobody), and this refusal is not its to answer — a recover round here + // would name the OLD account's login_hint, a clearLocal() would take the NEW tokens. + if (error?.oauthError === 'invalid_grant' && stored()?.refresh_token === current.refresh_token) { // The AS tells a STALE COPY apart from a dead grant: "…this copy is stale and the session was // not ended" means the family rotated on without this tab (a response lost to a navigation, // another tab) and the successor is spent too — this store holds nothing newer, but the AS @@ -768,8 +780,10 @@ function clearLocal() { writeRegistry(reg) } // The one DPoP key binds EVERY saved account's refresh token, so it rotates only when - // the last account leaves — "key loss ≡ session loss" now means ALL sessions. - if (Object.keys(reg).length === 0) void clearDpopKey() + // the last account leaves — "key loss ≡ session loss" now means ALL sessions. A support + // tab's registry is its own empty sessionStorage, never the operator's accounts: ending + // it must not rotate the key their tabs' tokens are bound to. + if (Object.keys(reg).length === 0 && !oidcIsSupportTab()) void clearDpopKey() access = {} tokenStore().removeItem(TOKENS_KEY) tokenStore().removeItem(DECLARATION_KEY) @@ -783,7 +797,7 @@ function persist(tokens: Stored) { fileAccount(tokens) } -// --- the account registry (multi-account menu) --------------------------------------- +// The account registry (multi-account menu). type RegistryEntry = Stored & { email?: string; name?: string; picture?: string; declaration?: string } @@ -910,7 +924,7 @@ function cleanUrl() { window.history.replaceState({}, '', url.toString()) } -// --- DPoP (plan D9): sender-constrained tokens -------------------------------------- +// DPoP (plan D9): sender-constrained tokens. // The key is generated NON-EXTRACTABLE and lives as a CryptoKey in IndexedDB: an XSS can // use it while running in-page, but can never exfiltrate it — which is the entire browser // story. Every /token call carries a proof once a key exists (per-mint opt-in binding for @@ -1066,7 +1080,7 @@ async function tokenRequest(params: { [key: string]: string }): Promise { return body } -// --- the browser's accounts (permitteer docs/browser-accounts.md) -------------------------- +// The browser's accounts (permitteer docs/browser-accounts.md). // The AS keeps a per-browser session SET, but its cookie never reaches this origin, so the // account API serves the set from this token's own session. Members this app holds no tokens // for are filed as KNOWN — identity only — and the menu offers them; picking one is a silent @@ -1143,8 +1157,7 @@ export async function oidcRefreshBrowserAccounts(): Promise { export async function oidcSelectKnownAccount(sub: string): Promise { const e = readRegistry()[sub] if (!e || e.refresh_token || !e.email) return false - await oidcStart({ prompt: 'none', loginHint: e.email }) - return true + return await oidcStart({ prompt: 'none', loginHint: e.email }) } // Discovery and the DPoP key are needed before the first token of every boot (mcpDetailReady From 10ae178e70269417ba9fcabc44f27e418844132e Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 22 Sep 2026 16:46:29 -0700 Subject: [PATCH 237/238] style(chat): the assistant's text carries the same small inset on every side --- frontend/src/components/Chat/ChatMessageItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 14f42318b..8d03fda7e 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -57,10 +57,10 @@ export const ChatMessageItem = React.memo(({ message }) => { '& table': scrollbarStyles(theme, { background: 'grayLightest', width: SCROLLBAR_WIDTH_NARROW }), }), { - // No card: the agent's words sit on the panel itself, flush with the + // No card: the agent's words sit on the panel itself, a hair off the // column edge the user bubble hangs from. The user's turns are then the // only cards, which is what makes each of them stand out in a long thread. - paddingY: 0.5, + padding: 0.5, fontSize: fontSizes.base, lineHeight: 1.7, wordBreak: 'break-word', From 660da627ccabf73c2262702ffc3464773d0fd7fb Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Tue, 22 Sep 2026 16:51:41 -0700 Subject: [PATCH 238/238] style(chat): the assistant's text is inset from the column edge --- frontend/src/components/Chat/ChatMessageItem.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx index 8d03fda7e..e7b6d4523 100644 --- a/frontend/src/components/Chat/ChatMessageItem.tsx +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -57,10 +57,11 @@ export const ChatMessageItem = React.memo(({ message }) => { '& table': scrollbarStyles(theme, { background: 'grayLightest', width: SCROLLBAR_WIDTH_NARROW }), }), { - // No card: the agent's words sit on the panel itself, a hair off the + // No card: the agent's words sit on the panel itself, inset from the // column edge the user bubble hangs from. The user's turns are then the // only cards, which is what makes each of them stand out in a long thread. - padding: 0.5, + paddingX: 1.5, + paddingY: 1, fontSize: fontSizes.base, lineHeight: 1.7, wordBreak: 'break-word',