diff --git a/.env.example b/.env.example index 057de765e..37bfb1d83 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,74 @@ +# 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). +# +# 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 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" +VITE_OAUTH_CLIENT_ID="remoteit_desktop" +VITE_OAUTH_GRAPHQL_RESOURCE="https://cloud.dev.remote.it/api" +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" + +# 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 +# cloud.dev…/api -> cloud.dev…/api/graphql and wss://cloud.dev…/api/ws — 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 --------------------------------------------------------------- +# 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 +# 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 -AIRBRAKE_PROJECT_KEY="..." -VITE_PORTAL="..." -VITE_API_URL="..." -VITE_CLIENT_ID="..." -VITE_GRAPHQL_API="..." -VITE_GRAPHQL_BETA_API="..." +VITE_PORTAL=false +VITE_API_URL="https://api.remote.it/apv/v27" VITE_DEVELOPER_KEY="..." -VITE_DEV_CALLBACK_URL="..." -VITE_PROD_CALLBACK_URL="..." -VITE_WEBSOCKET_URL="..." -VITE_WEBSOCKET_BETA_URL="..." + +# --- 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="..." 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/.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 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9f37eecd7..37032bd25 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -38,3 +38,15 @@ jobs: - name: Run typecheck run: npm run typecheck + + # 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: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/RELEASE.md b/RELEASE.md index 8e1910a11..a83f0f411 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/common/src/constants.ts b/common/src/constants.ts index b19358022..e27e4cd9f 100644 --- a/common/src/constants.ts +++ b/common/src/constants.ts @@ -42,3 +42,9 @@ export const DEFAULT_SERVICE: IService = { license: 'UNKNOWN', attributes: {}, } + +// Chat popout window — the boot flag and dimensions are a contract between +// the frontend (opens the window with ?chatPopout=) and electron (allows +// the window.open and sizes the child window) +export const CHAT_POPOUT_PARAM = 'chatPopout' +export const CHAT_POPOUT_SIZE = { width: 520, height: 780, minWidth: 360, minHeight: 500 } 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" +``` 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" +``` 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..f078ccac3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-ai-agent-license-limit.md @@ -0,0 +1,125 @@ +# 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:** 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 +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 + +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. 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/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). 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. diff --git a/electron/package.json b/electron/package.json index 06177f28b..a412c3718 100644 --- a/electron/package.json +++ b/electron/package.json @@ -173,4 +173,4 @@ "build/**/*" ] } -} +} \ No newline at end of file diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index 69c3487e9..b2d14506d 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -1,4 +1,6 @@ import electron, { Menu, dialog } from 'electron' +import { CHAT_POPOUT_PARAM, CHAT_POPOUT_SIZE } from '@common/constants' +import { execFile } from 'child_process' import path from 'path' import AutoUpdater from './AutoUpdater' import TrayMenu from './TrayMenu' @@ -184,6 +186,83 @@ 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. + * + * 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 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 + } + + 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) { if (!url) return const scheme = this.protocol + '://' @@ -194,6 +273,8 @@ export default class ElectronApp { } if (url.includes('authCallback')) { + // The RENDERER owns the exchange (D8): reload the window with the callback query — + // the app boots with ?code&state exactly like the web return. this.authCallback = true Logger.info('SET AUTH CALLBACK') } @@ -240,16 +321,44 @@ export default class ElectronApp { }) this.window.webContents.setWindowOpenHandler(({ url }) => { - Logger.info('OPEN EXTERNAL URL', { url }) - electron.shell.openExternal(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. + if (this.isAppOrigin(url) && new URL(url).searchParams.has(CHAT_POPOUT_PARAM)) { + return { + action: 'allow', + overrideBrowserWindowOptions: { ...CHAT_POPOUT_SIZE, autoHideMenuBar: true }, + } + } + this.openExternal(url) return { action: 'deny' } }) + // The allowed chat popout is a real child window: give it the same + // external-URL discipline as the main window, or window.open / + // target=_blank / link navigation inside it spawns unguarded native + // windows on remote content instead of the system browser + this.window.webContents.on('did-create-window', child => { + child.webContents.setWindowOpenHandler(({ url }) => { + this.openExternal(url) + return { action: 'deny' } + }) + child.webContents.on('will-navigate', (event, url) => { + if (this.isAppOrigin(url)) return + event.preventDefault() + this.openExternal(url) + }) + }) + this.window.webContents.on('will-navigate', (event, url) => { - if (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. + if (!this.isAppOrigin(url)) { + Logger.info('EXTERNAL NAVIGATION -> SYSTEM BROWSER', { url }) event.preventDefault() - electron.shell.openExternal(url) + this.openAuthWindow(url) } }) @@ -270,6 +379,13 @@ export default class ElectronApp { this.logWebErrors() } + /* The external-URL discipline: everything leaving the renderer opens in + the system browser, logged */ + private openExternal(url: string) { + Logger.info('OPEN EXTERNAL URL', { url }) + electron.shell.openExternal(url) + } + private validateWindowState(state?: IPreferences['windowState']): IPreferences['windowState'] { const defaults = preferences.windowDefaultState ?? { width: 1280, height: 800 } @@ -330,6 +446,14 @@ export default class ElectronApp { lastWindow?.destroy() } + private isAppOrigin(url: string): boolean { + try { + return new URL(url).origin === new URL(this.getStartUrl()).origin + } catch { + return false + } + } + private getStartUrl(): string { return process.env.NODE_ENV === 'development' ? `http://${IP_PRIVATE}:3003` : `http://${IP_PRIVATE}:29999` } diff --git a/frontend/package.json b/frontend/package.json index d5609f534..6c9b18e17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,11 +8,12 @@ "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", - "@aws-amplify/auth": "^5.6.21", "@capacitor-community/bluetooth-le": "^7.1.1", "@capacitor/app": "^7.0.1", "@capacitor/app-launcher": "^7.0.1", @@ -61,10 +62,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", @@ -96,8 +99,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/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/assets/RemoteAI.tsx b/frontend/src/assets/RemoteAI.tsx new file mode 100644 index 000000000..6974f0c28 --- /dev/null +++ b/frontend/src/assets/RemoteAI.tsx @@ -0,0 +1,46 @@ +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). + + 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 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/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/buttons/RefreshButton/RefreshButton.tsx b/frontend/src/buttons/RefreshButton/RefreshButton.tsx index 61edaaeb7..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,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_ADDONS_ROUTE}/: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/cognito/assets/img/logos/amiya.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/amiya.remote.it/logo.png deleted file mode 100644 index 42ddd7583..000000000 Binary files a/frontend/src/cognito/assets/img/logos/amiya.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/beta-commercial.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/beta-commercial.remote.it/logo.png deleted file mode 100644 index e10b7f107..000000000 Binary files a/frontend/src/cognito/assets/img/logos/beta-commercial.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/csp.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/csp.remote.it/logo.png deleted file mode 100644 index f96d352c2..000000000 Binary files a/frontend/src/cognito/assets/img/logos/csp.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/echo.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/echo.remote.it/logo.png deleted file mode 100644 index 95cad3bd7..000000000 Binary files a/frontend/src/cognito/assets/img/logos/echo.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/fraunhofer.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/fraunhofer.remote.it/logo.png deleted file mode 100644 index 8224d2e05..000000000 Binary files a/frontend/src/cognito/assets/img/logos/fraunhofer.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/isoutsource.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/isoutsource.remote.it/logo.png deleted file mode 100644 index c652605cb..000000000 Binary files a/frontend/src/cognito/assets/img/logos/isoutsource.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/jtekt.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/jtekt.remote.it/logo.png deleted file mode 100644 index 186eff199..000000000 Binary files a/frontend/src/cognito/assets/img/logos/jtekt.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/karats.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/karats.remote.it/logo.png deleted file mode 100644 index 74b506692..000000000 Binary files a/frontend/src/cognito/assets/img/logos/karats.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/kidsway.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/kidsway.remote.it/logo.png deleted file mode 100644 index 089924b09..000000000 Binary files a/frontend/src/cognito/assets/img/logos/kidsway.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/mhi.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/mhi.remote.it/logo.png deleted file mode 100644 index 9e45d5178..000000000 Binary files a/frontend/src/cognito/assets/img/logos/mhi.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/netalive.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/netalive.remote.it/logo.png deleted file mode 100644 index dbe662582..000000000 Binary files a/frontend/src/cognito/assets/img/logos/netalive.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/secom.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/secom.remote.it/logo.png deleted file mode 100644 index 5f143f08a..000000000 Binary files a/frontend/src/cognito/assets/img/logos/secom.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/test.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/test.remote.it/logo.png deleted file mode 100644 index e10b7f107..000000000 Binary files a/frontend/src/cognito/assets/img/logos/test.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/tono.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/tono.remote.it/logo.png deleted file mode 100644 index 63aa5e928..000000000 Binary files a/frontend/src/cognito/assets/img/logos/tono.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/assets/img/logos/vfk.remote.it/logo.png b/frontend/src/cognito/assets/img/logos/vfk.remote.it/logo.png deleted file mode 100644 index 89abd715d..000000000 Binary files a/frontend/src/cognito/assets/img/logos/vfk.remote.it/logo.png and /dev/null differ diff --git a/frontend/src/cognito/auth.ts b/frontend/src/cognito/auth.ts deleted file mode 100644 index c2db2622c..000000000 --- a/frontend/src/cognito/auth.ts +++ /dev/null @@ -1,506 +0,0 @@ -import browser, { windowOpen } from '../services/browser' -// import { OAuth2Client } from '@byteowls/capacitor-oauth2' -import { ICredentials } from '@aws-amplify/core' -import { DEVELOPER_KEY } from '../constants' -import { CognitoHostedUIIdentityProvider, Auth } from '@aws-amplify/auth' -import { - AuthProvider, - CodeDeliveryDetails, - CognitoUser, - ISignUpResult, - RecoveryChallengeType, - CognitoUserResult, - SamlOrgResult, -} from './types' -import { CognitoUserSession } from 'amazon-cognito-identity-js' -import axios from 'axios' -import brand from '@common/brand/config' - -export interface ConfigInterface { - cognitoAuthDomain: string - cognitoClientID?: string - cognitoRegion: string - cognitoUserPoolID: string - callbackURL: string - signoutCallbackURL?: string - checkSamlURL: string - redirectURL: string - urlOpener?: any -} - -export class AuthService { - public username?: string - private cognitoAuth: typeof Auth - private config: ConfigInterface - private cognitoUser?: CognitoUser - scope = ['email', 'profile', 'openid', 'aws.cognito.signin.user.admin'] - responseType = 'code' - - constructor(config: ConfigInterface) { - this.config = config - this.cognitoAuth = this.configureCognito() - } - - public async checkSaml(username: string): Promise { - const response = await axios.post( - this.config.checkSamlURL, - { username }, - { headers: { developerKey: DEVELOPER_KEY } } - ) - - // console.log ('setMFAPreference MFA PREF RESPONSE') - // console.log(response) - // console.log(response.data) - if (response.status == 200) { - return response.data - } else { - return { isSaml: false } - } - } - - public async forceTokenRefresh(): Promise { - await this.cognitoAuth.currentAuthenticatedUser({ bypassCache: true }) - } - - public async checkSignIn( - options: { refreshToken: boolean } = { - refreshToken: false, - } - ): Promise { - // Get the main Cognito user first. - try { - const cognitoUser: CognitoUser = await this.cognitoAuth.currentAuthenticatedUser() - - if (!cognitoUser) return { error: new Error('No cognito user') } - - const email = cognitoUser?.attributes?.email - if (!email) throw new Error('no cognito email exists for this user') - - // TODO: this is duplicated also in CognitoAuth.tsx, cleanup - cognitoUser.authProvider = this.determineAuthProvider(cognitoUser) - - // Check for session - let currentSession = await this.currentCognitoSession() - if (options.refreshToken) { - try { - const refreshToken = currentSession.getRefreshToken() - const refreshRequest = new Promise((res, rej) => { - // @ts-ignore - cognitoUser.refreshSession(refreshToken, (err: any, data: unknown) => { - return err ? rej(err) : res(data) - }) - }) - await refreshRequest // note that rejections will be caught and handled in the catch block. - currentSession = await this.currentCognitoSession() - } catch (err) { - // should not throw because user might just be on guest access or is authenticated through federation - console.log('Error attempting to refreshing the session', err) - } - } - if (currentSession) { - currentSession.getAccessToken().getJwtToken() - } else { - return { error: new Error('no current session') } - } - this.cognitoUser = cognitoUser - return { cognitoUser } - } catch (error) { - // Unable to get cognito user - return { error } - } - } - - public async signIn(username: string, password?: string): Promise { - this.username = username - - let cognitoUser: CognitoUser - try { - if (!password) { - cognitoUser = await this.cognitoAuth.signIn(this.username) - } else { - cognitoUser = await this.cognitoAuth.signIn(this.username, password) - } - } catch (error) { - return { error } - } - - // this.user = { cognitoUser, remoteitUser: undefined } - - if (cognitoUser) { - cognitoUser.authProvider = this.determineAuthProvider(cognitoUser) - } - - this.cognitoUser = cognitoUser - - return { cognitoUser } - } - - /** - * Confirm the sign in of a MFA session by providing the given - * verification code. This is the second step in a MFA flow. - */ - public async confirmSignIn(code: string, challengeName?: any): Promise { - if (!this.cognitoUser) return { error: new Error('no user set, cannot finish signin') } //throw new Error('no user set, cannot finish signin') - - try { - const cognitoUser: CognitoUser = await this.cognitoAuth.confirmSignIn(this.cognitoUser, code, challengeName) - - if (!cognitoUser || !this.username) return { error: new Error('confirm signin failed, not enough information') } - - // Update the Cognito user from the sign in response since - // now we have all of their account details. - this.cognitoUser = cognitoUser - } catch (error) { - return { error } - } - - return { cognitoUser: this.cognitoUser } - } - - public async googleSignIn() { - if (browser.isMobile) { - this.mobileAuth(CognitoHostedUIIdentityProvider.Google) - } else { - await this.cognitoAuth.federatedSignIn({ - customState: this.config.redirectURL, - provider: CognitoHostedUIIdentityProvider.Google, - }) - } - } - - // async loginWithCognito() { - // const oauth2Options = { - // appId: '4r5la59beqqc82gkefqmq3pejh', - // authorizationBaseUrl: 'https://auth.remote.it/oauth2/authorize', - // accessTokenEndpoint: 'https://auth.remote.it/oauth2/token', - // responseType: 'code', // You're using authorization code flow - // redirectUrl: 'remoteit://authCallback', // Must match the registered redirect URI - // scope: 'openid profile email', // Adjust the scope to match what is required for your Cognito setup - // additionalParameters: { - // // These parameters should reflect the Cognito and Google setup - // response_type: 'code', - // client_id: '4r5la59beqqc82gkefqmq3pejh', - // redirect_uri: 'remoteit://authCallback', - // identity_provider: 'Google', - // }, - // pkceEnabled: true, - // android: { - // responseType: 'code', // Set to 'code' to ensure the use of the authorization code flow - // }, - // ios: { - // responseType: 'code', // Set to 'code' to ensure the use of the authorization code flow - // }, - // } - - // try { - // const result = await OAuth2Client.authenticate(oauth2Options) - // console.log('OAuth result:', result) - - // // The result object will contain "access_token" among other tokens - // } catch (e) { - // console.error('OAuth failed:', e) - // } - // } - - public async oktaSignIn(): Promise { - return this.cognitoAuth.federatedSignIn({ - customState: this.config.redirectURL, - customProvider: 'Okta', - }) - } - - public async appleSignIn() { - if (browser.isMobile) { - this.mobileAuth(CognitoHostedUIIdentityProvider.Apple) - } else { - this.cognitoAuth.federatedSignIn({ - customState: this.config.redirectURL, - provider: CognitoHostedUIIdentityProvider.Apple, - }) - } - } - - public async amazonSignIn(): Promise { - return this.cognitoAuth.federatedSignIn({ - customState: this.config.redirectURL, - provider: CognitoHostedUIIdentityProvider.Amazon, - }) - } - - public async samlSignIn(domain: string): Promise { - return this.cognitoAuth.federatedSignIn({ - customState: this.config.redirectURL, - customProvider: domain, - }) - } - - public async mobileAuth(provider: string) { - const params = [ - `identity_provider=${provider}`, - `redirect_uri=${this.config.callbackURL}`, - `client_id=${this.config.cognitoClientID}`, - `response_type=${this.responseType}`, - `scope=${this.scope.join('+')}`, - ] - const authUrl = `https://${this.config.cognitoAuthDomain}/oauth2/authorize?${params.join('&')}` - await windowOpen(authUrl, 'auth') - // await this.loginWithCognito() @TODO implement with oauth2 - } - - public async signUp(username: string, password: string): Promise { - // r3.user.create(email) - try { - const resp: ISignUpResult = await this.cognitoAuth.signUp({ - username, - password, - attributes: { 'custom:brand': brand.name }, - }) - - this.cognitoUser = { - ...resp.user, - username, - preferredMFA: 'NOMFA', - } - - return { cognitoUser: this.cognitoUser } - } catch (error) { - return { error } - } - } - - public async resendSignUp(username: string): Promise { - await this.cognitoAuth.resendSignUp(username) - } - - public async forgotPassword(email: string): Promise { - const resp: CodeDeliveryDetails = await this.cognitoAuth.forgotPassword(email) - return resp - } - - public async forgotPasswordSubmit(shortcode: string, password: string, email?: string): Promise { - email = email || this.username - if (!email) throw new Error('Cannot send password reset, no email provided!') - this.username = email - return this.cognitoAuth.forgotPasswordSubmit(email, shortcode, password) - } - - /** - * requestAccountRecovery attempts to signin without password which sends - * an email to the user with a verification code. They can then use this - * code along with their two-factor account recovery code using the - * method "verifyRecoveryCode" to turn off two-factor and sign in. - */ - public async requestAccountRecovery(email?: string): Promise { - email = email || this.username - if (!email) return { error: new Error('Cannot request account recovery, no email provided!') } - - this.username = email - - // Signing in without a password triggers a request to reset - // the account. - const result = await this.signIn(email) - - if (result.error) { - return { error: result.error } - } - - this.cognitoUser = result.cognitoUser - - // This shouldn't happen, but handle it anyways. - if (!result.cognitoUser?.challengeName) - return { error: new Error('No challenge code provided, cannot reset account!') } - - const recoveryType: RecoveryChallengeType | undefined = this.cognitoUser?.challengeParam?.challengeType - - if ( - !this.cognitoUser?.challengeParam?.challengeType || - !['EMAIL_CODE', 'BACKUP_CODE'].includes(this.cognitoUser?.challengeParam?.challengeType) - ) - return { error: new Error('Invalid recovery type returned: ' + recoveryType) } - - return { cognitoUser: this.cognitoUser } - } - - /** - * verifyRecoveryCode is the second part of a two-factor account recovery process. - * The first step is to call "requestAccountRecovery" which will send a recovery email - * to the user. They then take that code and their two-factor recovery code and pass it - * into this method which allows them to login and change their settings. - */ - public async verifyRecoveryCode(emailVerificationCode: string, recoveryCode: string): Promise { - if (!emailVerificationCode) return { error: new Error('No email verification code provided!') } - - if (!this.username || !this.cognitoUser) - return { error: new Error('no user instance exists, please attempt signin first!') } - - try { - // This is an email only account recovery - let newUser: CognitoUser = await this.cognitoAuth.sendCustomChallengeAnswer( - this.cognitoUser, - emailVerificationCode - ) - - // Update the local copy of the Cognito user - this.cognitoUser = newUser - - // A rejected email code leaves the EMAIL_CODE challenge pending. Stop here - // so the recovery code below isn't consumed as another email code attempt. - if (newUser.challengeName === 'CUSTOM_CHALLENGE' && newUser.challengeParam?.challengeType === 'EMAIL_CODE') { - return { error: new Error('Email verification code is invalid, please double check and try again!') } - } - - newUser = await this.cognitoAuth.sendCustomChallengeAnswer(this.cognitoUser, recoveryCode) - - // Update the local copy of the Cognito user - this.cognitoUser = newUser - } catch (error) { - return { error } - } - - if (this.cognitoUser.challengeName === 'CUSTOM_CHALLENGE') { - return { error: new Error('Backup code is invalid, please double check and try again!') } - } - - // The custom auth flow can end without issuing tokens. Treat that as a - // failure so the user isn't redirected into an unauthenticated app. - if (!this.cognitoUser.signInUserSession) { - return { error: new Error('Account recovery did not complete, please try again or contact support.') } - } - - // Now get the Remote.It specific account information and - // then return the full user object. - // Removing this check for now - // this.user.remoteitUser = await this.getRemoteitUserInfo(this.username) - - return { cognitoUser: this.cognitoUser } - } - - public async currentCognitoSession(): Promise { - return this.cognitoAuth.currentSession() - } - - public async currentUserInfo(): Promise { - return await this.cognitoAuth.currentUserInfo() - } - - public async currentAuthenticatedUser(): Promise { - return await this.cognitoAuth.currentAuthenticatedUser() - } - - public async updateCurrentUserAttributes(attributes: any) { - const user = this.cognitoUser - return await Auth.updateUserAttributes(user, attributes) - } - - public async verifyCurrentUserAttribute(attribute: string) { - Auth.verifyCurrentUserAttribute(attribute) - } - - public async verifyCurrentUserAttributeSubmit(attribute: string, verificationCode: string) { - await Auth.verifyCurrentUserAttributeSubmit(attribute, verificationCode) - // .then(result => { - // console.log('verify number', result) - // }) - } - - public async setupTOTP() { - const awsUser = await this.cognitoAuth.currentAuthenticatedUser() - const code = await Auth.setupTOTP(awsUser) //.then(code => { - // You can directly display the `code` to the user or convert it to a QR code to be scanned. - // E.g., use following code sample to render a QR code with `qrcode.react` component: - // import QRCode from 'qrcode.react'; - // const str = "otpauth://totp/AWSCognito:"+ username + "?secret=" + code + "&issuer=" + issuer; - // - return code - } - - public async verifyTotpToken(code: string) { - const awsUser = await this.cognitoAuth.currentAuthenticatedUser() - await Auth.verifyTotpToken(awsUser, code) - } - - public async changePassword(existingPassword: string, newPassword: string) { - const awsUser = await this.cognitoAuth.currentAuthenticatedUser() - const session = await this.currentCognitoSession() - const refreshToken = session.getRefreshToken() - await new Promise((res, rej) => { - // @ts-ignore - awsUser.refreshSession(refreshToken, (err: any, data: unknown) => (err ? rej(err) : res(data))) - }) - await Auth.changePassword(awsUser, existingPassword, newPassword) - } - - public async signOut() { - try { - if (browser.isMobile) { - const params = [ - `client_id=${this.config.cognitoClientID}`, - `redirect_uri=${this.config.redirectURL}`, - `logout_uri=${this.config.signoutCallbackURL}`, - `response_type=${this.responseType}`, - `scope=${this.scope.join('+')}`, - ] - const logoutUrl = `https://${this.config.cognitoAuthDomain}/logout?${params.join('&')}` - await windowOpen(logoutUrl, 'auth') - } - await this.cognitoAuth.signOut() - } catch {} - } - - /* ------------------------------------------------------ - Private methods - ------------------------------------------------------ */ - - // private async getRemoteitUserInfo(email: string): Promise { - // const [remoteitUser, partnerPortalAccess] = await Promise.all([ - // this.remoteit?.user - // .userData(email) - // .catch((e: Error) => console.error(e.message)), - // this.remoteit?.entities - // .acl() - // .then(() => true) - // .catch(() => false), - // ]) - // if (!remoteitUser) throw new Error('Could not get Remote.It user info!') - // return { ...remoteitUser, partnerPortalAccess } - // } - - private determineAuthProvider(cognitoUser: CognitoUser): AuthProvider { - let authProvider: AuthProvider = '' - if (cognitoUser?.username?.includes('Google') || cognitoUser?.username?.includes('google')) { - authProvider = 'Google' - } - - if (cognitoUser?.username?.includes('Apple') || cognitoUser?.username?.includes('apple')) { - authProvider = 'Apple' - } - return authProvider - } - - private configureCognito() { - const config = this.config - - Auth.configure({ - Auth: { - mandatorySignIn: true, - region: config.cognitoRegion, - userPoolId: config.cognitoUserPoolID, - userPoolWebClientId: config.cognitoClientID, - }, - }) - - let oauth = { - domain: config.cognitoAuthDomain, - scope: this.scope, - redirectSignIn: config.callbackURL, - redirectSignOut: config.signoutCallbackURL ? config.signoutCallbackURL : config.callbackURL, - responseType: this.responseType, // or 'token', note that REFRESH token will only be generated when the responseType is code - urlOpener: config.urlOpener, - } - - // your Cognito Hosted UI configuration - Auth.configure({ oauth }) - - return Auth - } -} diff --git a/frontend/src/cognito/components/AccountRecovery/AccountRecovery.tsx b/frontend/src/cognito/components/AccountRecovery/AccountRecovery.tsx deleted file mode 100644 index 95c3b6274..000000000 --- a/frontend/src/cognito/components/AccountRecovery/AccountRecovery.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useState } from 'react' -import { Box, Button, TextField, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { AuthLayout } from '../AuthLayout' -import { Notice } from '../../../components/Notice' -import { Icon } from '../../../components/Icon' -import { VerifyRecoveryCodeFunc, SignInFunc, SignInSuccessFunc } from '../../types' - -export type AccountRecoveryProps = { - onVerifyRecoveryCode: VerifyRecoveryCodeFunc - onSignIn: SignInFunc - onSignInSuccess: SignInSuccessFunc - email: string - fullWidth?: boolean -} - -export function AccountRecovery({ - onSignIn, - onSignInSuccess, - onVerifyRecoveryCode, - email, - fullWidth, -}: AccountRecoveryProps): JSX.Element { - const { t } = useTranslation('cognito') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - const [sentEmailVerifyRequest, setSentEmailVerifyRequest] = useState(false) - const [emailVerificationCode, setEmailVerificationCode] = useState('') - const [recoveryCode, setRecoveryCode] = useState('') - - async function handleSendEmailVerifyRequest(e: React.FormEvent): Promise { - e.preventDefault() - - if (!email) return - - setError('') - setLoading(true) - - try { - const challenge = await onSignIn(email) - console.log('SIGN IN RETURNED') - console.log('Challenge:') - console.log(challenge) - if (challenge && challenge.includes('CUSTOM_CHALLENGE')) { - console.log('setSentEmail') - setSentEmailVerifyRequest(true) - } - } catch (localError) { - // console.log('Error: ') - // console.log(error) - setError(localError.message) - } - - setLoading(false) - } - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault() - - if (!email) return - - setError('') - setLoading(true) - - try { - const result = await onVerifyRecoveryCode(emailVerificationCode, recoveryCode) - if (result.error) { - setError(result.error.message) - } else if (result.cognitoUser) { - onSignInSuccess(result.cognitoUser) - } else { - setError('Account recovery did not complete, please try again or contact support.') - } - } catch (e) { - setError(t(`pages.auth-mfa.errors.${e.code}`)) - } - - setLoading(false) - } - - if (!email) return <> - console.log('sentEmailVerifyRequest:') - console.log(sentEmailVerifyRequest) - - return ( - <> - - {sentEmailVerifyRequest ? ( - <> -
- {error && ( - - {error} - - )} - {t('pages.account-recovery.description')} - - setEmailVerificationCode(e.currentTarget.value.trim())} - InputProps={{ disableUnderline: true }} - required - value={emailVerificationCode} - variant="filled" - /> - - - setRecoveryCode(e.currentTarget.value.trim())} - InputProps={{ disableUnderline: true }} - required - value={recoveryCode} - variant="filled" - /> - - - - -
- - - ) : ( - <> -
- - {t('pages.account-recovery.account-recovery-code')} - - - - -
- - - - - )} -
- - ) -} - -function SupportRecoveryRequest(): JSX.Element { - const { t } = useTranslation('cognito') - return ( - - - - {t('pages.account-recovery.lost-recovery-code-heading')} - - - {t('pages.account-recovery.lost-recovery-code')} - - - - - ) -} diff --git a/frontend/src/cognito/components/AccountRecovery/index.ts b/frontend/src/cognito/components/AccountRecovery/index.ts deleted file mode 100644 index d31043763..000000000 --- a/frontend/src/cognito/components/AccountRecovery/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountRecovery } from './AccountRecovery' diff --git a/frontend/src/cognito/components/AppleSignInButton.tsx b/frontend/src/cognito/components/AppleSignInButton.tsx deleted file mode 100644 index ce2921422..000000000 --- a/frontend/src/cognito/components/AppleSignInButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' -import { Stack, Button, ButtonProps } from '@mui/material' -import { Icon } from '../../components/Icon' - -export type AppleSignInButtonProps = Omit - -export function AppleSignInButton(props: AppleSignInButtonProps) { - return ( - - ) -} diff --git a/frontend/src/cognito/components/Auth/Auth.tsx b/frontend/src/cognito/components/Auth/Auth.tsx deleted file mode 100644 index 96db48871..000000000 --- a/frontend/src/cognito/components/Auth/Auth.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import React from 'react' -import { Route, Switch, useLocation } from 'react-router-dom' -import { - CheckSamlFunc, - CognitoUser, - ChallengeOption, - ConfirmSignInFunc, - FederatedSignInFunc, - RecoverPasswordRequestFunc, - SendCustomChallengeAnswerFunc, - SignInFunc, - SignInSuccessFunc, - SignUpFunc, - SamlSignInFunc, - VerifyPasswordChangeFunc, - VerifyRecoveryCodeFunc, - ResendFunc, -} from '../../types' -import { AccountRecovery } from '../AccountRecovery' -import { ForgotPassword } from '../ForgotPassword' -import { MFACode } from '../MFACode' -import { PasswordVerify } from '../PasswordVerify' -import { SignIn } from '../SignIn' -import { SignUp } from '../SignUp' -import { SignUpVerify } from '../SignUpVerify' -import { Wrapper } from '../Wrapper' - -export type AuthProps = { - onConfirmSignIn: ConfirmSignInFunc - onGoogleSignIn: FederatedSignInFunc - onAppleSignIn: FederatedSignInFunc - onOktaSignIn: FederatedSignInFunc - onRecoverPasswordRequest: RecoverPasswordRequestFunc - onSendCustomChallengeAnswer: SendCustomChallengeAnswerFunc - onSignIn: SignInFunc - onCheckSaml: CheckSamlFunc - onRecoverySignIn: SignInFunc - onSignInSuccess: SignInSuccessFunc - onSamlSignIn: SamlSignInFunc - onSignUp: SignUpFunc - onResend: ResendFunc - onVerifyPasswordChange: VerifyPasswordChangeFunc - onVerifyRecoveryCode: VerifyRecoveryCodeFunc - cognitoUser?: CognitoUser - showLogo?: boolean - errorMessage?: string - hideCaptcha?: boolean - inputEmail?: string // move to global state? - fullWidth?: boolean -} - -export function Auth(props: AuthProps): JSX.Element { - console.log('AUTH WRAPPER render') - return ( - - - - ) -} - -function Routes({ - onCheckSaml, - onConfirmSignIn, - onGoogleSignIn, - onAppleSignIn, - onOktaSignIn, - onRecoverPasswordRequest, - onSendCustomChallengeAnswer, - onSignIn, - onSamlSignIn, - onRecoverySignIn, - onSignInSuccess, - onSignUp, - onResend, - onVerifyPasswordChange, - onVerifyRecoveryCode, - cognitoUser, - showLogo, - errorMessage, - hideCaptcha, - inputEmail, - fullWidth, -}: AuthProps): JSX.Element { - const location = useLocation() - const [challenge, setChallenge] = React.useState() - const [email, setEmail] = React.useState(inputEmail || '') - - // save initial route for after sign in - React.useEffect(() => { - if (location.pathname !== '/sign-in') window.localStorage.setItem('initialRoute', location.pathname) - }, []) - - async function handleSignIn(username: string, password?: string): Promise { - const challenge = await onSignIn(username, password) - if (challenge) setChallenge(challenge) - return challenge - } - - async function handleSamlSignIn(domain: string) { - await onSamlSignIn(domain) - } - - async function handleSignIn2(username: string) { - const challenge = await onRecoverySignIn(username) - return challenge - } - - async function handleRecoverPasswordRequest(recoveryEmail: string): Promise { - setEmail(recoveryEmail) - onRecoverPasswordRequest(recoveryEmail) - } - - async function handleUsernameChange(username: string): Promise { - setEmail(username) - } - - async function handleSignup(newEmail: string, password: string): Promise { - await onSignUp(newEmail, password) - setEmail(newEmail) - } - - async function handleResend(newEmail: string): Promise { - await onResend(newEmail) - } - - return ( - <> - - ( - - )} - path="/forgot-password/verify" - /> - ( - - )} - path="/forgot-password" - /> - ( - - )} - path="/update-password" - /> - } path="/sign-up/verify" /> - ( - - )} - path="/sign-up/:email?" - /> - ( - - )} - path="/account-recovery" - /> - ( - - )} - path="/mfa-verify" - /> - ( - - )} - path="*" - /> - - - ) -} diff --git a/frontend/src/cognito/components/Auth/index.ts b/frontend/src/cognito/components/Auth/index.ts deleted file mode 100644 index 2800b0a3b..000000000 --- a/frontend/src/cognito/components/Auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Auth } from './Auth' diff --git a/frontend/src/cognito/components/AuthLayout/AuthLayout.tsx b/frontend/src/cognito/components/AuthLayout/AuthLayout.tsx deleted file mode 100644 index 772942be6..000000000 --- a/frontend/src/cognito/components/AuthLayout/AuthLayout.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' -import { Box, Stack, Tooltip, IconButton } from '@mui/material' -import { useHistory } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { SplashScreen } from '../SplashScreen' -import { PageHeading } from '../PageHeading' -import { Icon } from '../../../components/Icon' - -export type AuthLayoutProps = { - children: React.ReactNode - i18nKey?: string - showLogo?: boolean - back?: boolean - backLink?: string - fullWidth?: boolean -} - -export function AuthLayout({ children, i18nKey, showLogo, back, backLink, fullWidth }: AuthLayoutProps): JSX.Element { - const { t } = useTranslation('cognito') - const history = useHistory() - - let logo: null | React.ReactElement = null - if (showLogo) { - logo = - } - - return ( - - {logo} - {i18nKey && ( - - - {!!back && ( - - (backLink ? history.push(backLink) : history.goBack())} - > - - - - )} - {t(i18nKey)} - - - )} - {children} - - ) -} diff --git a/frontend/src/cognito/components/AuthLayout/index.ts b/frontend/src/cognito/components/AuthLayout/index.ts deleted file mode 100644 index 4b0e7c9c6..000000000 --- a/frontend/src/cognito/components/AuthLayout/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuthLayout } from './AuthLayout' diff --git a/frontend/src/cognito/components/Captcha/Captcha.tsx b/frontend/src/cognito/components/Captcha/Captcha.tsx deleted file mode 100644 index 3b7016f02..000000000 --- a/frontend/src/cognito/components/Captcha/Captcha.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import ReCAPTCHA from 'reaptcha' -import { RECAPTCHA_SITE_KEY } from '../../../constants' - -export type CaptchaProps = { - id?: string - onVerify: () => void -} - -export function Captcha({ ...props }: CaptchaProps): JSX.Element { - return -} diff --git a/frontend/src/cognito/components/Captcha/index.ts b/frontend/src/cognito/components/Captcha/index.ts deleted file mode 100644 index ec98c5d50..000000000 --- a/frontend/src/cognito/components/Captcha/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Captcha } from './Captcha' diff --git a/frontend/src/cognito/components/CoBrandingLogo/CoBrandingLogo.tsx b/frontend/src/cognito/components/CoBrandingLogo/CoBrandingLogo.tsx deleted file mode 100644 index d355bfdd5..000000000 --- a/frontend/src/cognito/components/CoBrandingLogo/CoBrandingLogo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -export type CoBrandingLogoProps = React.DetailedHTMLProps< - React.ImgHTMLAttributes, - HTMLImageElement -> & { - email?: string - hostName?: string - fallback?: JSX.Element - onLoaded?: () => void -} - -export function CoBrandingLogo({ - fallback, - hostName = window.location.hostname, - onLoaded, - ...props -}: CoBrandingLogoProps): JSX.Element { - try { - return - } catch (error) { - if (fallback) return fallback - return <> - } -} diff --git a/frontend/src/cognito/components/CoBrandingLogo/index.ts b/frontend/src/cognito/components/CoBrandingLogo/index.ts deleted file mode 100644 index 7d17f30fb..000000000 --- a/frontend/src/cognito/components/CoBrandingLogo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CoBrandingLogo } from './CoBrandingLogo' diff --git a/frontend/src/cognito/components/CognitoAuth/CognitoAuth.tsx b/frontend/src/cognito/components/CognitoAuth/CognitoAuth.tsx deleted file mode 100644 index af4412461..000000000 --- a/frontend/src/cognito/components/CognitoAuth/CognitoAuth.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from 'react' -import { CognitoUser, ChallengeOption, SignInSuccessFunc, CognitoUserResult, SamlOrgResult } from '../../types' -import { SplashScreen } from '../SplashScreen' -import { AuthService } from '../../auth' -import { Auth } from '../Auth' - -export type CognitoAuthProps = { - onSignInSuccess: SignInSuccessFunc - clientId?: string - errorMessage?: string - hideCaptcha?: boolean - authService: AuthService - fullWidth?: boolean -} - -export function CognitoAuth({ - onSignInSuccess, - errorMessage, - authService, - hideCaptcha, - fullWidth, -}: CognitoAuthProps): JSX.Element { - const [authUser, setAuthUser] = React.useState() - const [loading, setLoading] = React.useState(false) - const [cognito] = React.useState(authService) - - React.useEffect(() => { - handleCheckSignIn() - }, []) - - async function handleCheckSignIn(): Promise { - setLoading(true) - try { - const result = await cognito.checkSignIn() - if (result.cognitoUser) { - setAuthUser(result.cognitoUser) - onSignInSuccess(result.cognitoUser) - } - } catch (err) { - console.error(err) - } - setLoading(false) - } - - async function handleCheckSaml(username: string): Promise { - return await cognito.checkSaml(username) - } - - async function handleSignIn(username: string, password?: string): Promise { - const result = await cognito.signIn(username, password) - - if (result.error) throw result.error - - if (result.cognitoUser) { - setAuthUser(result.cognitoUser) - - if (result.cognitoUser.challengeName) { - return result.cognitoUser.challengeName - } - - onSignInSuccess(result.cognitoUser) - } - - return - } - - async function handleRecoverySignIn(username: string, password?: string): Promise { - const result = await cognito.signIn(username, password) - - if (result.error) throw result.error - - if (result.cognitoUser) { - // setAuthUser(result.cognitoUser) - - if (result.cognitoUser.challengeName) { - return result.cognitoUser.challengeName - } - - onSignInSuccess(result.cognitoUser) - } - - return - } - - async function handleSamlSignIn(domain: string): Promise { - return await cognito.samlSignIn(domain) - } - - async function handleOktaSignIn(): Promise { - return await cognito.oktaSignIn() - } - - async function handleVerifyPasswordChange(email: string, password: string, shortcode: string): Promise { - return await cognito.forgotPasswordSubmit(shortcode, password, email) - } - - async function handleRecoverPasswordRequest(email: string): Promise { - return await cognito.forgotPassword(email) - // .catch(e => { - // //don't give the user the hint that the email doesn't exist to prevent brute force attacks and to avoid possible users listing through a script - // console.error('recoverPasswordRequest Error', e) - // }) - } - - async function handleVerifyRecoveryCode( - emailVerificationCode: string, - recoveryCode: string - ): Promise { - return await cognito.verifyRecoveryCode(emailVerificationCode, recoveryCode) - } - - async function handleConfirmSignIn(code: string, challengeName?: ChallengeOption): Promise { - await cognito.confirmSignIn(code, challengeName) - } - - async function handleSendCustomChallengeAnswer(_code: string): Promise { - alert('send custom challenge answer') - return {} as CognitoUser - } - - async function handleSignUp(username: string, password: string): Promise { - const response = await cognito.signUp(username, password) - if (response.error) { - // await cognito.resendSignUp(username) - throw response.error - } - } - - async function handleResend(username: string): Promise { - await cognito.resendSignUp(username) - } - - if (loading) { - return - } - - console.log('COGNITO AUTH RENDER') - - return ( - cognito.googleSignIn()} - onAppleSignIn={() => cognito.appleSignIn()} - onOktaSignIn={handleOktaSignIn} - onRecoverPasswordRequest={handleRecoverPasswordRequest} - onRecoverySignIn={handleRecoverySignIn} - onResend={handleResend} - onSamlSignIn={handleSamlSignIn} - onSendCustomChallengeAnswer={handleSendCustomChallengeAnswer} - onSignIn={handleSignIn} - onSignInSuccess={onSignInSuccess} - onSignUp={handleSignUp} - onVerifyPasswordChange={handleVerifyPasswordChange} - onVerifyRecoveryCode={handleVerifyRecoveryCode} - /> - ) -} diff --git a/frontend/src/cognito/components/CognitoAuth/index.ts b/frontend/src/cognito/components/CognitoAuth/index.ts deleted file mode 100644 index ec1b6f2de..000000000 --- a/frontend/src/cognito/components/CognitoAuth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./CognitoAuth"; diff --git a/frontend/src/cognito/components/ErrorHandler/ErrorHandler.tsx b/frontend/src/cognito/components/ErrorHandler/ErrorHandler.tsx deleted file mode 100644 index 09dea1377..000000000 --- a/frontend/src/cognito/components/ErrorHandler/ErrorHandler.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react' -import { Box, Typography, Container } from '@mui/material' -import { MODE } from '../../../constants' -import { Link } from '../../../components/Link' - -export type Props = { - children: React.ReactNode -} - -interface State { - error: Error | null -} - -export class ErrorHandler extends React.Component { - state: State = { error: null } - - componentDidCatch(error: Error, _info: any) { - if (MODE === 'test') throw error - console.error('[CAUGHT ERROR]:', error) - this.setState({ error }) - } - - render() { - const { error } = this.state - if (error) { - return ( - - - Something went wrong - - - - Something on the page has gone wrong. Please try reloading the page and trying again. - - - - - If you continue to experience problems, please contact support at{' '} - support@remote.it. - - - Sorry for the inconvenience! - -
{error.message}
-
-
- ) - } - - return this.props.children - } -} diff --git a/frontend/src/cognito/components/ErrorHandler/index.ts b/frontend/src/cognito/components/ErrorHandler/index.ts deleted file mode 100644 index 21832e685..000000000 --- a/frontend/src/cognito/components/ErrorHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ErrorHandler } from './ErrorHandler' diff --git a/frontend/src/cognito/components/ForgotPassword/ForgotPassword.tsx b/frontend/src/cognito/components/ForgotPassword/ForgotPassword.tsx deleted file mode 100644 index 1780ee707..000000000 --- a/frontend/src/cognito/components/ForgotPassword/ForgotPassword.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { Box, Button, TextField } from '@mui/material' -import { Link } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { Notice } from '../../../components/Notice' -import { AuthLayout } from '../AuthLayout' -import { Icon } from '../../../components/Icon' -import { useHistory } from 'react-router-dom' -import { RecoverPasswordRequestFunc } from '../../types' - -export type ForgotPasswordProps = { - email?: string - onRecoverPasswordRequest: RecoverPasswordRequestFunc - titleKey?: string - buttonKey?: string - fullWidth?: boolean -} - -export function ForgotPassword({ - email, - onRecoverPasswordRequest, - titleKey, - buttonKey, - fullWidth, -}: ForgotPasswordProps): JSX.Element { - const { t } = useTranslation('cognito') - const history = useHistory() - const [stateEmail, setEmail] = React.useState(email ? email : '') - const [error, setError] = React.useState(null) - const [loading, setLoading] = React.useState(false) - - if (titleKey === undefined) { - titleKey = 'pages.forgot-password.title' - } - if (buttonKey === undefined) { - buttonKey = 'pages.forgot-password.reset-password' - } - - function emailChange(e: React.ChangeEvent): void { - setEmail(e.currentTarget.value.toLowerCase().trim()) - } - - async function handleForgotPassword(e: React.FormEvent): Promise { - e.preventDefault() - - setError(null) - setLoading(true) - - try { - await onRecoverPasswordRequest(stateEmail) - history.push(`/forgot-password/verify`) - return - } catch (error) { - setError(error) - } - - setLoading(false) - } - - return ( - -
- {error && ( - - {error.message} - - )} - - - - - - - - - -
-
- ) -} diff --git a/frontend/src/cognito/components/ForgotPassword/index.ts b/frontend/src/cognito/components/ForgotPassword/index.ts deleted file mode 100644 index ee0eed6e0..000000000 --- a/frontend/src/cognito/components/ForgotPassword/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ForgotPassword } from './ForgotPassword' diff --git a/frontend/src/cognito/components/GoogleSignInButton.tsx b/frontend/src/cognito/components/GoogleSignInButton.tsx deleted file mode 100644 index 7dac63132..000000000 --- a/frontend/src/cognito/components/GoogleSignInButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' -import { Button, ButtonProps } from '@mui/material' - -export type GoogleSignInButtonProps = Omit - -export function GoogleSignInButton(props: GoogleSignInButtonProps): JSX.Element { - return ( - - ) -} diff --git a/frontend/src/cognito/components/MFACode/MFACode.tsx b/frontend/src/cognito/components/MFACode/MFACode.tsx deleted file mode 100644 index 593154466..000000000 --- a/frontend/src/cognito/components/MFACode/MFACode.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React, { useState } from 'react' -import { Box, Button, Container, TextField, Typography, Divider } from '@mui/material' -import { Link as RouterLink } from 'react-router-dom' -import { Link } from '../../../components/Link' -import { - CognitoUser, - ChallengeOption, - ConfirmSignInFunc, - SendCustomChallengeAnswerFunc, - SignInSuccessFunc, -} from '../../types' -import { useTranslation } from 'react-i18next' -import { AuthLayout } from '../AuthLayout' -import { Notice } from '../../../components/Notice' -import { Icon } from '../../../components/Icon' - -export type MFACodeProps = { - challengeName?: ChallengeOption - onConfirmSignIn: ConfirmSignInFunc - onSendCustomChallengeAnswer: SendCustomChallengeAnswerFunc - onSignInSuccess: SignInSuccessFunc - cognitoUser?: CognitoUser -} - -function formatMaskedDestination(destination?: string): string { - const lastFour = destination?.match(/\d{4}$/)?.[0] - return lastFour ? ` (ending in ${lastFour})` : '' -} - -export function MFACode({ - challengeName, - onConfirmSignIn, - onSendCustomChallengeAnswer, - onSignInSuccess, - cognitoUser, -}: MFACodeProps): JSX.Element { - const { t } = useTranslation('cognito') - const [localCognitoUser, setAuthUser] = useState(cognitoUser) - const [code, setCode] = useState('') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault() - if (!localCognitoUser) return - - // Clear out previous state - setError('') - setLoading(true) - - // You need to get the code from the UI inputs - // and then trigger the following function with a button click - //const code = 'GET_A_CODE_HERE' //TODO: getCodeFromUserInput() - // If MFA is enabled, sign-in should be confirmed with the confirmation code - - if (challengeName === 'CUSTOM_CHALLENGE') { - try { - const authUser = await onSendCustomChallengeAnswer(code) - if (localCognitoUser?.challengeName === 'CUSTOM_CHALLENGE') { - setAuthUser(authUser) - setCode('') - return - } - - onSignInSuccess(authUser) - } catch (e) { - console.error('verificationError', e) - setError(e.message) - } - } else { - try { - if (challengeName === 'SMS_MFA' || challengeName === 'SOFTWARE_TOKEN_MFA') { - await onConfirmSignIn(code, challengeName) - // console.error('challengeReturned: ', localCognitoUser.challengeName) - // console.error('localCognitoUser: ', localCognitoUser) - if (!localCognitoUser.signInUserSession) { - setError(t('pages.auth-mfa-totp.invalid-code')) - } else { - onSignInSuccess(localCognitoUser) - } - } - } catch (e) { - console.error('verificationError', e) - setError(t(`pages.auth-mfa.errors.${e.code}`)) - } - } - - setLoading(false) - } - - let title = 'pages.auth-mfa.title' - if (challengeName === 'SOFTWARE_TOKEN_MFA') { - title = 'pages.auth-mfa-totp.title' - } - - if (!localCognitoUser) - return ( - - - Sorry, something went wrong, please try again. - - - ) - - return ( - -
- {error && ( - - {error} - - )} - - {challengeName === 'SMS_MFA' && ( - - {t('pages.auth-mfa.mfa-verification-sent', { - number: formatMaskedDestination(localCognitoUser.challengeParam?.CODE_DELIVERY_DESTINATION), - })} - - )} - {challengeName === 'SOFTWARE_TOKEN_MFA' && ( - {t('pages.auth-mfa.totp-mfa-verification')} - )} - {challengeName === 'CUSTOM_CHALLENGE' && ( - {t('CUSTOM CHALLENGE')} - // TODO: No translation key!!! - )} - - - setCode(e.currentTarget.value.toLowerCase().trim())} - required - value={code} - variant="filled" - /> - - - - - - - - - - {t('pages.auth-mfa.having-problems')} - - {t('pages.auth-mfa.no-device-access')} - - - -
- ) -} diff --git a/frontend/src/cognito/components/MFACode/index.ts b/frontend/src/cognito/components/MFACode/index.ts deleted file mode 100644 index f449b943e..000000000 --- a/frontend/src/cognito/components/MFACode/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MFACode } from './MFACode' diff --git a/frontend/src/cognito/components/PageHeading/PageHeading.tsx b/frontend/src/cognito/components/PageHeading/PageHeading.tsx deleted file mode 100644 index a8efb02e0..000000000 --- a/frontend/src/cognito/components/PageHeading/PageHeading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import { Typography } from '@mui/material' - -export type PageHeadingProps = { - children: React.ReactNode -} - -export function PageHeading({ children }: PageHeadingProps) { - return {children} -} diff --git a/frontend/src/cognito/components/PageHeading/index.ts b/frontend/src/cognito/components/PageHeading/index.ts deleted file mode 100644 index 9fccbf5cf..000000000 --- a/frontend/src/cognito/components/PageHeading/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PageHeading } from './PageHeading' diff --git a/frontend/src/cognito/components/PasswordStrengthInput/PasswordStrengthInput.css b/frontend/src/cognito/components/PasswordStrengthInput/PasswordStrengthInput.css deleted file mode 100644 index afde9fd06..000000000 --- a/frontend/src/cognito/components/PasswordStrengthInput/PasswordStrengthInput.css +++ /dev/null @@ -1,29 +0,0 @@ -@import '../../../styling.css'; -.special-chars { - white-space: normal !important; -} -.password-strength-meter { - width: 100% !important; -} -.password-strength-meter-progress::-webkit-progress-bar { - background-color: var(--color-gray); - border-radius: 3px; -} - -.password-strength-meter-progress::-webkit-progress-value { - border-radius: 2px; - background-size: 35px 20px, 100% 100%, 100% 100%; -} - -.strength-1::-webkit-progress-value { - background-color: var(--danger) !important; -} -.strength-2::-webkit-progress-value { - background-color: var(--warning) !important; -} -.strength-3::-webkit-progress-value { - background-color: var(--success) !important; -} -.strength-3::-webkit-progress-value { - background-color: var(--success) !important; -} diff --git a/frontend/src/cognito/components/PasswordStrengthInput/PasswordStrengthInput.tsx b/frontend/src/cognito/components/PasswordStrengthInput/PasswordStrengthInput.tsx deleted file mode 100644 index c200984cd..000000000 --- a/frontend/src/cognito/components/PasswordStrengthInput/PasswordStrengthInput.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import React, { useState } from 'react' -import { PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH } from '../../../constants' -import { useTranslation } from 'react-i18next' -import { Box, TextField, Typography } from '@mui/material' -import zxcvbn from 'zxcvbn' - - -export type Props = { - isNewPassword?: boolean - onChange: (password: string, isValid: boolean) => void -} - -export function PasswordStrengthInput({ isNewPassword, onChange }: Props): JSX.Element { - const { t } = useTranslation('cognito') - const [password, setPassword] = useState('') - const [passwordConfirmation, setPasswordConfirmation] = useState('') - const [valid, setValid] = useState(false) - const [tooShort, setTooShort] = useState(true) - const [tooLong, setTooLong] = useState(false) - // const [hasDigit, setHasDigit] = useState(true) - // const [hasLower, setHasLower] = useState(true) - // const [hasUpper, setHasUpper] = useState(true) - // const [hasSpecialChar, setHasSpecialChar] = useState(true) - const [hasMatch, setHasMatch] = useState(false) - - // const passwordSpecialChars = '^$*.[]{}()?-"!@#%&/,><\':;|_~' - - function checkTestedResult(pass: string): zxcvbn.ZXCVBNScore { - return zxcvbn(pass).score - } - - function checkTooShort(pass: string): boolean { - const value = (pass ? pass.length : 0) < PASSWORD_MIN_LENGTH - setTooShort(value) - return value - } - - function checkTooLong(pass: string): boolean { - const value = (pass ? pass.length : 0) > PASSWORD_MAX_LENGTH - setTooLong(value) - return value - } - - // function checkHasDigit(pass: string): boolean { - // const value = /(.*\d+.*)/g.test(pass) - // setHasDigit(value) - // return value - // } - - // function checkHasLower(pass: string): boolean { - // const value = /(.*[a-z]+.*)/g.test(pass) - // setHasLower(value) - // return value - // } - - // function checkHasUpper(pass: string): boolean { - // const value = /(.*[A-Z]+.*)/g.test(pass) - // setHasUpper(value) - // return value - // } - - // function checkHasSpecialChar(pass: string): boolean { - // const value = /(.*[$*.{}?"!@#%&/,><\':;|_~`^\]\[\)\(]+.*)/g.test(pass) - // setHasSpecialChar(value) - // return value - // } - - function checkMatches(pass: string, passConfirm: string): boolean { - const value = pass !== '' && passConfirm !== '' && pass === passConfirm - setHasMatch(value) - return value - } - - function checkPasswordConfirmation(e: React.SyntheticEvent): void { - const val = e.currentTarget.value - setPasswordConfirmation(val) - const isValid = checkValid(password) - const isMatching = checkMatches(password, val) - sendChange(password, isValid && isMatching) - } - - function handlePasswordChange(e: React.FormEvent): void { - const val = e.currentTarget.value - setPassword(val) - const isValid = checkValid(val) - const isMatching = checkMatches(val, passwordConfirmation) - sendChange(password, isValid && isMatching) - } - - function createPasswordStrengthLabel(score: number): string { - switch (score) { - case 0: - return t('pages.forgot-password-verify.password-strength-score.0') - case 1: - return t('pages.forgot-password-verify.password-strength-score.1') - case 2: - return t('pages.forgot-password-verify.password-strength-score.2') - case 3: - return t('pages.forgot-password-verify.password-strength-score.3') - case 4: - return t('pages.forgot-password-verify.password-strength-score.4') - default: - return t('pages.forgot-password-verify.password-strength-score.0') - } - } - - //Check full validation disabled for now - // function checkValid(pass: string): boolean { - // const isValid = - // !checkTooShort(pass) && - // !checkTooLong(pass) && - // checkHasDigit(pass) && - // checkHasLower(pass) && - // checkHasUpper(pass) && - // checkHasSpecialChar(pass) - // setValid(isValid) - // return isValid - // } - - function checkValid(pass: string): boolean { - const isValid = !checkTooShort(pass) && !checkTooLong(pass) - setValid(isValid) - return isValid - } - - function sendChange(pass: string, isValid: boolean): void { - onChange(pass, isValid) - } - - const error = password && !valid - const confirmError = passwordConfirmation != '' && !hasMatch - - return ( - <> - - - {tooShort && - t('pages.forgot-password-verify.password-error.too-short', { - min_length: PASSWORD_MIN_LENGTH, - })} - {tooLong && - t('pages.forgot-password-verify.password-error.too-long', { - max_length: PASSWORD_MAX_LENGTH, - })} - {/* {!hasDigit && ( - t( - 'pages.forgot-password-verify.password-error.missing-number' - )} - )} - {!hasLower && ( - t( - 'pages.forgot-password-verify.password-error.missing-lower' - )} - )} - {!hasUpper && ( - t( - 'pages.forgot-password-verify.password-error.missing-upper' - )} - )} - {!hasSpecialChar && ( - t( - 'pages.forgot-password-verify.password-error.missing-special-char' - )} - - ${passwordSpecialChars.spListItemt('').join(' ')} - - )} */} - - ) : ( - t('pages.forgot-password-verify.password-rules-reduced', { - min_length: PASSWORD_MIN_LENGTH, - max_length: PASSWORD_MAX_LENGTH, - }) - ) - } - /> - - - checkPasswordConfirmation(e)} - type="password" - variant="filled" - InputProps={{ disableUnderline: true }} - error={!!confirmError} - helperText={confirmError ? t('pages.forgot-password-verify.password-error.passwords-do-not-match') : ' '} - /> - - - - - - - - {/* - {passwordSpecialChars.split('').join(' ')} - */} - - ) -} - diff --git a/frontend/src/cognito/components/PasswordStrengthInput/index.ts b/frontend/src/cognito/components/PasswordStrengthInput/index.ts deleted file mode 100644 index 57f5abb33..000000000 --- a/frontend/src/cognito/components/PasswordStrengthInput/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PasswordStrengthInput } from './PasswordStrengthInput' diff --git a/frontend/src/cognito/components/PasswordVerify/PasswordVerify.tsx b/frontend/src/cognito/components/PasswordVerify/PasswordVerify.tsx deleted file mode 100644 index 611a4c0ef..000000000 --- a/frontend/src/cognito/components/PasswordVerify/PasswordVerify.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useState } from 'react' -import { Box, Button, TextField, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { useHistory, useLocation } from 'react-router-dom' -import { AuthLayout } from '../AuthLayout' -import { Notice } from '../../../components/Notice' -import { Icon } from '../../../components/Icon' -import { PasswordStrengthInput } from '../PasswordStrengthInput' - -export type PasswordVerifyProps = { - onVerifyPasswordChange: (email: string, password: string, shortcode: string) => Promise - email?: string - fullWidth?: boolean -} - -export function PasswordVerify({ onVerifyPasswordChange, email, fullWidth }: PasswordVerifyProps): JSX.Element { - const { t } = useTranslation('cognito') - const history = useHistory() - const location = useLocation() - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - const [shortcode, setShortcode] = useState('') - const [password, setPassword] = useState('') - const [isValidPassword, setIsValidPassword] = useState(false) - const [verificationRequestSent, setVerificationRequestSent] = useState(true) - - // Get the email from the URL bar - const { search } = location - const localEmail = email ? email : '' - const resetPasswordNeeded = search.includes('resetRequired=true') - - function handlePasswordValidation(password: string, validation: boolean): void { - setPassword(password) - setIsValidPassword(validation) - } - - async function handleVerifyPasswordChange(e: React.FormEvent): Promise { - e.preventDefault() - - setError(null) - setLoading(true) - setVerificationRequestSent(false) - - try { - await onVerifyPasswordChange(localEmail.trim(), password.trim(), shortcode.trim()) - history.push(`/sign-in`, { - alert: { - type: 'success', - message: t('pages.forgot-password-verify.success-message'), - }, - }) - return - } catch (e) { - console.error(e) - if (e.code) setError(t(`pages.auth-mfa.errors.${e.code}`)) - else setError(e.message) - } - - setLoading(false) - } - - return ( - -
- {verificationRequestSent && resetPasswordNeeded && ( - - {t('pages.password-reset.cognito-reset-password-required', { - email, - })} - - )} - {verificationRequestSent && !resetPasswordNeeded && ( - <> - - {t('pages.password-reset.verification-code-message', { - email, - })} - - {t('pages.password-reset.signout-all-text')} - - )} - {error && ( - - {error} - - )} - - setShortcode(e.currentTarget?.value.trim())} - InputProps={{ disableUnderline: true }} - // maxLength={6} - required - value={shortcode} - variant="filled" - /> - - - - - - - -
-
- ) -} diff --git a/frontend/src/cognito/components/PasswordVerify/index.ts b/frontend/src/cognito/components/PasswordVerify/index.ts deleted file mode 100644 index a80134c69..000000000 --- a/frontend/src/cognito/components/PasswordVerify/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PasswordVerify } from './PasswordVerify' diff --git a/frontend/src/cognito/components/SignIn/SignIn.tsx b/frontend/src/cognito/components/SignIn/SignIn.tsx deleted file mode 100644 index 3a52f902c..000000000 --- a/frontend/src/cognito/components/SignIn/SignIn.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import React from 'react' -import { rememberMe, sanitizeEmail } from '../../../helpers/userHelper' -import { - Box, - Button, - TextField, - Typography, - Divider, - Checkbox, - Grid, - FormControlLabel, - InputAdornment, - Collapse, -} from '@mui/material' -import { SignInFunc, SamlSignInFunc, UsernameChangeFunc, CheckSamlFunc, FederatedSignInFunc } from '../../types' -import { useTranslation } from 'react-i18next' -import { Notice } from '../../../components/Notice' -import { Icon } from '../../../components/Icon' -import { AuthLayout } from '../AuthLayout' -import { GoogleSignInButton } from '../GoogleSignInButton' -import { AppleSignInButton } from '../AppleSignInButton' -import { Link } from '../../../components/Link' -import { spacing } from '../../../styling' -import { useHistory } from 'react-router-dom' -import { IconButton } from '../../../buttons/IconButton' - - -export type SignInProps = { - email?: string - onCheckSaml: CheckSamlFunc - onUsernameChange?: UsernameChangeFunc - onGoogleSignIn: FederatedSignInFunc - onAppleSignIn: FederatedSignInFunc - onOktaSignIn: FederatedSignInFunc - onSignIn: SignInFunc - onSamlSignIn: SamlSignInFunc - showLogo?: boolean - errorMessage?: string - fullWidth?: boolean -} - -export function SignIn({ - email, - onCheckSaml, - onUsernameChange, - onGoogleSignIn, - onAppleSignIn, - onSignIn, - onSamlSignIn, - showLogo = true, - errorMessage = undefined, - fullWidth, -}: SignInProps): JSX.Element { - let externalError: Error | null = null - if (errorMessage) { - externalError = new Error(errorMessage) - } - const { t } = useTranslation('cognito') - const history = useHistory() - const [username, setUsername] = React.useState(email || rememberMe.username) - const [password, setPassword] = React.useState('') - const [error, setError] = React.useState(externalError) - const [loading, setLoading] = React.useState(false) - const [showPassword, setShowPassword] = React.useState(false) - const [emailProcessed, setEmailProcessed] = React.useState(rememberMe.emailProcessed) - const [remember, setRemember] = React.useState(rememberMe.checked) - const passRef = React.useRef() - - React.useEffect(() => { - setError(errorMessage ? new Error(errorMessage) : null) - if (errorMessage) setLoading(false) - }, [errorMessage]) - - function scrollIntoView(event: React.FocusEvent) { - event.target.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - - function scrollReset(event: React.FocusEvent) { - event.target.scrollIntoView({ behavior: 'smooth', block: 'end' }) - } - - function getError(error: unknown): Error { - return error instanceof Error ? error : new Error('Sign in failed, please try again.') - } - - function handleEnterKey(e: React.KeyboardEvent): void { - if (e.key !== 'Enter' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return - - const target = e.target as HTMLElement - if (target.tagName !== 'INPUT') return - - e.preventDefault() - e.currentTarget.requestSubmit() - } - - async function handleFederatedSignIn(signIn: FederatedSignInFunc): Promise { - if (loading) return - - setError(null) - setLoading(true) - - try { - await signIn() - } catch (error) { - console.error(error) - setError(getError(error)) - } finally { - setLoading(false) - } - } - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault() - if (loading) return - - setError(null) - - if (!username || (!password && emailProcessed)) return alert('Please enter a username and password') - - setLoading(true) - if (remember) rememberMe.set({ username, emailProcessed }) - - try { - if (emailProcessed) { - // await onSignIn(username, password) - const challenge = await onSignIn(username, password) - - if (onUsernameChange) onUsernameChange(username) - - if (challenge) { - // They need to recover their account. - if (challenge === 'CUSTOM_CHALLENGE') { - history.push('/account-recovery') - return - } - - // MFA verification is required, send them to verify. - if (challenge === 'SMS_MFA' || challenge === 'SOFTWARE_TOKEN_MFA') { - history.push('/mfa-verify') - return - } - - // TODO: handle other challenge options - } - } else { - if (username) { - const result = await onCheckSaml(username) - if (result.isSaml && result.orgName) { - //Its an org! - await onSamlSignIn(result.orgName) - } else { - setEmailProcessed(true) - } - } - } - } catch (error) { - console.error(error) - setError(getError(error)) - } finally { - setLoading(false) - } - } - - return ( - - - handleFederatedSignIn(onGoogleSignIn)} - disabled={loading} - /> - handleFederatedSignIn(onAppleSignIn)} - disabled={loading} - /> - - - - {t('pages.sign-in.or')?.toLowerCase()} - - -
- {error?.message && ( - - {error.message} - - )} - - ) => { - setUsername(sanitizeEmail(e?.currentTarget?.value)) - }} - onFocus={scrollIntoView} - onBlur={scrollReset} - value={username} - variant="filled" - type="email" - /> - - passRef.current?.focus()}> - - ) => { - setPassword(e?.currentTarget?.value) - }} - type={showPassword ? 'text' : 'password'} - variant="filled" - InputProps={{ - endAdornment: ( - spacing(1) }}> - setShowPassword(!showPassword)} - icon={showPassword ? 'eye' : 'eye-slash'} - /> - - ), - }} - /> - - - - - - - - { - setRemember(!remember) - rememberMe.toggle({ username, emailProcessed }) - }} - checkedIcon={} - icon={} - color="primary" - /> - } - label={Remember me} - /> - - - - - - {t('pages.sign-in.forgot-password')} - - - - - -
- - - {t('pages.sign-in.create-account')} - {t('pages.sign-in.sign-up-link')} - - - {!emailProcessed && ( - - - { - setEmailProcessed(true) - }} - > - {t('pages.sign-in.user-password-link')} - - - - )} - {emailProcessed && ( - - - { - setEmailProcessed(false) - }} - > - {t('pages.sign-in.saml-link')} - - - - )} -
- ) -} diff --git a/frontend/src/cognito/components/SignIn/index.ts b/frontend/src/cognito/components/SignIn/index.ts deleted file mode 100644 index a34f58cb6..000000000 --- a/frontend/src/cognito/components/SignIn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SignIn } from './SignIn' diff --git a/frontend/src/cognito/components/SignInMessage/SignInMessage.tsx b/frontend/src/cognito/components/SignInMessage/SignInMessage.tsx deleted file mode 100644 index 1dc1c1f71..000000000 --- a/frontend/src/cognito/components/SignInMessage/SignInMessage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { LoadingMessage } from '../../../components/LoadingMessage' - -export function SignInMessage(): JSX.Element { - const { t } = useTranslation('cognito') - return -} diff --git a/frontend/src/cognito/components/SignInMessage/index.ts b/frontend/src/cognito/components/SignInMessage/index.ts deleted file mode 100644 index de6307895..000000000 --- a/frontend/src/cognito/components/SignInMessage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SignInMessage } from './SignInMessage' diff --git a/frontend/src/cognito/components/SignUp/SignUp.tsx b/frontend/src/cognito/components/SignUp/SignUp.tsx deleted file mode 100644 index c25461375..000000000 --- a/frontend/src/cognito/components/SignUp/SignUp.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React, { useState } from 'react' -import { Box, Button, Checkbox, TextField, Typography, FormControlLabel } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { useHistory, useParams } from 'react-router-dom' -import { SignUpFunc, ResendFunc } from '../../types' -import { AuthLayout } from '../AuthLayout' -import { Captcha } from '../Captcha' -import { Notice } from '../../../components/Notice' -import { Icon } from '../../../components/Icon' -import { Link } from '../../../components/Link' -import { PasswordStrengthInput } from '../PasswordStrengthInput' - -export type SignUpProps = { - onSignUp: SignUpFunc - onResend: ResendFunc - hideCaptcha?: boolean - fullWidth?: boolean -} - -export function SignUp({ onSignUp, onResend, hideCaptcha, fullWidth }: SignUpProps): JSX.Element { - const { t } = useTranslation('cognito') - const history = useHistory() - const params = useParams<{ email?: string }>() - const [error, setError] = useState(null) - const [showResend, setShowResend] = useState(false) - const [loading, setLoading] = useState(false) - const [terms, setTerms] = useState(false) - const [password, setPassword] = React.useState('') - const [verified, setVerified] = useState(hideCaptcha || false) - const [isValidPassword, setIsValidPassword] = useState(false) - const [email, setEmail] = useState(decodeURIComponent(params?.email || '')) - - async function resend() { - onResend(email) - } - - async function forgotPassword() { - history.push(`/forgot-password`) - } - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault() - setError(null) - setLoading(true) - - await onSignUp(email, password) - .then(() => history.push(`/sign-up/verify`)) - .catch((err: Error) => { - if (err.name == 'UsernameExistsException') { - setShowResend(true) - } - setError(err) - }) - - setLoading(false) - } - - // Grab possible alerts to show - let alert - const { state }: any = window.location - if (state && state.alert) alert = state.alert - - function setPasswordValidation(password: string, isValidPassword: boolean): void { - setPassword(password.trim()) - setIsValidPassword(isValidPassword) - } - - return ( - - {alert && ( - - {alert.message} - - )} -
- {error && ( - - {error.message} - - )} - {showResend && ( - <> - - If you have not yet confirmed your account choose resend verification. If your account is confirmed but - you don't know your password you can go to forgot password and set a new one. - - - - - - - )} - - setEmail(e.currentTarget.value.toLowerCase().trim())} - inputProps={{ maxLength: 254 }} - placeholder="Email address..." - required - type="email" - variant="filled" - /> - - - setPasswordValidation(password, isValidPassword)} - /> - - {!hideCaptcha && ( - - setVerified(true)} /> - - )} - - setTerms(e.target.checked)} - checkedIcon={} - icon={} - color="primary" - /> - } - label={ - - I agree to the Remote.It - Terms of Use, - Privacy Policy - and - Fair Use Policy. - - } - /> - - - - - {/* - - Already have an account? Sign in - - */} -
-
- ) -} - diff --git a/frontend/src/cognito/components/SignUp/index.ts b/frontend/src/cognito/components/SignUp/index.ts deleted file mode 100644 index a9e29fc13..000000000 --- a/frontend/src/cognito/components/SignUp/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SignUp } from './SignUp' diff --git a/frontend/src/cognito/components/SignUpVerify/SignUpVerify.tsx b/frontend/src/cognito/components/SignUpVerify/SignUpVerify.tsx deleted file mode 100644 index 9cdd6d421..000000000 --- a/frontend/src/cognito/components/SignUpVerify/SignUpVerify.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react' -import { Dispatch } from '../../../store' -import { useDispatch } from 'react-redux' -import { Box, Button, Typography } from '@mui/material' -import { useTranslation } from 'react-i18next' -import { AuthLayout } from '../AuthLayout' -import { Link } from '../../../components/Link' -import { Icon } from '../../../components/Icon' -import { ResendFunc } from '../../types' -export type SignUpVerifyProps = { - email: string - onResend: ResendFunc - fullWidth?: boolean -} - -export function SignUpVerify({ email, onResend, fullWidth }: SignUpVerifyProps): JSX.Element { - const [sending, setSending] = React.useState(false) - const dispatch = useDispatch() - const { t } = useTranslation('cognito') - - return ( - - - - {t('pages.forgot-password-verify.verification-link-message')} - - - - {email} - - - - - - - {t('pages.verify-account.verification-received-message')} -
- {t('pages.verify-account.check-spam')} - { - setSending(true) - await onResend(email) - dispatch.ui.set({ noticeMessage: t('pages.verify-account.resent-notice') || '' }) - setSending(false) - }} - > - {sending ? t('pages.verify-account.sending') : t('pages.verify-account.resend-confirmation')} - -
-
-
- ) -} diff --git a/frontend/src/cognito/components/SignUpVerify/index.ts b/frontend/src/cognito/components/SignUpVerify/index.ts deleted file mode 100644 index 88e15f597..000000000 --- a/frontend/src/cognito/components/SignUpVerify/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SignUpVerify } from './SignUpVerify' diff --git a/frontend/src/cognito/components/SplashScreen/SplashScreen.tsx b/frontend/src/cognito/components/SplashScreen/SplashScreen.tsx deleted file mode 100644 index eeca15552..000000000 --- a/frontend/src/cognito/components/SplashScreen/SplashScreen.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' -import { Box } from '@mui/material' -import { CoBrandingLogo } from '../CoBrandingLogo' -import { spacing } from '../../../styling' -import { Logo } from '@common/brand/Logo' - -export function SplashScreen(): JSX.Element { - return ( - <> - - - - - - ) -} diff --git a/frontend/src/cognito/components/SplashScreen/index.ts b/frontend/src/cognito/components/SplashScreen/index.ts deleted file mode 100644 index d4728e7a5..000000000 --- a/frontend/src/cognito/components/SplashScreen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SplashScreen } from './SplashScreen' diff --git a/frontend/src/cognito/components/Wrapper/Wrapper.tsx b/frontend/src/cognito/components/Wrapper/Wrapper.tsx deleted file mode 100644 index 1644904ba..000000000 --- a/frontend/src/cognito/components/Wrapper/Wrapper.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react' -import i18n from '../../i18n' -import { I18nextProvider } from 'react-i18next' - -export type WrapperProps = { - children: React.ReactNode -} - -export function Wrapper({ children }: WrapperProps): JSX.Element { - return {children} -} diff --git a/frontend/src/cognito/components/Wrapper/index.ts b/frontend/src/cognito/components/Wrapper/index.ts deleted file mode 100644 index 7fec56b1b..000000000 --- a/frontend/src/cognito/components/Wrapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Wrapper } from './Wrapper' diff --git a/frontend/src/cognito/i18n.ts b/frontend/src/cognito/i18n.ts deleted file mode 100644 index 71ae166e2..000000000 --- a/frontend/src/cognito/i18n.ts +++ /dev/null @@ -1,6 +0,0 @@ -// The auth (cognito) screens now share the app-wide i18next instance. Their -// strings live in the `cognito` namespace (frontend/src/i18n/locales/*/cognito.json). -// Kept as a re-export so existing imports (e.g. Wrapper.tsx) keep working. -import i18n from '../i18n' - -export default i18n diff --git a/frontend/src/cognito/types.d.ts b/frontend/src/cognito/types.d.ts deleted file mode 100644 index a74d1fbf1..000000000 --- a/frontend/src/cognito/types.d.ts +++ /dev/null @@ -1,102 +0,0 @@ -import Auth from '@aws-amplify/auth' - -// eslint-disable-next-line import/named -export { CodeDeliveryDetails, ISignUpResult } from 'amazon-cognito-identity-js' - -// Export Cognito base types -export interface CognitoUser /*extends CognitoCognitoUser*/ { - authProvider?: AuthProvider - id?: string - attributes?: CognitoUserAttributes - challengeName?: ChallengeOption - challengeParam?: { - challengeType: RecoveryChallengeType - CODE_DELIVERY_DESTINATION?: string - } - preferredMFA: MFAMethod - username: string - signInUserSession?: unknown -} - -export type RecoveryChallengeType = 'BACKUP_CODE' | 'EMAIL_CODE' - -export type CognitoAuthInstance = typeof Auth - -export interface CognitoUserResult { - cognitoUser?: CognitoUser - error?: Error -} - -export interface SamlOrgResult { - isSaml: boolean - orgName?: string -} - -export type AuthProvider = 'Apple' | 'Google' | '' - -export interface CognitoUserAttributes { - 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 type MFAMethod = 'SMS' | 'TOTP' | 'NOMFA' - -export type AuthErrorCode = 'MFA_REQUIRED' | 'CUSTOM_CHALLENGE' - -export type TOTPChallenge = 'SOFTWARE_TOKEN_MFA' - -export type SMSChallenge = 'SMS_MFA' - -export type ChallengeOption = - | TOTPChallenge - | 'CUSTOM_CHALLENGE' - | SMSChallenge - | 'NEW_PASSWORD_REQUIRED' - | 'MFA_SETUP' - | 'SELECT_MFA_TYPE' - -export type SignInFunc = (username: string, password?: string) => Promise - -export type FederatedSignInFunc = () => void | Promise - -export type SamlSignInFunc = (domain: string) => void | Promise - -export type UsernameChangeFunc = (email: string) => Promise - -export type SignInSuccessFunc = (user: CognitoUser) => void - -export type CheckSamlFunc = (username: string) => Promise -export interface SignInError extends Error { - code?: AuthErrorCode - user?: { - challengeName?: ChallengeOption - } -} - -export type SignUpFunc = (username: string, password: string) => Promise - -export type ResendFunc = (username: string) => Promise - -export type ConfirmSignInFunc = (code: string, challengeName?: ChallengeOption) => Promise - -export type SendCustomChallengeAnswerFunc = (code: string) => Promise - -export type VerifyRecoveryCodeFunc = (emailVerificationCode: string, recoveryCode: string) => Promise - -export type VerifyPasswordChangeFunc = (email: string, password: string, shortcode: string) => Promise - -export type RecoverPasswordRequestFunc = (email: string) => Promise - -export type AvailableLanguage = 'en' | 'ja' - -export type FontSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl' - -export type IconWeight = 'light' | 'regular' | 'solid' - -export type Currency = 'USD' diff --git a/frontend/src/cognito/utils/delay.ts b/frontend/src/cognito/utils/delay.ts deleted file mode 100644 index 0f01a4205..000000000 --- a/frontend/src/cognito/utils/delay.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} diff --git a/frontend/src/cognito/utils/isEmpty.ts b/frontend/src/cognito/utils/isEmpty.ts deleted file mode 100644 index 248890ba5..000000000 --- a/frontend/src/cognito/utils/isEmpty.ts +++ /dev/null @@ -1,9 +0,0 @@ -export default function isEmpty(obj?: { [key: string]: any }): boolean { - if (!obj) return true - - for (const key in obj) { - if (obj.hasOwnProperty(key)) return false - } - - return true -} diff --git a/frontend/src/components/AddDevice.tsx b/frontend/src/components/AddDevice.tsx index 8d27ec197..767facfc7 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,7 @@ export const AddDevice: React.FC = ({ platform, tags, serviceTypes, redir } code={registrationCode} link={redirectUrl} - label={platform.installation?.label} + label={codeOnly ? t('addDevice.registrationCode', 'Registration Code') : undefined} sx={{ textAlign: 'left' }} /> @@ -53,23 +56,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/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index 2efe4554b..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() @@ -99,6 +100,17 @@ export const AdminSidebarNav: React.FC = () => { + handleNavClick(ADMIN_ADDONS_ROUTE)} + > + + + + + + import('./Chat/ChatPanel').then(m => ({ default: m.ChatPanel }))) +const ChatWindow = React.lazy(() => import('./Chat/ChatWindow').then(m => ({ default: m.ChatWindow }))) export const App: React.FC = () => { // Subscribe the whole app to i18next language changes and lazy-locale loads, so // 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,13 +47,17 @@ 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 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() - 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) + // 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 { hideSidebar, singlePanel, triplePanel, mobile } = useLayoutBreakpoints() const isRootMenu = location.pathname.match(REGEX_FIRST_PATH)?.[0] === location.pathname const showBottomMenu = (mobile || browser.isMobile) && isRootMenu && hideSidebar const needsUserHydration = authenticated && !user @@ -69,10 +71,11 @@ export const App: React.FC = () => { showBottomMenu, singlePanel, triplePanel, - sidePanelWidth, + sidePanelWidth: sidebarWidth, } - 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) useEffect(() => { hideSplashScreen() @@ -80,7 +83,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 ( @@ -119,21 +122,72 @@ export const App: React.FC = () => { }> - - {hideSidebar ? : } - - - {showBottomMenu && } + {/* 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 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 && ( + + + + )} + + )} diff --git a/frontend/src/components/AvatarMenu.tsx b/frontend/src/components/AvatarMenu.tsx index 83bc73698..b69cb362f 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, Avatar as MuiAvatar } from '@mui/material' +import { useHideSidebar } from '../hooks/useChatEnabled' import { useSelector, useDispatch } from 'react-redux' import { selectLicenseIndicator } from '../models/plans' import { ListItemLocation } from './ListItemLocation' @@ -13,6 +13,7 @@ import { ListItemLink } from './ListItemLink' import { isRemoteUI } from '../helpers/uiHelper' import { DesktopUI } from './DesktopUI' import { Avatar } from './Avatar' +import { oidcAccounts, oidcRefreshBrowserAccounts } from '../services/oidc' import { emit } from '../services/Controller' const ENTER_DELAY = 300 @@ -24,13 +25,15 @@ 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() 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 || '')) @@ -39,7 +42,10 @@ export const AvatarMenu: React.FC = () => { const activeUser = useSelector(selectActiveUser) const userAdmin = useSelector((state: State) => state.auth.user?.admin || false) + // handleOpen is reached from the hover timer, the click and every re-entry of the menu paper; + // the browser's accounts are asked for once per opening, not per pointer crossing. const handleOpen = () => { + if (!open) void oidcRefreshBrowserAccounts().finally(() => setAccounts(oidcAccounts())) window.addEventListener('keydown', checkAltMenu) setOpen(true) } @@ -178,6 +184,41 @@ 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. */} + {accounts + .filter(a => !a.active) + .map(a => ( + + {(a.name || a.email || '?').charAt(0).toUpperCase()} + + } + onClick={async () => { + handleClose() + await dispatch.auth.activateAccount(a.sub) + }} + /> + ))} + { + handleClose() + await dispatch.auth.switchAccount() + }} + /> { + 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})` +} + +/* 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) + useResizeMeasure(ref, element => + setGutter(Math.max(element.offsetWidth - element.clientWidth, element.offsetHeight - element.clientHeight)) + ) + return gutter +} export type BodyProps = { inset?: boolean @@ -14,6 +47,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,105 +66,84 @@ export const Body: React.FC = ({ gutterBottom, gutterTop, verticalOverflow, + fadeTop, horizontalOverflow, 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' + const scrollRef = React.useRef(null) + const gutter = useScrollbarGutter(scrollRef) + + // 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), + ].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', - })} - /> - )} - {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', - })} - /> - )} - setHover(true)} - onMouseLeave={() => setHover(false)} - sx={[ - theme => ({ - flexGrow: 1, - height: '100%', - overflow: verticalOverflow && horizontalOverflow ? 'scroll' : 'auto', - 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', - }, - // 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` } : {}, - hover - ? (theme: Theme) => ({ - '&::-webkit-scrollbar-thumb': { - backgroundColor: `${theme.palette.grayLight.main} !important`, - }, - }) - : {}, - ...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/ChangePassword/ChangePassword.tsx b/frontend/src/components/ChangePassword/ChangePassword.tsx index 2391f80ca..e9e50b866 100644 --- a/frontend/src/components/ChangePassword/ChangePassword.tsx +++ b/frontend/src/components/ChangePassword/ChangePassword.tsx @@ -3,10 +3,11 @@ import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' import { PasswordStrengthInput } from './PasswordStrengthInput' import { Button, TextField, Typography } from '@mui/material' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { ConfirmButton } from '../../buttons/ConfirmButton' -import { Dispatch } from '../../store' +import { Dispatch, State } from '../../store' import { Gutters } from '../Gutters' +import { CodeStep } from '../MFA/steps' export const ChangePassword = () => { const { t } = useTranslation() @@ -16,22 +17,62 @@ export const ChangePassword = () => { const [saving, setSaving] = useState(false) const [key, setKey] = useState(0) const { auth } = useDispatch() + const passwordChallenge = useSelector((state: State) => state.auth.passwordChallenge) + const [code, setCode] = useState('') const history = useHistory() const evaluateCurrentPassword = (e: { target: { value: React.SetStateAction } }) => { setCurrentPassword(e.target.value.toString()) } + const reset = () => { + setCurrentPassword('') + setPassword('') + setCode('') + setValid(false) + setKey(k => k + 1) + } const updatePassword = async () => { setSaving(true) const success = await auth.changePassword({ currentPassword, password }) setSaving(false) - if (success) { - setCurrentPassword('') - setPassword('') - setValid(false) - setKey(k => k + 1) - } + if (success) reset() + } + const verifyCode = async () => { + setSaving(true) + const success = await auth.completePasswordChallenge(code) + setSaving(false) + if (success) reset() + else setCode('') } + + // The credential store challenged (pool MFA): the change is staged server-side and + // completes with the authenticator code — same proof the console relays. + if (passwordChallenge) + return ( + <> + + {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.' + )} + + } + code={code} + onCode={setCode} + busy={saving} + onSubmit={verifyCode} + onCancel={() => auth.set({ passwordChallenge: undefined })} + /> + + ) + return ( <> @@ -66,7 +107,8 @@ export const ChangePassword = () => { children: ( <> - {t('changePassword.noticeBefore', 'Changing your password will')} {t('changePassword.noticeEmphasis', 'NOT')}{' '} + {t('changePassword.noticeBefore', 'Changing your password will')}{' '} + {t('changePassword.noticeEmphasis', 'NOT')}{' '} {t('changePassword.noticeAfter', 'automatically sign you out of other sessions.')} diff --git a/frontend/src/components/Chat/ChatApproval.tsx b/frontend/src/components/Chat/ChatApproval.tsx new file mode 100644 index 000000000..90a64304c --- /dev/null +++ b/frontend/src/components/Chat/ChatApproval.tsx @@ -0,0 +1,42 @@ +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 + 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 }) => { + const { t } = useTranslation() + return ( + + + }} + /> + + + {JSON.stringify(input, null, 2)} + + + + + + + ) +} diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx new file mode 100644 index 000000000..4d2bb07a1 --- /dev/null +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -0,0 +1,139 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import { Button, Typography } from '@mui/material' +import { State, Dispatch } from '../../store' +import { ChatMessages } from './ChatMessages' +import { ChatApproval } from './ChatApproval' +import { ChatInput } from './ChatInput' +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' +import { oidcLeaveRefused } from '../../services/oidc' +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 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) + const error = useSelector((state: State) => state.chat.error) + 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. + // 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. 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 ( + <> + {unreachable && !!messages.length && ( + + {unavailableMessage} + + )} + {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.')}`} + + } + > + {!oidcLeaveRefused() && ( + + )} + + ) : unreachable && !messages.length ? ( + + ) : !messages.length && !pendingConfirmation && !error ? ( + + ) : ( + + {pendingConfirmation && ( + dispatch.chat.confirm(approved)} + /> + )} + {error && ( + dispatch.chat.set({ error: null })}> + {error} + + )} + + )} + {/* 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.' + )} + + + } + > + dispatch.chat.send(text)} + onStop={() => dispatch.chat.stop()} + /> + + + ) +} diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx new file mode 100644 index 000000000..424ed8d99 --- /dev/null +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -0,0 +1,252 @@ +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 { Dispatch, State } from '../../store' +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' +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 + 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<{ children?: React.ReactNode }> = ({ children }) => { + const { t } = useTranslation() + return ( + + + {/* 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} + + ) +} + +export const NewChatButton: React.FC = () => { + const { t } = useTranslation() + const dispatch = useDispatch() + return ( + dispatch.chat.newConversation()} /> + ) +} + +/* 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) + + /* 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) + /* 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 + setAnchorEl(e.currentTarget) + } + 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. */ + useResizeMeasure(labelRef, element => setCropped(element.scrollWidth > element.clientWidth + 1), [label]) + + const fade = 'linear-gradient(90deg, #000 calc(100% - 20px), transparent)' + + return ( + <> + + + {label} + + + + + + + + {t('chat.history', 'History')} + + {items.length === 0 && ( + + + + )} + {items.map(c => ( + { + dispatch.chat.openConversation(c.id) + close() + }} + > + + {/* stopPropagation so the row's own onClick does not load the very + conversation we are about to delete. */} + { + e.stopPropagation() + setDeleting(c) + close() + }} + > + + + + ))} + + {/* 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/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx new file mode 100644 index 000000000..61561e68e --- /dev/null +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Box, InputBase } from '@mui/material' +import { fontSizes, radius } from '../../styling' +import { IconButton } from '../../buttons/IconButton' +import { ChatUsage } from './ChatUsage' + +type Props = { + disabled: boolean + placeholder?: string + streaming: boolean + onSend: (text: string) => void + onStop: () => void +} + +export const ChatInput: React.FC = ({ disabled, placeholder, streaming, onSend, onStop }) => { + const { t } = useTranslation() + 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 => { + // isComposing: Enter is confirming an IME candidate (ja/zh/ko), + // not submitting — sending here would post half-composed text + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault() + submit() + } + }} + /> + {streaming ? ( + + ) : ( + + )} + + + + ) +} diff --git a/frontend/src/components/Chat/ChatIntro.tsx b/frontend/src/components/Chat/ChatIntro.tsx new file mode 100644 index 000000000..43942b666 --- /dev/null +++ b/frontend/src/components/Chat/ChatIntro.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { Box, Chip, Typography } from '@mui/material' +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, + 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() + + /* 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', 'Help me add a new device'), + ] + + return ( + + + + + {t('chat.introTitle', 'Remote.It AI')} + + + {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, + rather than full-width buttons competing with the composer below. */} + + {prompts.map(prompt => ( + dispatch.chat.send(prompt)} + sx={{ + bgcolor: 'white.main', + color: 'grayDarker.main', + maxWidth: '100%', + '&:hover': { bgcolor: 'primaryLighter.main' }, + }} + /> + ))} + + + ) +} diff --git a/frontend/src/components/Chat/ChatMark.tsx b/frontend/src/components/Chat/ChatMark.tsx new file mode 100644 index 000000000..6f9986cd8 --- /dev/null +++ b/frontend/src/components/Chat/ChatMark.tsx @@ -0,0 +1,38 @@ +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 new file mode 100644 index 000000000..e7b6d4523 --- /dev/null +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -0,0 +1,149 @@ +import React from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { useTranslation } from 'react-i18next' +import { Box, Typography } from '@mui/material' +import { fontSizes, radius, scrollbarStyles, SCROLLBAR_WIDTH_NARROW } from '../../styling' +import { ChatTranscriptMessage } from '../../models/chat' +import { ChatToolCalls } from './ChatToolCalls' + +// 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) => , +} + +type Props = { + message: ChatTranscriptMessage +} + +// 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 }) => { + const { t } = useTranslation() + if (message.role === 'user') + return ( + + + + {message.text} + + + + ) + + return ( + + + {!!message.text && ( + ({ + // 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. 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: 'grayLightest', width: SCROLLBAR_WIDTH_NARROW }), + }), + { + // 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. + paddingX: 1.5, + paddingY: 1, + fontSize: fontSizes.base, + 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. + color: 'grayDarker.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: fontSizes.md, + fontWeight: 'medium', + color: 'grayDarkest.main', + marginTop: 1.5, + marginBottom: 0.5, + }, + '& a': { color: 'primary.main' }, + '& code': { + fontFamily: "'Roboto Mono', monospace", + fontSize: fontSizes.sm, + bgcolor: 'grayLighter.main', + borderRadius: `${radius.sm}px`, + paddingX: 0.5, + paddingY: 0.25, + }, + '& 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' }, + }, + '& table': { + display: 'block', + overflowX: 'auto', + borderCollapse: 'collapse', + fontSize: fontSizes.sm, + marginY: 2, + borderRadius: `${radius.sm}px`, + }, + // 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: 'white.main', + border: '1px solid', + borderColor: 'grayLightest.main', + paddingX: 1.5, + paddingY: 0.5, + textAlign: 'left', + whiteSpace: 'nowrap', + '& code': { + bgcolor: 'grayLighter.main', + }, + }, + '& blockquote': { + borderLeft: '3px solid', + borderColor: 'grayLighter.main', + marginX: 0, + paddingLeft: 1.5, + color: 'grayDark.main', + }, + }, + ]} + > + + {message.text} + + + )} + {message.interrupted && ( + + {t('chat.interrupted', 'Interrupted')} + + )} + + ) +}) + +ChatMessageItem.displayName = 'ChatMessageItem' diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx new file mode 100644 index 000000000..e8ee50665 --- /dev/null +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useRef, useState } from 'react' +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 = { + messages: ChatTranscriptMessage[] + streaming: boolean + typing?: boolean + children?: React.ReactNode +} + +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, 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 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 ( + + + {/* 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/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx new file mode 100644 index 000000000..1d9fd1b4d --- /dev/null +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -0,0 +1,156 @@ +import React, { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +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, layoutBreakpoints, chatMaxWidth } from '../../hooks/useChatEnabled' +import { getViewportWidth } from '../../hooks/useViewportWidth' +import { useChatMainSync } from '../../hooks/useChatSync' +import { usePanelDrag } from '../../hooks/usePanelDrag' +import { PanelHandle } from '../PanelHandle' +import { IconButton } from '../../buttons/IconButton' +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 + 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 = () => { + const { t } = useTranslation() + const open = useSelector((state: State) => state.chat.open) + 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. + const turnActive = useSelector(selectTurnActive) + const docked = useChatDocked() + const chatWidth = useChatWidth() + const sidebarWidth = layout.sidePanelWidth + const dispatch = useDispatch() + + useChatMainSync() + + // Drag-to-resize, same mechanism as the content panels — anchored right, so + // 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(() => chatMaxWidth(getViewportWidth()), []) + + /* 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) => { + const viewport = getViewportWidth() + if (layoutBreakpoints(viewport - width) === layoutBreakpoints(viewport - published.current)) return + setWidth(width) + }, + [setWidth] + ) + const drag = usePanelDrag(chatWidth, { + minWidth: CHAT_PANEL_WIDTH_MIN, + getMaxWidth, + onChange: publishIfLayoutChanges, + onPersist: setWidth, + layoutDep: layout, + anchor: 'right', + }) + if (!open) return null + + return ( + + ) +} diff --git a/frontend/src/components/Chat/ChatToolCalls.tsx b/frontend/src/components/Chat/ChatToolCalls.tsx new file mode 100644 index 000000000..cf9803175 --- /dev/null +++ b/frontend/src/components/Chat/ChatToolCalls.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Box, ButtonBase, Collapse, Typography, CircularProgress } from '@mui/material' +import { ChatToolCall } from '../../models/chat' +import { Icon } from '../Icon' +import { radius } from '../../styling' + +export const ChatToolCalls: React.FC<{ toolCalls: ChatToolCall[] }> = ({ toolCalls }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + if (!toolCalls.length) return null + const running = toolCalls.some(c => c.status === 'running') + return ( + + setOpen(!open)} sx={{ borderRadius: `${radius.sm}px`, paddingX: 0.5, color: 'grayDark.main' }}> + {running ? ( + + ) : ( + + )} + + {t('chat.toolsUsed', { + count: toolCalls.length, + defaultValue_one: 'Used {{count}} tool', + defaultValue_other: 'Used {{count}} tools', + })} + + + + {toolCalls.map(call => ( + + + {call.name} + {call.status === 'running' ? ' …' : ''} + + {call.result && ( + + {call.result.slice(0, 200)} + + )} + + ))} + + + ) +} diff --git a/frontend/src/components/Chat/ChatUsage.tsx b/frontend/src/components/Chat/ChatUsage.tsx new file mode 100644 index 000000000..d12634025 --- /dev/null +++ b/frontend/src/components/Chat/ChatUsage.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { + Box, + Popover, + Tooltip, + Typography, + LinearProgress, + CircularProgress, + IconButton as MuiIconButton, +} from '@mui/material' +import { State } from '../../store' +import { formatReset } from '../../helpers/dateHelper' +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 usageColor = (used: number) => (used >= 90 ? 'error' : used >= 70 ? 'warning' : 'primary') + +/* One window's row in the popover: a labeled bar + reset time. */ +const WindowRow: React.FC<{ label: string; window: UsageWindow; gutterBottom?: boolean }> = ({ + label, + window, + gutterBottom, +}) => { + const { t } = useTranslation() + const used = pct(window) + const color = usageColor(used) + return ( + + + + {label} + + + {window.unlimited ? t('chat.usageUnlimited', 'No limit') : `${used}%`} + + + {!window.unlimited && ( + <> + + {window.resetsAt && ( + + {t('chat.usageResets', 'Resets {{when}}', { when: formatReset(window.resetsAt) })} + + )} + + )} + + ) +} + +/* 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. 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) + const [anchorEl, setAnchorEl] = React.useState(null) + const [hovered, setHovered] = React.useState(false) + + if (!usage || (usage.session.unlimited && usage.weekly.unlimited)) return null + + const worst = Math.max(pct(usage.session), pct(usage.weekly)) + const color = usageColor(worst) + + return ( + <> + {/* The app's tooltip rather than the DOM's `title`: same treatment as every + other icon button (see buttons/IconButton), and the ring carries no label + of its own. Above, because the button sits in the composer at the foot. + + Open is driven rather than left to the hover default: the popover opens + into the same space, and the pointer is still over the button when it + does — so the label has to stand down while the detail is showing. */} + setHovered(true)} + onClose={() => setHovered(false)} + > + setAnchorEl(e.currentTarget)} sx={{ padding: 1 }}> + + + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'bottom', 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 new file mode 100644 index 000000000..444a7c308 --- /dev/null +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +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 { 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 + chat model. The window chrome provides close. */ +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(selectTurnActive) + + useChatPopoutSync() + + return ( + + + + dispatch.chat.popIn()} + /> + + + + ) +} 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..c1d87dc07 100644 --- a/frontend/src/components/ConnectedApps/AgentListItem.tsx +++ b/frontend/src/components/ConnectedApps/AgentListItem.tsx @@ -4,13 +4,18 @@ 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 + // The areas this grant reaches — the same names the detail page headers use. + const areas = [ + ...new Set([ + ...(agent.groups ?? []).map(g => g.typeLabel), + ...(agent.scopeGroups ?? []).map(g => g.api), + ].filter(Boolean)), + ] as string[] return ( @@ -21,14 +26,14 @@ export const AgentListItem: React.FC<{ agent: IAuthorizedAgent }> = ({ agent }) primary={name} secondary={ <> - {reachSummary(agent.reach, accountLabel)} - {' · '} - {agent.lastActive ? ( + {!agent.active ? 'Revoked · ' : ''} + {areas.length ? `${areas.join(', ')} · ` : ''} + {agent.lastUsedAt ? ( <> - Active + Last used ) : ( - 'No activity yet' + '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..747e20b47 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,33 @@ 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 ? ( @@ -39,7 +59,10 @@ export const ConnectedApps: React.FC = () => { - + )} diff --git a/frontend/src/components/ConnectedApps/helpers.ts b/frontend/src/components/ConnectedApps/helpers.ts deleted file mode 100644 index 4d70d648c..000000000 --- a/frontend/src/components/ConnectedApps/helpers.ts +++ /dev/null @@ -1,55 +0,0 @@ -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` -} - -// 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` -} 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..c91c2cb3f 100644 --- a/frontend/src/components/DeviceList.tsx +++ b/frontend/src/components/DeviceList.tsx @@ -1,13 +1,15 @@ import React, { useCallback, useMemo } from 'react' +import { useContainerNarrowerThan } from '../hooks/useContainerWidth' +import { MOBILE_WIDTH } from '../constants' 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 { Box, Typography } from '@mui/material' import { DeviceListItem } from './DeviceListItem' import { Attribute } from './Attributes' import { isOffline } from '../models/devices' @@ -117,40 +119,49 @@ export const DeviceList: React.FC = ({ select, }) => { const location = useLocation() - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + // 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 { containerRef, narrow: mobile } = useContainerNarrowerThan(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/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/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/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/DoublePanel.tsx b/frontend/src/components/DoublePanel.tsx index d967a3373..8d7e0d81f 100644 --- a/frontend/src/components/DoublePanel.tsx +++ b/frontend/src/components/DoublePanel.tsx @@ -1,10 +1,12 @@ import React, { useRef, useState, useEffect, useCallback } from 'react' import { getPanelWidthDefault, usePanelWidth } from '../hooks/usePanelWidth' import { usePanelDrag } from '../hooks/usePanelDrag' +import { subscribeViewport } from '../hooks/useViewportWidth' 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 @@ -26,16 +28,14 @@ export const DoublePanel: React.FC = ({ left, right, layout, header = tru const sidePanelWidth = layout.sidePanelWidth + PADDING - const getMaxWidth = useCallback( - () => { - const fullWidth = primaryRef.current?.parentElement?.offsetWidth || 1000 - return 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, minWidth: MIN_WIDTH, getMaxWidth, onPersist: setPanelWidth, @@ -50,11 +50,9 @@ export const DoublePanel: React.FC = ({ left, right, layout, header = tru useEffect(() => { measureParent() }, [layout, drag.width, measureParent]) - - useEffect(() => { - window.addEventListener('resize', measureParent) - return () => window.removeEventListener('resize', measureParent) - }, [measureParent]) + // The shared listener stands in for a resize listener: coalesced to a frame, silent when + // nothing moved, and no render of this panel until the measurement changes + useEffect(() => subscribeViewport(measureParent), [measureParent]) const panelSx = { height: '100%', @@ -78,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, - }, - })} - > -
- + = ({ 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/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/GuideBubble.tsx b/frontend/src/components/GuideBubble.tsx index 6954971f6..b88b4efcd 100644 --- a/frontend/src/components/GuideBubble.tsx +++ b/frontend/src/components/GuideBubble.tsx @@ -129,12 +129,14 @@ 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 absent until the account loads, and an invalid Date persists as - // null (Date#toJSON) and rehydrates as null — so it can be missing or NaN. - // Guard both, or Math.max returns NaN and every comparison below is false, - // silently ungating every bubble. - const created = state.user.created?.getTime() - const cohortAnchor = Math.max(created && !Number.isNaN(created) ? created : 0, state.ui.guidesResetDate || 0) + // `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 }) const dismissed = useSelector((state: State) => dismissedAt(state.ui.expireBubbles)) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 85ab21f08..af089e26e 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,11 +1,14 @@ -import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH } from '../../constants' +import { REGEX_FIRST_PATH, CHAT_GUIDE_DATE, ADMIN_ADDONS_ROUTE } from '../../constants' +import { useMobile } from '../../hooks/useMobile' +import { useChatEnabled } from '../../hooks/useChatEnabled' +import { GuideBubble } from '../GuideBubble' import React, { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' 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' @@ -29,13 +32,16 @@ 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 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 const navigateUp = useNavigationUp(panels) const [showSearch, setShowSearch] = useState(false) - const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const sidebarHidden = layout.hideSidebar + const mobile = useMobile() const inputRef = useRef(null) const dispatch = useDispatch() const location = useLocation() @@ -44,8 +50,18 @@ 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 isAdminRootPage = adminRootPages.includes(location.pathname) + const adminRootPages = [ + '/admin/users', + '/admin/admins', + '/admin/partners', + '/admin/enterprise-licenses', + '/admin/devices', + '/admin/notices', + '/partner-stats', + ] + // 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_ADDONS_ROUTE) const isRootMenu = menu === location.pathname || isAdminRootPage return ( @@ -68,7 +84,13 @@ export const Header: React.FC = ({ panels = 1 }) => { )} {(layout.hideSidebar || browser.isMobile) && ( - + )} {!isRootMenu && ( @@ -80,6 +102,40 @@ 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 && ( + + + {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/components/Icon.tsx b/frontend/src/components/Icon.tsx index 3c40f2501..33bb408c1 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -3,12 +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' library.add(fal, fab, far, fas) @@ -23,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 @@ -105,6 +108,7 @@ export const Icon = React.forwardRef( // Handle special icon cases if (name === 'r3') return + if (name === 'remote-ai') return let fontType: IconPrefix = 'far' 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/LicensingIcon.tsx b/frontend/src/components/LicensingIcon.tsx index 10752ca17..d49f67f47 100644 --- a/frontend/src/components/LicensingIcon.tsx +++ b/frontend/src/components/LicensingIcon.tsx @@ -1,22 +1,12 @@ import React from 'react' -import { REMOTEIT_PRODUCT_ID, AWS_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 = '' - - 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/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..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,6 +39,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 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) { + template = 'text' + message = t('limitSetting.aiAgentAvailable', 'AI agent is available') + } + break case 'tagging': // ignore break 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 ( 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 new file mode 100644 index 000000000..3da49d9a3 --- /dev/null +++ b/frontend/src/components/MFA/MFASettings.tsx @@ -0,0 +1,320 @@ +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 { Gutters } from '../Gutters' +import { PasswordStep, CodeStep, ChoiceStep, RecoveryCodes } from './steps' +import { + selfMfaStanding, + selfMfaEnroll, + selfMfaConfirm, + selfMfaPrefer, + selfMfaDisable, + selfChallenge, + MfaMethod, + METHOD_LABEL, + SelfResult, +} from '../../services/passportSelf' +import { OAUTH_PASSPORT_RESOURCE } from '../../constants' + +/** + * 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'; 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[] } + +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 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 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() + 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, 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.'), + }) + } + + 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 }) + 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.") + setStep({ ...current, challenge: r.challenge, error }) + return + } + 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 = ( + + {t('mfa.title', 'Two-Factor Authentication')} + + ) + + 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 ( + <> + {title} + + {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.')} + + + + ) + + if (step.at === 'password') { + const needsPhone = step.mode === 'enroll' && step.method === 'sms' + return ( + <> + {title} + submitPassword(step.mode, step.method)} + onCancel={() => refresh()} + > + {needsPhone && ( + setPhone(e.target.value.trim())} + /> + )} + + + ) + } + + if (step.at === 'select') + return ( + <> + {title} + refresh()} + /> + + ) + + if (step.at === 'relay' || step.at === 'scan') + return ( + <> + {title} + + {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.')} + + {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.')} + + ) + } + code={code} + onCode={setCode} + error={step.error} + busy={busy} + onSubmit={submitCode} + onCancel={() => refresh()} + /> + + ) + + return ( + <> + {title} + refresh()} /> + + ) +} diff --git a/frontend/src/components/MFA/PasskeysSettings.tsx b/frontend/src/components/MFA/PasskeysSettings.tsx new file mode 100644 index 000000000..ddc608a10 --- /dev/null +++ b/frontend/src/components/MFA/PasskeysSettings.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Box, Button, Chip, Typography } from '@mui/material' +import { Gutters } from '../Gutters' +import { PasswordStep, CodeStep, ChoiceStep, RecoveryCodes } from './steps' +import { + selfMe, + selfPasskeyRegister, + selfPasskeyConfirm, + selfPasskeyDelete, + selfChallenge, + MfaMethod, + SelfContinuation, + SelfResult, + Passkey, +} from '../../services/passportSelf' +import { toBase64url, fromBase64url } from '../../helpers/base64url' + +/** + * Passkeys (plan Phase 2d): ONE store for both credential lanes — a passkey registered + * here is the same credential the sign-in's second-factor step asserts, bridge or local. + * Registration/removal re-prove the password (relayed code included); bridge accounts + * need a code factor first — sign-ins from older apps rely on it, and the copy says so. + */ + +type Step = + | { at: 'view'; keys: Passkey[] } + | { at: 'password'; mode: 'add' | 'remove'; keyId?: string; error?: string } + | { + at: 'relay' + pending: { mode: 'add' | 'remove'; keyId?: string } + challenge: string + hint?: string + isSelect?: boolean + options?: MfaMethod[] + error?: string + } + | { at: 'naming'; codes?: string[] } + +export const PasskeysSettings: React.FC = () => { + const { t } = useTranslation() + const [step, setStep] = useState({ at: 'view', keys: [] }) + const [password, setPassword] = useState('') + const [code, setCode] = useState('') + const [choice, setChoice] = useState('totp') + const [busy, setBusy] = useState(false) + const [supported] = useState(() => typeof window !== 'undefined' && !!window.PublicKeyCredential) + + const refresh = async () => { + const me = await selfMe() + setStep({ at: 'view', keys: me.httpStatus === 200 ? me.passkeys ?? [] : [] }) + } + useEffect(() => { + refresh() + }, []) + + /** The register continuation hands back WebAuthn creation options — run the ceremony. */ + const ceremony = async (r: SelfContinuation) => { + const options = r.options as Record + try { + const cred = (await navigator.credentials.create({ + publicKey: { + ...options, + challenge: fromBase64url(options.challenge), + user: { ...options.user, id: fromBase64url(options.user.id) }, + excludeCredentials: (options.excludeCredentials ?? []).map((c: any) => ({ ...c, id: fromBase64url(c.id) })), + } as unknown as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential + const response = cred.response as AuthenticatorAttestationResponse + const name = t('passkeys.defaultName', 'This device') + const done = await selfPasskeyConfirm( + String(r.challenge), + { + attestationObject: toBase64url(response.attestationObject), + clientDataJSON: toBase64url(response.clientDataJSON), + }, + name + ) + if (done.status === 'ok') setStep({ at: 'naming', codes: done.recovery_codes }) + else + setStep({ + at: 'password', + mode: 'add', + error: done.error_description || t('passkeys.failed', 'Registration failed — try again.'), + }) + } catch (error: any) { + // The user closing the platform prompt is a cancel, not an error worth shouting. + if (error?.name === 'NotAllowedError') return refresh() + setStep({ + at: 'password', + mode: 'add', + error: error?.message || t('passkeys.failed', 'Registration failed — try again.'), + }) + } + } + + 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) + return setStep({ + at: 'relay', + pending, + challenge: r.challenge, + hint: r.hint, + isSelect: r.status === 'select', + options: (r.options as MfaMethod[]) ?? [], + }) + setStep({ + at: 'password', + mode: pending.mode, + keyId: pending.keyId, + error: + r.error === 'invalid_password' + ? t('mfa.wrongPassword', "That password didn't match.") + : r.error === 'pool_factor_required' + ? r.error_description || t('passkeys.needFactor', 'Set up an authenticator or text codes first.') + : r.error_description || t('passkeys.failed', 'Something went wrong — try again.'), + }) + } + + const submitPassword = async (mode: 'add' | 'remove', keyId?: string) => { + setBusy(true) + const r = mode === 'add' ? await selfPasskeyRegister(password) : await selfPasskeyDelete(password, keyId ?? '') + setBusy(false) + setPassword('') + await follow(r, { mode, keyId }) + } + + const submitCode = async () => { + const current = step as Extract + setBusy(true) + const r = await selfChallenge(current.challenge, current.isSelect ? { choice } : { code }) + setBusy(false) + setCode('') + await follow(r, current.pending) + } + + const title = ( + + {t('passkeys.title', 'Passkeys')} + + ) + + if (!supported) return null + + if (step.at === 'view') + return ( + <> + {title} + + {step.keys.map(key => ( + + + + + ))} + + {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} + submitPassword(step.mode, step.keyId)} + onCancel={() => refresh()} + /> + + ) + + if (step.at === 'relay') + return ( + <> + {title} + {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()} + /> + )} + + ) + + return ( + <> + {title} + + + {t('passkeys.added', 'Passkey added — next sign-in, use it instead of typing a code.')} + + + {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.' + )} + + + + + ) +} diff --git a/frontend/src/components/MobileUI.tsx b/frontend/src/components/MobileUI.tsx index 29847e027..44ad6dc84 100644 --- a/frontend/src/components/MobileUI.tsx +++ b/frontend/src/components/MobileUI.tsx @@ -1,7 +1,6 @@ import React from 'react' import browser from '../services/browser' -import { useMediaQuery } from '@mui/material' -import { MOBILE_WIDTH } from '../constants' +import { useMobile } from '../hooks/useMobile' type Props = { ios?: boolean @@ -11,7 +10,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 = 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 } = useDispatch() let activeOrg = useSelector(selectOrganization) @@ -281,4 +282,3 @@ function Title({ primary, count }: { primary: string; count: number }) { ) } - diff --git a/frontend/src/components/PanelHandle.tsx b/frontend/src/components/PanelHandle.tsx new file mode 100644 index 000000000..58b575bca --- /dev/null +++ b/frontend/src/components/PanelHandle.tsx @@ -0,0 +1,51 @@ +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 — every resizable edge, content panels and + the chat column alike. */ +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. + * 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 }) => ( + +
+ +) 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/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/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..e2c76f3a6 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() @@ -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/RemoteHeader.tsx b/frontend/src/components/RemoteHeader.tsx index 84cfde85c..e153edb27 100644 --- a/frontend/src/components/RemoteHeader.tsx +++ b/frontend/src/components/RemoteHeader.tsx @@ -1,15 +1,44 @@ 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 { useViewportWiderThan } 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 }) => { - 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. + (Web only — Electron always fills its window.) */ + const chatWidth = useChatWidth() + const appMaxWidth = APP_MAX_WIDTH + (useChatDocked() ? chatWidth : 0) + /* 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. 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 @@ -51,10 +80,12 @@ 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'})`, - maxWidth: maxWidth ? APP_MAX_WIDTH : undefined, + 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, boxShadow: maxWidth || showFrame ? 3 : undefined, @@ -64,4 +95,4 @@ export const RemoteHeader: React.FC = ({ device, children }) => { ) -} \ No newline at end of file +} 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/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 ( = ({ 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/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/components/ServiceList.tsx b/frontend/src/components/ServiceList.tsx index de801203e..ef7608780 100644 --- a/frontend/src/components/ServiceList.tsx +++ b/frontend/src/components/ServiceList.tsx @@ -1,11 +1,12 @@ import React, { useRef } from 'react' +import { useContainerNarrowerThan } from '../hooks/useContainerWidth' +import { MOBILE_WIDTH } from '../constants' import { useLocation } from 'react-router-dom' import { useSelector } from 'react-redux' -import { MOBILE_WIDTH } from '../constants' import { getSortOptions } from './SortServices' import { selectDeviceModelAttributes } from '../selectors/devices' import { DeviceListHeaderCheckbox } from './DeviceListHeaderCheckbox' -import { Divider, useMediaQuery } 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,9 @@ export const ServiceList: React.FC = ({ const { sortService } = getSortOptions(useSelector(selectDeviceModelAttributes).sortServiceOption) const location = useLocation() const previousName = useRef('') - const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + // 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 { containerRef, narrow: mobile } = useContainerNarrowerThan(MOBILE_WIDTH) const rows = devices.reduce((row, device) => { const hasFilter = applicationTypes?.length @@ -54,29 +57,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 && } + + + ) + })} + + + ) } 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/components/SignInApp.tsx b/frontend/src/components/SignInApp.tsx index 04d424eb6..56f7168bc 100644 --- a/frontend/src/components/SignInApp.tsx +++ b/frontend/src/components/SignInApp.tsx @@ -1,26 +1,193 @@ -import React from 'react' -import { CognitoUser } from '../cognito/types' -import { CognitoAuth } from '../cognito/components/CognitoAuth' +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, oidcAutoStartExhausted, oidcIsSupportTab, oidcLeaveRefused } from '../services/oidc' +import { MODE } from '../constants' +import browser from '../services/browser' +import brand from '@common/brand/config' -export function SignInApp() { - const { signInError, authService } = useSelector((state: State) => state.auth) - const { auth } = useDispatch() +/** + * 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 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 + 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() + /* 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)) - const onSignInSuccess = (user: CognitoUser) => { - setTimeout(() => auth.handleSignInSuccess(user), 100) + 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.") + } } - if (!authService) return null + return ( + + + {message()} + + {!!detail && showDetail && ( + + {detail} + + )} + + ) +} + +export function SignInApp() { + const { t } = useTranslation() + const { signInFailed, signInError, signInErrorCode, signInRetryAfter, signingIn, initialized } = useSelector( + (state: State) => state.auth + ) + 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 + 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. */ + // 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 && !oidcLeaveRefused() + useEffect(() => { + if (!autoStart) return + auth.signIn({ auto: 'boot' }) + // 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 (supportTab) + return ( + + {initialized ? ( + <> + + Support session ended + + + Close this tab to return to the console. + + + ) : ( + <> + + + Opening the support session… + + + )} + + ) + + if (autoStart || (!browser.isElectron && signingIn)) + return ( + + + + {t('signIn.redirecting', 'Taking you to sign in…')} + + + ) return ( - + + + {t('signIn.title', 'Sign in to {{app}}', { app: brand.appName })} + + + {t('signIn.subtitle', "We'll open your browser to sign you in with Remote.It Single Sign-On.")} + + {signingIn ? ( + + + + {t('signIn.waiting', 'Waiting for your browser… finish signing in there.')} + + + + ) : ( + + )} + {signInFailed && } + ) } 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/components/TriplePanel.tsx b/frontend/src/components/TriplePanel.tsx index 11124b7f3..9de337eeb 100644 --- a/frontend/src/components/TriplePanel.tsx +++ b/frontend/src/components/TriplePanel.tsx @@ -1,34 +1,9 @@ import React, { useRef, useCallback } from 'react' import { usePanelWidth } from '../hooks/usePanelWidth' import { usePanelDrag } from '../hooks/usePanelDrag' -import { Box, Theme } from '@mui/material' +import { Box } from '@mui/material' import { Header } from './Header' - -const handleSx = (theme: Theme) => ({ - zIndex: 8, - position: 'absolute' as const, - 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, - }, -}) +import { PanelHandle } from './PanelHandle' type Props = { left: React.ReactNode @@ -60,26 +35,21 @@ export const TriplePanel: React.FC = ({ left, center, right, layout, head const sidePanelWidth = layout.sidePanelWidth + PADDING - const getPrimaryMaxWidth = useCallback( - () => { - const fullWidth = primaryRef.current?.parentElement?.offsetWidth || 1000 - const secondaryWidth = secondaryRef.current?.offsetWidth || MIN_WIDTH - return fullWidth - secondaryWidth - MIN_WIDTH - sidePanelWidth - }, - [sidePanelWidth] - ) + const getPrimaryMaxWidth = useCallback(() => { + const fullWidth = primaryRef.current?.parentElement?.offsetWidth || 1000 + const secondaryWidth = secondaryRef.current?.offsetWidth || MIN_WIDTH + // 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 - secondaryWidth - MIN_WIDTH - sidePanelWidth) + }, [sidePanelWidth]) - const getSecondaryMaxWidth = useCallback( - () => { - const fullWidth = secondaryRef.current?.parentElement?.offsetWidth || 1000 - const primaryWidth = primaryRef.current?.offsetWidth || MIN_WIDTH - return fullWidth - primaryWidth - MIN_WIDTH - sidePanelWidth - }, - [sidePanelWidth] - ) + const getSecondaryMaxWidth = useCallback(() => { + const fullWidth = secondaryRef.current?.parentElement?.offsetWidth || 1000 + const primaryWidth = primaryRef.current?.offsetWidth || MIN_WIDTH + return Math.max(MIN_WIDTH, fullWidth - primaryWidth - MIN_WIDTH - sidePanelWidth) + }, [sidePanelWidth]) const dragPrimary = usePanelDrag(primaryPanelWidth, { - panelRef: primaryRef, minWidth: MIN_WIDTH, getMaxWidth: getPrimaryMaxWidth, onPersist: setPrimaryPanelWidth, @@ -87,7 +57,6 @@ export const TriplePanel: React.FC = ({ left, center, right, layout, head }) const dragSecondary = usePanelDrag(secondaryPanelWidth, { - panelRef: secondaryRef, minWidth: MIN_WIDTH, getMaxWidth: getSecondaryMaxWidth, onPersist: setSecondaryPanelWidth, @@ -105,9 +74,7 @@ export const TriplePanel: React.FC = ({ left, center, right, layout, head {left} - -
- + = ({ left, center, right, layout, head {center} - -
- + {right} diff --git a/frontend/src/components/ViewAsBanner.tsx b/frontend/src/components/ViewAsBanner.tsx index 39ec78e8b..a4d2cccda 100644 --- a/frontend/src/components/ViewAsBanner.tsx +++ b/frontend/src/components/ViewAsBanner.tsx @@ -1,24 +1,38 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector, useDispatch } from 'react-redux' +import { useSelector } from 'react-redux' import { Box, Typography, IconButton } from '@mui/material' -import { State, Dispatch } from '../store' +import { State } from '../store' import { Icon } from './Icon' +import { oidcActor, oidcSupportEndsAt, oidcEndSupportTab } from '../services/oidc' +import { OAUTH_ISSUER } from '../constants' +import { getLocale } from '../helpers/dateHelper' +/* A SUPPORT SESSION (permitteer impersonation, docs/desktop-support.md) needs no app state: 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. */ export const ViewAsBanner: React.FC = () => { const { t } = useTranslation() - const viewAsUser = useSelector((state: State) => state.ui.viewAsUser) - const dispatch = useDispatch() + const user = useSelector((state: State) => state.auth.user) - if (!viewAsUser) return null + // Both read (and decode) the token store, so once per session rather than per render. + const { actor, endsAt } = useMemo(() => ({ actor: oidcActor(), endsAt: oidcSupportEndsAt() }), [user]) + if (!actor || !user) return null + // The session's end is the token's expiry: shown so the operator knows how long the view + // lasts — nothing renews it. + const until = endsAt ? new Date(endsAt).toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }) : '' const handleExit = () => { - // Clear from sessionStorage - 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. window.close() + window.setTimeout(() => { + if (window.closed) return + oidcEndSupportTab() + window.location.assign(`${OAUTH_ISSUER}/admin/console/users`) + }, 150) } return ( @@ -36,7 +50,12 @@ export const ViewAsBanner: React.FC = () => { }} > - {t('viewAsBanner.viewingAs', { email: viewAsUser.email, defaultValue: 'Viewing as: {{email}}' })} + {t('viewAsBanner.supportSession', { + email: user.email || '', + until, + defaultValue: + 'Support session — viewing as {{email}} until {{until}}. Tokens are stamped with your identity; the user can see and end this session.', + })} ) => { + 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 f99ed487f..f4dab6adf 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,29 +1,108 @@ import brand from '@common/brand/config' +import { CATALOGUE } from './platforms/catalogue' const env = import.meta.env export const MODE = env.MODE || 'development' -export const CLIENT_ID = env.VITE_CLIENT_ID -export const MOBILE_CLIENT_ID = env.VITE_MOBILE_CLIENT_ID -export const COGNITO_USER_POOL_ID = env.VITE_COGNITO_USER_POOL_ID || 'us-west-2_6nKjyW7yg' -export const COGNITO_AUTH_DOMAIN = env.VITE_COGNITO_AUTH_DOMAIN || 'auth.remote.it' + +/* 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: + 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' +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. +export const OAUTH_ISSUER = env.VITE_OAUTH_ISSUER || '' +export const OAUTH_CLIENT_ID = env.VITE_OAUTH_CLIENT_ID || 'remoteit_desktop' +export const OAUTH_ACCOUNT_RESOURCE = `${OAUTH_ISSUER}/account/api` +// 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.remote.it/api' +// The two front shapes, recognised in ONE place (graphql-permitteer docs/CLOUD-EDGE.md): the unified +// front's TREE identifier, with graphql and the socket as paths inside it, and the legacy per-stage +// hosts, one each for graphql and events. Group 1 is the stage, absent on prod. +export const CLOUD_TREE_RE = /^https:\/\/cloud(?:\.([a-z0-9-]+))?\.remote\.it\/api$/ +export const CLOUD_GRAPHQL_RE = /^(https:\/\/cloud(?:\.[a-z0-9-]+)?\.remote\.it\/api)\/graphql$/ +export const LEGACY_GRAPHQL_RE = /^https:\/\/graphql(?:\.([a-z0-9-]+))?\.remote\.it\/graphql$/ +export const LEGACY_EVENTS_RE = /^wss:\/\/ws(?:\.([a-z0-9-]+))?\.remote\.it\/v1$/ +export const cloudTreeUrls = (tree: string) => ({ + graphql: `${tree}/graphql`, + ws: `${tree.replace(/^https:/, 'wss:')}/ws`, +}) +/** The RESOURCE (RFC 8707 audience) to mint for when calling a given GraphQL URL — the ONE rule, + * whichever lane chose the URL. On the legacy per-stage hosts the identifier IS the graphql URL. + * 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.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.remote.it' +export const OAUTH_MCP_RESOURCE = env.VITE_OAUTH_MCP_RESOURCE || 'https://cloud.remote.it/mcp' +// 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' +// Dev rides the vite /agent proxy (same-origin, CSP-clean) even when VITE_AGENT_URL is set; +// builds have no proxy and call the deployed agent. +export const AGENT_URL = env.DEV ? '/agent' : env.VITE_AGENT_URL || '/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' +// 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. +// +// Two front shapes exist, and the audience means a different thing in each. On the legacy per-stage +// HOSTS the identifier IS the graphql URL (https://graphql..remote.it/graphql). On the +// UNIFIED FRONT (graphql-permitteer docs/CLOUD-EDGE.md) one host carries every surface under PATH +// TREES, so the identifier is the TREE — https://cloud..remote.it/api — and graphql and the +// socket are paths INSIDE it. Calling the audience directly there would POST queries at the tree +// root, so the tree has to be recognised and the leaf appended. +const cloudTree = CLOUD_TREE_RE.test(OAUTH_GRAPHQL_RESOURCE) +export const GRAPHQL_API = + env.VITE_GRAPHQL_API || (cloudTree ? cloudTreeUrls(OAUTH_GRAPHQL_RESOURCE).graphql : OAUTH_GRAPHQL_RESOURCE) export const GRAPHQL_BETA_API = env.VITE_GRAPHQL_BETA_API || 'https://api.remote.it/graphql/beta' +// Test Settings: an ad-hoc request header injected on API calls (helpers/apiHelper.getTestHeader). +export const TEST_HEADER = 'test-header' 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 REDIRECT_URL = env.VITE_REDIRECT_URL || PROTOCOL + 'authCallback' -export const SIGNOUT_REDIRECT_URL = PROTOCOL + 'signoutCallback' -export const CALLBACK_URL = - env.VITE_CALLBACK_URL || env.MODE === 'development' - ? env.VITE_DEV_CALLBACK_URL || 'https://dev-auth.internal.remote.it/v1/callback/' - : env.VITE_PROD_CALLBACK_URL || 'https://auth.api.remote.it/v1/callback/' - -export const WEBSOCKET_URL = env.VITE_WEBSOCKET_URL -export const WEBSOCKET_BETA_URL = env.VITE_WEBSOCKET_BETA_URL + +// Pairs with the GraphQL stage above, the same pairing Test Settings' stage picker applies: +// graphql.…/graphql <-> wss://ws.…/v1 on the legacy hosts, and the tree's own /ws +// path on the unified front. 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. +// +// 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(CLOUD_GRAPHQL_RE)?.[1] +const graphqlStage = GRAPHQL_API.match(LEGACY_GRAPHQL_RE)?.[1] +export const WEBSOCKET_URL = + env.VITE_WEBSOCKET_URL || + (graphqlTree ? cloudTreeUrls(graphqlTree).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 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 @@ -43,7 +122,6 @@ export const REGISTRATION_CODE_EXPIRATION_HOURS = 24 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' // When the guide bubble system shipped — bubbles are hidden from accounts created before their start date export const GUIDE_START_DATE = new Date('2022-09-20') @@ -63,7 +141,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' @@ -94,9 +178,33 @@ 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 +export const CHAT_PANEL_WIDTH = 400 +export const CHAT_PANEL_WIDTH_MIN = 320 +/* When the AI chat tour shipped. GuideBubble's `added` — a "dismiss all" from before + this date does not suppress it, so users who opted out of the older guides still + get introduced to a feature that did not exist back then. */ +export const CHAT_GUIDE_DATE = new Date('2026-08-27') +/* 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 +// 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 +/* 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/helpers/DateTransform.ts b/frontend/src/helpers/DateTransform.ts index 61f93600b..59163cccf 100644 --- a/frontend/src/helpers/DateTransform.ts +++ b/frontend/src/helpers/DateTransform.ts @@ -28,7 +28,12 @@ const DateTransform = createTransform( return obj } return convertDates(outboundState) - } + }, + + // Chat transcripts are free-form user/agent text — a message or tool result + // that happens to look like a timestamp must rehydrate as a string, not a + // Date (rendering a Date as a React child crashes the app) + { blacklist: ['chat'] } ) export default DateTransform diff --git a/frontend/src/helpers/apiHelper.ts b/frontend/src/helpers/apiHelper.ts index e93862e2a..168ea766a 100644 --- a/frontend/src/helpers/apiHelper.ts +++ b/frontend/src/helpers/apiHelper.ts @@ -1,4 +1,12 @@ -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, + resourceForApiURL, +} from '../constants' import { graphQLRentANode } from '../services/graphQLMutation' import { version } from './versionHelper' import { store } from '../store' @@ -15,6 +23,15 @@ 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 URL +// the app CALLS — whichever lane chose it: the build's default, a backend override, the Test +// Settings switcher or a hand-typed target — so a URL and a token can never disagree about the +// stage. An off-allowlist target fails at MINT with a legible invalid_target instead of as +// ambient 401s an hour later. +export function getApiResource(): string { + return resourceForApiURL(getApiURL() ?? GRAPHQL_API) +} + export function getRestApi(): string | undefined { try { if (!store) return API_URL @@ -73,4 +90,4 @@ export async function submitGoogleForm(formId: string, entries: Record { + 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(/=+$/, '') +} + +export const fromBase64url = (s: string): Uint8Array => + Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) + +export const decodeBase64url = (s: string): string => new TextDecoder().decode(fromBase64url(s)) diff --git a/frontend/src/helpers/dateHelper.ts b/frontend/src/helpers/dateHelper.ts index ca1312b01..cef5e1881 100644 --- a/frontend/src/helpers/dateHelper.ts +++ b/frontend/src/helpers/dateHelper.ts @@ -11,6 +11,16 @@ export const getLocale = () => i18n.resolvedLanguage || window.navigator.languag export const humanizeDuration = (ms: number, options: HumanizerOptions = {}) => humanize(ms, { language: getLocale(), fallbacks: ['en'], ...options }) +// 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(getLocale(), { hour: 'numeric', minute: '2-digit' }) + : at.toLocaleString(getLocale(), { weekday: 'short', hour: 'numeric', minute: '2-digit' }) +} + // Wrap a humanized duration as a localized relative-past phrase. Word order is // language-specific (en "3 days ago", de "vor 3 Tagen", ja "3日前", es "hace 3 días"), // so the ordering lives in the catalog rather than a hard-coded English suffix. 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/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/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts new file mode 100644 index 000000000..88234784f --- /dev/null +++ b/frontend/src/hooks/useChatEnabled.ts @@ -0,0 +1,118 @@ +import { useMemo } from 'react' +import { useSelector } from 'react-redux' +import { State } from '../store' +import { selectLimitsLookup } from '../selectors/organizations' +import browser from '../services/browser' +import { useViewportSelect, useViewportWiderThan } from './useViewportWidth' +import { + CHAT_FEATURE, + CHAT_PANEL_WIDTH, + CHAT_PANEL_WIDTH_MIN, + CHAT_PANEL_WIDTH_MAX, + CHAT_MIN_CONTENT_WIDTH, + HIDE_SIDEBAR_WIDTH, + HIDE_TWO_PANEL_WIDTH, + SHOW_TRIPLE_PANEL_WIDTH, + MOBILE_WIDTH, + SIDEBAR_WIDTH, + ORGANIZATION_BAR_WIDTH, +} from '../constants' + +/* 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. + 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 + 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. */ +// 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. +export const chatMaxWidth = (viewport: number): number => + 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 + 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. */ +const chatWidthFor = (stored: number, viewport: number): number => + Math.min(Math.max(stored || CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_MIN), chatMaxWidth(viewport)) +export const useChatWidth = (): number => { + const stored = useSelector((state: State) => state.chat.width) + return useViewportSelect(w => chatWidthFor(stored, w)) +} + +/* 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. 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 + 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 fits = useViewportWiderThan(CHAT_PANEL_WIDTH_MIN + CHAT_MIN_CONTENT_WIDTH) + return enabled && open && !browser.isMobile && fits +} + +/* The layout's breakpoints, measured against the width the app actually has left: the + window minus the docked chat column. Every 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. + Subscribed as the packed answer, so App re-renders when a breakpoint flips, not on + every frame of a resize or a chat drag. */ +export const useLayoutBreakpoints = () => { + const docked = useChatDocked() + const stored = useSelector((state: State) => state.chat.width) + const packed = useViewportSelect(w => layoutBreakpoints(docked ? w - chatWidthFor(stored, w) : w)) + return useMemo( + () => ({ + hideSidebar: !!(packed & 1), + singlePanel: !!(packed & 2), + triplePanel: !!(packed & 4), + mobile: !!(packed & 8), + }), + [packed] + ) +} + +/* 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 => useLayoutBreakpoints().hideSidebar + +/* 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() + 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) diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts new file mode 100644 index 000000000..629be5812 --- /dev/null +++ b/frontend/src/hooks/useChatSync.ts @@ -0,0 +1,140 @@ +import { useEffect } from 'react' +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 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) + +/* 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 + +/* What both chat surfaces do on boot: follow the signed-in identity, clear what must not + survive a reload, and — while SHOWN — catch up with the server. A licensed account with the + dock closed asks the agent for nothing: no list, no meter, no transcript. */ +const useChatBoot = (open: boolean): void => { + const userId = useChatIdentity() + const dispatch = useDispatch() + + // 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. + useEffect(() => { + const synced = dispatch.chat.syncIdentity(userId) + if (!open || !userId) return + synced.then(() => { + dispatch.chat.syncTranscript() + dispatch.chat.loadConversations() + dispatch.chat.loadUsage() + }) + }, [open, 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 + // deliberately leaves the stream running) + dispatch.chat.resetTransient() + }, []) + + useEffect(() => { + if (open) dispatch.chat.checkHealth() + }, [open]) + + 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() + useChatBoot(open) + + useEffect(() => { + const handlers: PopoutMainHandlers = { + getHandoff: currentHandoff, + adopt: payload => { + dispatch.chat.adoptHandoff(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 }) + }, + // 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) + checkPopoutPresence(handlers) + 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() + } + }, []) +} + +/* Popout-window chat lifecycle: adopt the handed-off conversation, answer + liveness pings, and hand the transcript back on unload — keeps ChatWindow + display-only. */ +export const useChatPopoutSync = (): void => { + const { t } = useTranslation() + const dispatch = useDispatch() + useChatBoot(true) + + useEffect(() => { + document.title = t('chat.windowTitle', 'remote.it chat') + const unsubscribe = initChatPopoutWindow({ + adopt: payload => dispatch.chat.adoptHandoff(payload), + getHandoff: currentHandoff, + onSignout: () => window.close(), + }) + 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() + } + }, []) +} diff --git a/frontend/src/hooks/useContainerWidth.ts b/frontend/src/hooks/useContainerWidth.ts index bc0cb3b99..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,23 +9,18 @@ import { useRef, useState, useEffect } from 'react' export function useContainerWidth() { const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(1000) - - useEffect(() => { - const updateWidth = () => { - if (containerRef.current) { - setContainerWidth(containerRef.current.offsetWidth) - } - } - - updateWidth() - - const resizeObserver = new ResizeObserver(updateWidth) - if (containerRef.current) { - resizeObserver.observe(containerRef.current) - } - - return () => resizeObserver.disconnect() - }, []) - + useResizeMeasure(containerRef, element => setContainerWidth(element.offsetWidth)) return { containerRef, containerWidth } } + +/** + * Is the container narrower than `threshold`? Same observer, but the state is the ANSWER, + * so a list that only wants its cramped/roomy mode re-renders when that flips — not on + * every frame of a panel drag, window resize or sidebar collapse. + */ +export function useContainerNarrowerThan(threshold: number) { + const containerRef = useRef(null) + const [narrow, setNarrow] = useState(false) + useResizeMeasure(containerRef, element => setNarrow(element.offsetWidth < threshold)) + return { containerRef, narrow } +} 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) diff --git a/frontend/src/hooks/usePanelDrag.ts b/frontend/src/hooks/usePanelDrag.ts index f1d990874..a0d23643a 100644 --- a/frontend/src/hooks/usePanelDrag.ts +++ b/frontend/src/hooks/usePanelDrag.ts @@ -1,11 +1,21 @@ import React, { useRef, useState, useEffect, useCallback } from 'react' +import { subscribeViewport } from './useViewportWidth' interface UsePanelDragOptions { - panelRef: React.RefObject 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 + * is inverted. Defaults to left, matching the content panels. */ + anchor?: 'left' | 'right' } /** @@ -13,14 +23,13 @@ interface UsePanelDragOptions { * Used by DoublePanel and TriplePanel to keep resize logic DRY. * * @param initialWidth - Starting width of the panel - * @param options.panelRef - Ref to the panel DOM element * @param options.minWidth - Minimum allowed width * @param options.getMaxWidth - Callback returning the max allowed width * @param options.onPersist - Called on drag end with the final width * @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 { minWidth, getMaxWidth, onPersist, onChange, layoutDep, anchor = 'left' } = options const handleRef = useRef(initialWidth) const moveRef = useRef(0) @@ -36,13 +45,19 @@ 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 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] + [minWidth, getMaxWidth, anchor, onChange] ) const onUp = useCallback( @@ -51,16 +66,21 @@ 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] + [onMove, onPersist] ) const onDown = (event: React.MouseEvent) => { 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) @@ -70,11 +90,14 @@ export function usePanelDrag(initialWidth: number, options: UsePanelDragOptions) setWidth(initialWidth) }, [initialWidth]) + // Re-clamp when the layout shifts or the window resizes. The resize is subscribed, not + // rendered: a frame that leaves the width inside its bounds renders nothing. useEffect(() => { measure() - window.addEventListener('resize', measure) - return () => window.removeEventListener('resize', measure) - }, [layoutDep]) + }, [layoutDep, measure]) + const measureRef = useRef(measure) + measureRef.current = measure + useEffect(() => subscribeViewport(() => measureRef.current()), []) return { width, grab, onDown } } 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/hooks/useViewAsUser.ts b/frontend/src/hooks/useViewAsUser.ts deleted file mode 100644 index 089f11ed0..000000000 --- a/frontend/src/hooks/useViewAsUser.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect } from 'react' -import { useLocation, useHistory } from 'react-router-dom' -import { useDispatch } from 'react-redux' -import { Dispatch } from '../store' - -export function useViewAsUser() { - const location = useLocation() - const history = useHistory() - const dispatch = useDispatch() - - useEffect(() => { - // First, try to restore from sessionStorage (survives refresh) - const savedViewAs = window.sessionStorage.getItem('viewAsUser') - if (savedViewAs) { - try { - const viewAsUser = JSON.parse(savedViewAs) - dispatch.ui.set({ viewAsUser }) - console.log('Restored viewAs from sessionStorage:', viewAsUser) - } catch (e) { - console.error('Failed to parse viewAsUser from sessionStorage:', e) - } - } - - // Then check URL parameter (for initial open) - const params = new URLSearchParams(location.search) - const viewAsParam = params.get('viewAs') - - if (viewAsParam) { - const [userId, email] = viewAsParam.split(',') - if (userId && email) { - const viewAsUser = { id: userId, email: decodeURIComponent(email) } - dispatch.ui.set({ viewAsUser }) - // Save to sessionStorage for persistence across refreshes - window.sessionStorage.setItem('viewAsUser', JSON.stringify(viewAsUser)) - console.log('Set viewAs from URL:', viewAsUser) - - // Remove the parameter from URL - params.delete('viewAs') - const newSearch = params.toString() - history.replace({ - pathname: location.pathname, - search: newSearch ? `?${newSearch}` : '', - }) - } - } - }, [location.search, dispatch, history, location.pathname]) -} diff --git a/frontend/src/hooks/useViewportWidth.ts b/frontend/src/hooks/useViewportWidth.ts new file mode 100644 index 000000000..4fabdf274 --- /dev/null +++ b/frontend/src/hooks/useViewportWidth.ts @@ -0,0 +1,61 @@ +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) +} + +/* The same listener for code that reacts to a resize without rendering it — a re-clamp, a + measurement. Returns the unsubscribe. */ +export const subscribeViewport = (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 + } + } +} + +/* The width right now, for event handlers that must not subscribe (a drag's own math). */ +export const getViewportWidth = (): number => width + +/* 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 + 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. + Primitives only: an object would be a fresh reference every frame. */ +export const useViewportSelect = (select: (width: number) => T): T => + useSyncExternalStore(subscribeViewport, () => select(width)) + +/* Is the window at least `threshold` wide? */ +export const useViewportWiderThan = (threshold: number): boolean => useViewportSelect(w => w >= threshold) diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 8e24f8e3b..b17c2eed5 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -3,7 +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 enCognito from './locales/en/cognito.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. @@ -14,7 +14,7 @@ export const SUPPORTED_LANGUAGES: { value: string; label: string }[] = [ { value: 'es', label: 'Español' }, ] -export const NAMESPACES = ['app', 'notices', 'cognito'] as const +export const NAMESPACES = ['app', 'notices', 'platforms'] as const export type LanguageMode = 'system' | 'en' | 'ja' | 'de' | 'es' @@ -59,7 +59,7 @@ i18n returnNull: false, partialBundledLanguages: true, resources: { - en: { app: enApp, notices: enNotices, cognito: enCognito }, + 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 5b3d0f73e..b2d53b7a1 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", @@ -280,6 +281,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 +290,56 @@ "noticeTitle": "Hinweis", "title": "Passwort ändern" }, + "chat": { + "approve": "", + "close": "", + "closeChat": "", + "deleteConfirmAction": "", + "deleteConfirmTitle": "", + "deleteConversation": "", + "deny": "", + "guideAgentBody": "", + "guideAgentTitle": "", + "guideComposeBody": "", + "guideComposeTitle": "", + "guideHistoryBody": "", + "guideHistoryTitle": "", + "history": "", + "historyEmpty": "", + "inputPlaceholder": "", + "interrupted": "", + "introBody": "", + "introTitle": "", + "newChat": "", + "newPrompt1": "", + "newPrompt2": "", + "newPrompt3": "", + "newSession": "", + "notLicensed": "", + "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 +457,7 @@ "close": "Schließen", "confirm": "Bestätigen", "confirmingEllipsis": "Wird bestätigt...", + "continue": "", "delete": "Löschen", "disable": "Deaktivieren", "done": "Fertig", @@ -420,6 +474,7 @@ "saved": "Gespeichert", "saving": "Wird gespeichert", "savingEllipsis": "Wird gespeichert...", + "verify": "", "yes": "Ja" }, "connect": { @@ -445,26 +500,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 +974,7 @@ "dismissAll": "alle ausblenden" }, "header": { + "aiAgent": "", "back": "Zurück", "create": "", "deviceSearch": "Gerätesuche", @@ -1030,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", @@ -1070,6 +1150,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,10 +1253,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.", "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", @@ -1498,6 +1606,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", @@ -2059,6 +2176,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", @@ -2109,8 +2244,20 @@ "testPage": { "addQueryHeader": "Abfrage-Header hinzufügen", "addQueryHeaderPlaceholder": "Beispiel: \"key:value\"", + "agentURL": "", + "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", + "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}})", @@ -2118,6 +2265,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", @@ -2194,6 +2342,7 @@ }, "viewAsBanner": { "exit": "Ansichtsmodus verlassen", + "supportSession": "Support-Sitzung — Ansicht als {{email}} bis {{until}}. Tokens tragen Ihre Identität; die Person kann diese Sitzung sehen und beenden.", "viewingAs": "Anzeige als: {{email}}" } } diff --git a/frontend/src/i18n/locales/de/notices.json b/frontend/src/i18n/locales/de/notices.json index 045eefd62..99b54e67a 100644 --- a/frontend/src/i18n/locales/de/notices.json +++ b/frontend/src/i18n/locales/de/notices.json @@ -9,6 +9,15 @@ "loginFailed": "Anmeldung fehlgeschlagen.", "passwordChanged": "Passwort erfolgreich geändert." }, + "chat": { + "authRequired": "Agentenauthentifizierung erforderlich — melden Sie sich an, um fortzufahren.", + "deleteFailed": "", + "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": { "surveyFailed": "Die Übermittlung der Verbindungsumfrage ist fehlgeschlagen. Bitte wenden Sie sich an den Support." }, diff --git a/frontend/src/i18n/locales/de/platforms.json b/frontend/src/i18n/locales/de/platforms.json new file mode 100644 index 000000000..7292fd749 --- /dev/null +++ b/frontend/src/i18n/locales/de/platforms.json @@ -0,0 +1,132 @@ +{ + "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.", + "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", + "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.", + "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", + "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 be1372d37..c45494a70 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", @@ -280,6 +281,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 +290,56 @@ "noticeTitle": "Notice", "title": "Change Password" }, + "chat": { + "approve": "Approve", + "close": "Close", + "closeChat": "Close chat", + "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", + "notLicensed": "Remote.It AI is not available for this account.", + "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 +457,7 @@ "close": "Close", "confirm": "Confirm", "confirmingEllipsis": "Confirming...", + "continue": "Continue", "delete": "Delete", "disable": "Disable", "done": "Done", @@ -420,6 +474,7 @@ "saved": "Saved", "saving": "Saving", "savingEllipsis": "Saving...", + "verify": "Verify", "yes": "Yes" }, "connect": { @@ -445,26 +500,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 +974,7 @@ "dismissAll": "dismiss all" }, "header": { + "aiAgent": "AI Agent", "back": "Back", "create": "Create", "deviceSearch": "Device Search", @@ -1030,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", @@ -1070,6 +1150,32 @@ "logsPage": { "title": "Logs" }, + "mfa": { + "choose": "How would you like to get your code?", + "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,10 +1253,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.", "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", @@ -1498,6 +1606,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", @@ -2059,6 +2176,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", @@ -2109,8 +2244,20 @@ "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", + "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", + "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", "disableTestUIHint": "To re-enable the alpha UI you will have to select the Avatar menu while holding alt-shift.", "featureLabel": "{{name}} (default {{state}})", @@ -2118,6 +2265,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", @@ -2194,6 +2342,7 @@ }, "viewAsBanner": { "exit": "Exit view-as mode", + "supportSession": "Support session — viewing as {{email}} until {{until}}. Tokens are stamped with your identity; the user can see and end this session.", "viewingAs": "Viewing as: {{email}}" } } diff --git a/frontend/src/i18n/locales/en/cognito.json b/frontend/src/i18n/locales/en/cognito.json deleted file mode 100644 index 927c8e2e5..000000000 --- a/frontend/src/i18n/locales/en/cognito.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "app-store-banner": { - "connect": "Connect from anywhere using our mobile app.", - "unknown-os": "Now available for iOS and Android." - }, - "CUSTOM CHALLENGE": "CUSTOM CHALLENGE", - "global": { - "action": { - "continue": "Continue", - "continue-signup": "Continue to sign in" - }, - "actions": { - "cancel": "Cancel", - "signing-in": "Signing you in...", - "submit": "Submit" - }, - "user": { - "email": "Email", - "new-password-confirm-placeholder": "Enter your new password again", - "new-password-placeholder": "Enter your new password", - "password": "Password", - "password-confirm": "Confirm Password", - "password-confirm-placeholder": "Enter your password again", - "password-current": "Current Password", - "password-current-placeholder": "Enter your current password", - "password-placeholder": "Enter a password", - "username": "Email or Username" - } - }, - "pages": { - "account-recovery": { - "account-recovery-code": "If you can’t access your mobile device to receive the two-factor\n verification code, you can use a verification code that will be sent\n to your email and the recovery code provided when you enabled\n two-factor authentication.", - "description": "We sent a login code to your email. Enter both the login code and the recovery code below", - "email-recovery-code-placeholder": "Enter login Code", - "lost-recovery-code": "If you can’t access your mobile device or recovery code you can request a reset of your authentication settings. For security reasons this can take 1-3 business days.", - "lost-recovery-code-button": "Request account reset", - "lost-recovery-code-heading": "Lost your mobile device and recovery codes?", - "recovery-code-placeholder": "Enter Recovery Code", - "start-recovery": "Start Recovery", - "title": "Two-Factor Account Recovery" - }, - "auth": { - "create-account": { - "button-label": "Create Account", - "button-loading": "Creating..." - } - }, - "auth-mfa": { - "errors": { - "BACKUP_CODE_INVALID": "Invalid Backup Code", - "EMAIL_CODE_INVALID": "Invalid Email Code" - }, - "having-problems": "Having Problems?", - "mfa-verification-sent": "A verification code has been sent to your mobile device{{number}}. This code is only valid for 3 minutes.", - "no-device-access": "Can't access your two-factor device", - "title": "SMS Two-Factor Authentication", - "totp-mfa-verification": "Enter the one-time password generated by your authentication app.", - "verification-code-placeholder": "Enter verification code" - }, - "auth-mfa-totp": { - "invalid-code": "Invalid Code", - "title": "Two-Factor Authentication" - }, - "forgot-password": { - "email": "Email Address", - "reset-password": "Reset Password", - "title": "Forgot Password" - }, - "forgot-password-verify": { - "password-error": { - "missing-lower": "The password must contain an lowercase letter.", - "missing-number": "The password must contain a number.", - "missing-special-char": "The password must contain at least one of the the following characters ", - "missing-upper": "The password must contain an uppercase letter.", - "passwords-do-not-match": "The passwords do not match", - "too-long": "The password must be no more than {{max_length}} characters long.", - "too-short": "The password must be at least {{min_length}} characters long." - }, - "password-rules": "Passwords must be {{min_length}}-{{max_length}} characters, contain a number, an uppercase and lowercase letter, and at least one of the the following characters ", - "password-rules-reduced": "Passwords must be {{min_length}}-{{max_length}} characters.", - "password-strength-html": "Password Strength: {{strength}}", - "password-strength-score": { - "0": "Very Weak", - "1": "Weak", - "2": "Fair", - "3": "Good", - "4": "Strong" - }, - "success-message": "Your password has been reset, please sign in", - "title": "Verify Your Account", - "verification-link-message": "An email verification link has been sent to you. Click the link and return here. Then press continue to sign in." - }, - "password-reset": { - "cognito-reset-password-required": "For security purposes, we require users to change their passwords periodically. A verification code has been sent to {{email}}, please check your email then enter the verification code and update your password.", - "signout-all-text": "Current sessions will remain active. If you have experienced a security breach, signing out everywhere is recommended. To sign out everywhere, sign in to app.remote.it and then go to the accounts page to sign out everywhere.", - "update-button": "Update Password", - "verification-code": "Verification Code", - "verification-code-message": "A verification code has been sent to {{email}}, please check your email then enter the verification code and update your password." - }, - "sign-in": { - "create-account": "Don't have an account yet?", - "forgot-password": "Forgot your password?", - "or": "OR", - "saml-link": "SAML Login", - "sign-in": "Sign In", - "sign-up-link": "Sign up for free!", - "signing-in": "Signing In...", - "user-password-link": "Username/Password Login" - }, - "sign-up": { - "title": "Sign Up" - }, - "update-password": { - "reset-password": "Update Password", - "title": "Update Password" - }, - "verify-account": { - "check-spam": "Check your spam", - "resend-confirmation": "Resend account confirmation.", - "resent-notice": "Email verification resent.", - "sending": "Sending...", - "verification-received-message": "Didn’t receive your verification link?" - } - } -} diff --git a/frontend/src/i18n/locales/en/notices.json b/frontend/src/i18n/locales/en/notices.json index aa11dc69b..04cf9fd63 100644 --- a/frontend/src/i18n/locales/en/notices.json +++ b/frontend/src/i18n/locales/en/notices.json @@ -9,6 +9,15 @@ "loginFailed": "Login failed.", "passwordChanged": "Password changed successfully." }, + "chat": { + "authRequired": "Agent authentication required — sign in to continue.", + "deleteFailed": "Could not delete the conversation — try again.", + "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": { "surveyFailed": "Connection survey submission failed. Please contact support." }, diff --git a/frontend/src/i18n/locales/en/platforms.json b/frontend/src/i18n/locales/en/platforms.json new file mode 100644 index 000000000..f0e9405c9 --- /dev/null +++ b/frontend/src/i18n/locales/en/platforms.json @@ -0,0 +1,132 @@ +{ + "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.", + "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", + "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.", + "instructions": "Install the Remote.It Jumpbox extension in Docker Desktop, open it, and enter this code to register." + }, + "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 85c3edabd..8998938f2 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", @@ -283,6 +284,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 +293,57 @@ "noticeTitle": "Aviso", "title": "Cambiar contraseña" }, + "chat": { + "approve": "", + "close": "", + "closeChat": "", + "deleteConfirmAction": "", + "deleteConfirmTitle": "", + "deleteConversation": "", + "deny": "", + "guideAgentBody": "", + "guideAgentTitle": "", + "guideComposeBody": "", + "guideComposeTitle": "", + "guideHistoryBody": "", + "guideHistoryTitle": "", + "history": "", + "historyEmpty": "", + "inputPlaceholder": "", + "interrupted": "", + "introBody": "", + "introTitle": "", + "newChat": "", + "newPrompt1": "", + "newPrompt2": "", + "newPrompt3": "", + "newSession": "", + "notLicensed": "", + "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 +461,7 @@ "close": "Cerrar", "confirm": "Confirmar", "confirmingEllipsis": "Confirmando...", + "continue": "", "delete": "Eliminar", "disable": "Deshabilitar", "done": "Listo", @@ -423,6 +478,7 @@ "saved": "Guardado", "saving": "Guardando", "savingEllipsis": "Guardando...", + "verify": "", "yes": "Sí" }, "connect": { @@ -448,26 +504,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 +984,7 @@ "dismissAll": "descartar todo" }, "header": { + "aiAgent": "", "back": "Atrás", "create": "", "deviceSearch": "Búsqueda de dispositivos", @@ -1040,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", @@ -1082,6 +1163,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,10 +1266,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.", "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", @@ -1517,6 +1626,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", @@ -2094,6 +2212,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", @@ -2146,8 +2283,20 @@ "testPage": { "addQueryHeader": "Agregar encabezado de consulta", "addQueryHeaderPlaceholder": "Ejemplo: \"key:value\"", + "agentURL": "", + "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", + "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}})", @@ -2155,6 +2304,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", @@ -2234,6 +2384,7 @@ }, "viewAsBanner": { "exit": "Salir del modo de vista como", + "supportSession": "Sesión de soporte — viendo como {{email}} hasta {{until}}. Los tokens llevan tu identidad; la persona puede ver y finalizar esta sesión.", "viewingAs": "Viendo como: {{email}}" } } diff --git a/frontend/src/i18n/locales/es/notices.json b/frontend/src/i18n/locales/es/notices.json index c8f540837..470f0bdb3 100644 --- a/frontend/src/i18n/locales/es/notices.json +++ b/frontend/src/i18n/locales/es/notices.json @@ -9,6 +9,15 @@ "loginFailed": "Error al iniciar sesión.", "passwordChanged": "Contraseña cambiada correctamente." }, + "chat": { + "authRequired": "Se requiere autenticación del agente — inicie sesión para continuar.", + "deleteFailed": "", + "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": { "surveyFailed": "No se pudo enviar la encuesta de conexión. Ponte en contacto con soporte." }, diff --git a/frontend/src/i18n/locales/es/platforms.json b/frontend/src/i18n/locales/es/platforms.json new file mode 100644 index 000000000..e8495d406 --- /dev/null +++ b/frontend/src/i18n/locales/es/platforms.json @@ -0,0 +1,132 @@ +{ + "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.", + "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", + "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.", + "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", + "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 6baca91e8..adcbe7672 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": "サービスをスキャン", @@ -277,6 +278,8 @@ }, "changePassword": { "currentPassword": "現在のパスワード", + "mfaCode": "", + "mfaPrompt": "", "noticeAfter": "他のセッションからサインアウトされることはありません。", "noticeBefore": "パスワードの変更では、", "noticeEmphasis": "自動的に", @@ -284,6 +287,55 @@ "noticeTitle": "注意", "title": "パスワードを変更" }, + "chat": { + "approve": "", + "close": "", + "closeChat": "", + "deleteConfirmAction": "", + "deleteConfirmTitle": "", + "deleteConversation": "", + "deny": "", + "guideAgentBody": "", + "guideAgentTitle": "", + "guideComposeBody": "", + "guideComposeTitle": "", + "guideHistoryBody": "", + "guideHistoryTitle": "", + "history": "", + "historyEmpty": "", + "inputPlaceholder": "", + "interrupted": "", + "introBody": "", + "introTitle": "", + "newChat": "", + "newPrompt1": "", + "newPrompt2": "", + "newPrompt3": "", + "newSession": "", + "notLicensed": "", + "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 +453,7 @@ "close": "閉じる", "confirm": "確認", "confirmingEllipsis": "確認中...", + "continue": "", "delete": "削除", "disable": "無効にする", "done": "完了", @@ -417,6 +470,7 @@ "saved": "保存しました", "saving": "保存中", "savingEllipsis": "保存中...", + "verify": "", "yes": "はい" }, "connect": { @@ -442,26 +496,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 +964,7 @@ "dismissAll": "すべて閉じる" }, "header": { + "aiAgent": "", "back": "戻る", "create": "", "deviceSearch": "デバイス検索", @@ -1020,11 +1097,13 @@ "title": "ライセンス" }, "licensingSetting": { + "expires": "", "notSubscribed": "未契約", "plan": "プラン", "renews": "更新" }, "limitSetting": { + "aiAgentAvailable": "", "emailSupport": "メールサポートが利用可能です", "evaluationPeriod": "サービスには{{period}}の評価期間が付与されます", "forumSupport": "フォーラムサポートのみ", @@ -1058,6 +1137,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,10 +1240,12 @@ "quitConfirm": "終了しても接続は閉じられません。", "scripting": "スクリプト", "settings": "設定", + "signedInOnBrowser": "このブラウザでサインイン済み", "signOut": "サインアウト", "signOutConfirm": "サインアウトすると、このデバイスの譲渡や他のユーザーのサインインが可能になります。すべての接続が停止します。", "support": "サポート", "supportForum": "サポートフォーラム", + "switchAccount": "", "testUI": "テストUI", "testUIConfirm": "アルファ機能を有効にすると、動作が不安定になることがあります。テストおよび開発目的のみを想定しています。", "thisDevice": "このデバイス", @@ -1479,6 +1586,15 @@ "noPartnersFound": "パートナーが見つかりません", "searchPlaceholder": "パートナーを検索..." }, + "passkeys": { + "add": "", + "added": "", + "defaultName": "", + "explainer": "", + "failed": "", + "needFactor": "", + "title": "" + }, "passwordStrengthInput": { "confirmNewPassword": "新しいパスワードを確認", "enterNewPassword": "新しいパスワードを入力", @@ -2024,6 +2140,23 @@ "chooseServices": "アクセスを許可するサービスを選択してください。", "services": "サービス" }, + "signIn": { + "autoStopped": "", + "button": "", + "cancel": "", + "errorExpired": "", + "errorRateLimited": "", + "errorRateLimitedWait_other": "", + "errorRefused": "", + "errorUnavailable": "", + "errorUnknown": "", + "errorUnreachable": "", + "redirecting": "", + "retry": "", + "subtitle": "", + "title": "", + "waiting": "" + }, "statusChip": { "connected": "接続済み", "idle": "アイドル", @@ -2072,8 +2205,20 @@ "testPage": { "addQueryHeader": "クエリヘッダーを追加", "addQueryHeaderPlaceholder": "例: \"key:value\"", + "agentURL": "", + "agentURLInvalid": "", + "aiAgent": "", + "apiTarget": "APIターゲット", + "backgroundWork": "", + "backgroundWorkOff": "", + "backgroundWorkOn": "", + "backgroundWorkUnknown": "", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", + "customAPITarget": "カスタム", + "customAPITargetHint": "認可サーバーに登録されていないURLを指定します。", + "customGraphQLURL": "カスタムGraphQL URL(詳細設定)", + "customWebSocketURL": "カスタムWebSocket URL(詳細設定)", "disableTestUI": "テストUIを無効にする", "disableTestUIHint": "アルファUIを再度有効にするには、alt-shiftを押しながらアバターメニューを選択する必要があります。", "featureLabel": "{{name}}(デフォルト: {{state}})", @@ -2081,6 +2226,7 @@ "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", "licensingOptions": "ライセンスオプション", + "mintError": "このターゲットはトークン発行時に拒否されました: {{error}}", "overrideDefaultAPIs": "デフォルトのAPIをオーバーライド", "overrideLicensesAndLimits": "ライセンスと制限をオーバーライド", "reset": "リセット", @@ -2154,6 +2300,7 @@ }, "viewAsBanner": { "exit": "表示モードを終了", + "supportSession": "サポートセッション — {{email}} として表示中({{until}} まで)。トークンにはあなたの ID が記録され、ユーザーはこのセッションを確認して終了できます。", "viewingAs": "表示中: {{email}}" } } diff --git a/frontend/src/i18n/locales/ja/notices.json b/frontend/src/i18n/locales/ja/notices.json index 15b1762be..e55f9711e 100644 --- a/frontend/src/i18n/locales/ja/notices.json +++ b/frontend/src/i18n/locales/ja/notices.json @@ -9,6 +9,15 @@ "loginFailed": "ログインに失敗しました。", "passwordChanged": "パスワードを変更しました。" }, + "chat": { + "authRequired": "エージェントの認証が必要です — サインインして続行してください。", + "deleteFailed": "", + "popupBlocked": "ポップアウトがブロックされました — このサイトのポップアップを許可してから、もう一度お試しください。", + "sessionExpired": "エージェントのセッションの期限が切れました — もう一度サインインして続行してください。", + "signInFailed": "エージェントのサインインに失敗しました — {{error}}", + "streamEnded": "", + "usageReset": "{{msg}} {{when}}にリセットされます。" + }, "connection": { "surveyFailed": "接続アンケートの送信に失敗しました。サポートにお問い合わせください。" }, diff --git a/frontend/src/i18n/locales/ja/platforms.json b/frontend/src/i18n/locales/ja/platforms.json new file mode 100644 index 000000000..619832062 --- /dev/null +++ b/frontend/src/i18n/locales/ja/platforms.json @@ -0,0 +1,132 @@ +{ + "advantech": { + "name": "Advantech", + "description": "Advantechシステム向けです。" + }, + "alpine": { + "name": "Alpine Linux", + "description": "Alpine Linuxベースのシステム向けです。" + }, + "amnimo": { + "name": "Amnimo" + }, + "android": { + "name": "Android", + "description": "Androidのスマートフォンまたはタブレット向けです。", + "instructions": "Play StoreからRemote.It ScreenViewアプリをインストールして開き、このコードを入力してデバイスを登録してください。" + }, + "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向けです。", + "instructions": "Docker DesktopにRemote.It Jumpbox拡張機能をインストールして開き、このコードを入力して登録してください。" + }, + "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/models/adminAddonLicenses.test.ts b/frontend/src/models/adminAddonLicenses.test.ts new file mode 100644 index 000000000..8182d903a --- /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 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('A') + expect(dispatch.adminAddonLicenses.fetch).toHaveBeenCalledTimes(1) + }) + + 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..a737ca576 --- /dev/null +++ b/frontend/src/models/adminAddonLicenses.ts @@ -0,0 +1,219 @@ +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 '.' + +/* 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. */ +const listRequest = latestWins() +const productsRequest = latestWins() + +const emptiedList = { customers: [], total: 0, hasMore: false, listStatus: 'idle' as const } + +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 or target empties the list: the rows on screen belong to the old one. + setProductId: (state, productId?: string) => + productId === state.productId ? state : { ...state, productId, ...emptiedList }, + setTarget: (state, target?: string) => (target === state.target ? state : { ...state, target, ...emptiedList }), + 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 => { + // One page of the current product's customers: `from` 0 replaces the list, anything else appends. + const loadPage = async (state: AdminAddonLicensesState, from: number) => { + const isLatest = listRequest.take() + dispatch.adminAddonLicenses.setListStatus('loading') + + const result = await graphQLAdminAddonCustomers( + state.productId!, + { from, 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 (!isLatest()) return + + const data = result === 'ERROR' ? undefined : result?.data?.data?.admin?.addonCustomers + if (!data) { + // On a later page the rows held stay; the page keeps its Load More for another try. + dispatch.adminAddonLicenses.setListStatus('failed') + return + } + const page = { customers: data.items || [], total: data.total || 0, hasMore: !!data.hasMore } + if (from) dispatch.adminAddonLicenses.appendCustomers(page) + else dispatch.adminAddonLicenses.setCustomers(page) + } + + return { + /* The product catalogue. Resolves to the fresh list, or undefined when nothing answered — the + products held stay, marked failed. */ + async fetchProducts(): Promise { + const isLatest = productsRequest.take() + dispatch.adminAddonLicenses.setProductsStatus('loading') + const result = await graphQLAdminAddonProducts() + if (!isLatest()) 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.invalidate() + dispatch.adminAddonLicenses.setTarget(target) + 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 : products[0]?.id + 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 + await loadPage(state, 0) + }, + + async fetchMore(_: void, rootState) { + const state = rootState.adminAddonLicenses + if (!state.productId || !state.hasMore || state.listStatus === 'loading') return + await loadPage(state, state.customers.length) + }, + + // Sign-out: nothing in flight may land in the next session's state. + async reset() { + listRequest.invalidate() + productsRequest.invalidate() + dispatch.adminAddonLicenses.resetState() + }, + } + }, +}) diff --git a/frontend/src/models/agents.ts b/frontend/src/models/agents.ts index 92bc10d25..180ea284f 100644 --- a/frontend/src/models/agents.ts +++ b/frontend/src/models/agents.ts @@ -1,18 +1,16 @@ 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 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 } const defaultState: IAgentsState = { @@ -20,7 +18,7 @@ const defaultState: IAgentsState = { fetching: false, updating: undefined, agents: [], - accessTokenTtlSeconds: 300, + needsReauth: false, } export default createModel()({ @@ -34,15 +32,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 +48,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/models/auth.test.ts b/frontend/src/models/auth.test.ts new file mode 100644 index 000000000..76b170a14 --- /dev/null +++ b/frontend/src/models/auth.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach, afterEach } 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. `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 }, + }) +) + +// 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, + 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: {} })) +vi.mock('../services/Network', () => ({ default: {} })) +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() }, 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('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() }, + } +} + +// 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' + +const effectsFor = (dispatch: any) => (authModel as any).effects(dispatch) + +beforeEach(() => { + oidcStart.mockReset() + signOutEverywhere.mockReset().mockResolvedValue({ status: 200, body: { ended: 1, pool: 'skipped' } }) + oidcActor.mockReset().mockReturnValue(null) + oidcGrantStale.mockReset() + oidcMcpDetailReady.mockReset().mockResolvedValue('mcp_type') +}) + +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 sessions (no signOutEverywhere)', async () => { + const dispatch = makeDispatch() + await effectsFor(dispatch).signOut(undefined, { auth: { backendAuthenticated: false } }) + expect(signOutEverywhere).not.toHaveBeenCalled() + // Local teardown still happens. + expect(dispatch.auth.signedOut).toHaveBeenCalledTimes(1) + }) +}) + +/* "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(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) + }) +}) + +/* 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] + ) + }) +}) + +/* 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(() => { + 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 904a2a8e0..f21923f8e 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -5,101 +5,217 @@ import network from '../services/Network' import browser from '../services/browser' import analytics from '../services/analytics' import { selectDeviceModelAttributes } from '../selectors/devices' -import { - CLIENT_ID, - MOBILE_CLIENT_ID, - CALLBACK_URL, - AUTH_API_URL, - COGNITO_USER_POOL_ID, - COGNITO_AUTH_DOMAIN, - REDIRECT_URL, - SIGNOUT_REDIRECT_URL, - API_URL, - DEVELOPER_KEY, - SIGN_OUT_BACKEND_TIMEOUT, -} from '../constants' -import { persistor } from '../store' +import { SIGN_OUT_BACKEND_TIMEOUT, SIGN_OUT_EVERYWHERE_TIMEOUT } from '../constants' +import { persistor, store } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' -import { CognitoUser } from '../cognito/types' -import { AuthService, ConfigInterface } from '../cognito/auth' +import { selfChangePassword, selfChallenge } from '../services/passportSelf' +import { signOutEverywhere } from '../services/permitteerAccount' +import { + oidcConfigured, + oidcSignedIn, + oidcClaims, + oidcStart, + oidcClearLocal, + oidcCompleteFromUrl, + oidcActivateAccount, + oidcActivationHint, + invalidateOidcToken, + oidcGrantStale, + oidcMcpDetailReady, + oidcActor, + oidcTakeSupportTicket, + oidcSelectKnownAccount, + oidcClearAutoStarts, + OidcError, + OidcErrorCode, +} from '../services/oidc' import { createModel } from '@rematch/core' import { RootModel } from '.' -import sleep from '../helpers/sleep' import zendesk from '../services/zendesk' -import axios from 'axios' import i18n from '../i18n' - -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 -} +import { withTimeout } from '../helpers/sleep' 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 - authService?: AuthService + /** 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 - mfaMethod: string - AWSUser: AWSUser } const defaultState: AuthState = { initialized: false, authenticated: false, backendAuthenticated: false, + signInFailed: false, signInError: undefined, + signInErrorCode: undefined, + signInRetryAfter: undefined, + signingIn: false, user: undefined, - authService: undefined, - mfaMethod: '', - AWSUser: { authProvider: '' }, } -const authServiceConfig = (): ConfigInterface => ({ - cognitoClientID: browser.isMobile ? MOBILE_CLIENT_ID : CLIENT_ID, - cognitoUserPoolID: COGNITO_USER_POOL_ID, - cognitoAuthDomain: COGNITO_AUTH_DOMAIN, - checkSamlURL: AUTH_API_URL + '/checkSaml', - cognitoRegion: 'US-WEST-2', - redirectURL: - browser.isPortal || browser.isElectron || browser.isMobile ? window.origin : window.origin + '/v1/callback/', - callbackURL: browser.isPortal ? window.origin : browser.isElectron || browser.isMobile ? REDIRECT_URL : CALLBACK_URL, - signoutCallbackURL: browser.isPortal - ? window.origin - : browser.isElectron || browser.isMobile - ? SIGNOUT_REDIRECT_URL - : CALLBACK_URL, +/* 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, + 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 = { + signInFailed: false, + signInError: undefined, + signInErrorCode: undefined, + signInRetryAfter: undefined, +} + export default createModel()({ state: defaultState, effects: dispatch => ({ - // silent suppresses the session-error toast for machine-triggered runs (a - // network reconnect), where the user didn't ask for this and may not even be - // looking at the app. See Controller.onNetworkConnect. - async init(options: { silent?: boolean } = {}, state) { + /* 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 + 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) { - console.log('AUTH SERVICE CONFIG', authServiceConfig()) - const authService = new AuthService(authServiceConfig()) - console.log('AUTH INIT', { authService }) - await sleep(500) - await dispatch.auth.set({ authService }) - await dispatch.auth.checkSession({ refreshToken: true, silent: options.silent }) + 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() + 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() + await dispatch.auth.healGrant() // refused by oidcStart in a support tab + } 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 = oidcActivationHint() + if (hint) await oidcStart({ prompt: 'none', loginHint: hint, auto: `activate:${hint}` }) + } + } else if (!oidcConfigured()) console.error('VITE_OAUTH_ISSUER is not configured') + } catch (error: any) { + console.error('AUTH INIT: sign-in completion failed', error) + // 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. Otherwise fall through + // to this branch's richer error mapping (signInFailure). + 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)) + } } 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. + * + * 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 (!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) + } + }, + /** 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 + * 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' }) + } catch (error) { + dispatch.auth.set(signInFailure(error)) + } + }, + /** 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)) return window.location.assign('/') + // 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). + if (await oidcSelectKnownAccount(sub)) return + await dispatch.auth.switchAccount() + }, + /** `auto` names a sign-in nobody clicked for (the web sign-in screen's own start) so the + * ledger in oidcStart can bound it; a refused one leaves the screen as it was. */ + async signIn(options?: { auto?: string }) { + dispatch.auth.set({ signingIn: true, ...signInCleared }) + try { + // 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. + if (!(await oidcStart({ prompt: 'select_account', auto: options?.auto }))) + dispatch.auth.set({ signingIn: false }) + } catch (error: any) { + console.error('SIGN IN FAILED', error) + dispatch.auth.set(signInFailure(error)) + } + }, async fetchUser(_: void) { const { auth } = dispatch const response = await graphQLLogin() @@ -107,7 +223,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() @@ -116,80 +232,80 @@ export default createModel()({ dispatch.ui.set({ errorMessage: i18n.t('notices:auth.loginFailed', { defaultValue: 'Login failed.' }) }) } }, - async changePassword(passwordValues: IPasswordValue, state): Promise { - const existingPassword = passwordValues.currentPassword - const newPassword = passwordValues.password - - try { - await state.auth.authService?.changePassword(existingPassword, newPassword) + // Native password change over the Passport self-API (Phase 2b): the current password + // 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 r = await selfChangePassword(passwordValues.currentPassword ?? '', passwordValues.password ?? '') + if (r.status === 'ok') { + dispatch.auth.set({ passwordChallenge: undefined }) dispatch.ui.set({ successMessage: i18n.t('notices:auth.passwordChanged', { defaultValue: 'Password changed successfully.' }), }) return true - } catch (error: any) { - const message = - error.code === 'NotAuthorizedException' - ? 'Current password is incorrect.' - : error.code === 'InvalidPasswordException' - ? error.message || 'New password does not meet the requirements.' - : error.code === 'LimitExceededException' - ? 'Too many attempts. Please try again later.' - : error.message || 'An unexpected error occurred. Please try again.' - dispatch.ui.set({ errorMessage: message }) + } + if (r.status === 'mfa' && r.challenge) { + dispatch.auth.set({ passwordChallenge: { challenge: r.challenge, hint: r.hint } }) return false } + dispatch.ui.set({ + errorMessage: + r.error === 'invalid_password' + ? 'Current password is incorrect.' + : r.error === 'weak_password' + ? r.error_description || 'New password does not meet the requirements.' + : r.error_description || 'An unexpected error occurred. Please try again.', + }) + 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, - Authorization: await getToken(), - }, - } - ) - dispatch.auth.setAWSUserEmail(email) + /** Answer the store's second-factor challenge raised by changePassword. */ + async completePasswordChallenge(code: string, state): Promise { + const pending = state.auth.passwordChallenge + if (!pending) return false + const r = await selfChallenge(pending.challenge, { code }) + if (r.status === 'ok') { + dispatch.auth.set({ passwordChallenge: undefined }) dispatch.ui.set({ - successMessage: i18n.t('notices:auth.emailModified', { defaultValue: 'Email modified successfully.' }), + successMessage: i18n.t('notices:auth.passwordChanged', { defaultValue: 'Password changed successfully.' }), }) - } else { - dispatch.ui.set({ errorMessage: i18n.t('notices:auth.invalidFormat', { defaultValue: 'Invalid format.' }) }) + return true } + // invalid_code re-arms the SAME step under a fresh handle — a typo never restarts. + dispatch.auth.set({ passwordChallenge: r.challenge ? { challenge: r.challenge, hint: pending.hint } : undefined }) + dispatch.ui.set({ + errorMessage: r.challenge ? 'That code didn’t match — try again.' : 'The request expired — start over.', + }) + return false }, - async forceRefreshToken(_: void, state) { - if (!state.auth.authService) return - await state.auth.authService.forceTokenRefresh() - }, - async checkSession(options: { refreshToken: boolean; silent?: boolean }, state) { - if (!state.auth.authService) return - try { - const result = await state.auth.authService.checkSignIn({ refreshToken: options.refreshToken }) - if (result.cognitoUser) { - await dispatch.auth.handleSignInSuccess(result.cognitoUser) - } else { - console.error('SESSION ERROR', result.error, result) - // still logged above - silent only withholds the user-facing toast - if (result.error?.message && !options.silent) dispatch.ui.set({ errorMessage: result.error.message }) - } - } catch (error) { - console.error('Check sign in error', error) + // 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: { 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 } - }, - async handleSignInSuccess(cognitoUser: CognitoUser): Promise { - if (cognitoUser?.username) { - await dispatch.auth.set({ authenticated: true }) - await dispatch.auth.fetchUser() - await dispatch.mfa.getAWSUser() - console.log('AUTHENTICATED SUCCESS') + if (!oidcSignedIn() && state.auth.authenticated) { + console.error('SESSION ERROR: session gone (refresh family dead or signed out)') + await dispatch.auth.signedOut() } }, + 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 }) + await dispatch.auth.fetchUser() + console.log('AUTHENTICATED SUCCESS') + }, async backendAuthenticated(_: void, state) { if (state.auth.authenticated) { dispatch.auth.set({ backendAuthenticated: true }) @@ -202,21 +318,36 @@ 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 }) - //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 @@ -251,6 +382,11 @@ export default createModel()({ if (!browser.hasBackend) dispatch.auth.appReady() }, 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 (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 @@ -272,11 +408,27 @@ export default createModel()({ /** * Gets called when the backend signs the user out */ - async signedOut(_: void, state) { + async signedOut(_: void) { + // 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. + await dispatch.chat.signOut() await persistor.purge() - // purge has to happen before signOut because signOut can trigger a reload - await state.auth.authService?.signOut() - await dispatch.auth.set({ user: undefined }) + // 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; 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 + 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.agents.reset() dispatch.user.reset() dispatch.organization.reset() dispatch.networks.reset() @@ -295,13 +447,13 @@ export default createModel()({ dispatch.files.reset() dispatch.jobs.reset() dispatch.tags.reset() - dispatch.mfa.reset() dispatch.ui.reset() dispatch.products.reset() dispatch.partnerStats.reset() 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 @@ -318,19 +470,48 @@ export default createModel()({ Controller.close() }, async globalSignOut() { - const Authorization = await getToken() - const response = await axios.get(`${AUTH_API_URL}/globalSignout`, { - headers: { Authorization }, - }) - console.log(`globalSignOut: `, response) + // "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 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) + } catch (error) { + console.warn('SIGN OUT EVERYWHERE FAILED', error) + } dispatch.auth.signOut() }, }), 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 new file mode 100644 index 000000000..ca0d5ce6b --- /dev/null +++ b/frontend/src/models/chat.test.ts @@ -0,0 +1,544 @@ +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, + 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(), + 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 {}, +})) +vi.mock('../services/chatPopout', () => ({ + broadcastChatSignout: vi.fn(), + 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 } })) + +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 = () => ({ + chat: { + set: vi.fn(), + stop: vi.fn(), + clearConversation: vi.fn(), + loadConversations: vi.fn(), + newConversation: vi.fn(), + // 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() }, +}) +// The wider snapshot send() reads (resolveChatOrg looks at the user and memberships) +const sendable = (chat: Record = {}) => ({ + ...current(chat), + 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. +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 = {}) => ({ + 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() + deleteConversation.mockReset() + streamChat.mockReset() + confirmTool.mockReset() + backgroundDisable.mockReset().mockResolvedValue(undefined) + listConversations.mockReset() + 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 +// 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 () => { + const a = deferred() + fetchConversation.mockImplementationOnce(() => a.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const openA = fx.openConversation('A', current()) + await fx.send('hello', sendable()) // the composer stays enabled during a pick; a send commits + a.resolve(remote) + await openA + 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) + 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')) + }) + + /* 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 + 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) + }) +}) + +/* 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.endTurn).toHaveBeenCalled() // turnEnded clears the pending approval + }) + + 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.endTurn).toHaveBeenCalled() + }) +}) + +/* 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', () => { + 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.endTurn).toHaveBeenCalledWith('notices:chat.streamEnded') + }) +}) + +/* 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: {}, accounts: { activeId: '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. + Every such event — a pick, a New Chat, a send, a handoff — advances the generation the + sync took its ticket from; that ticket is the whole check. */ +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 () => { + const sync = deferred() + fetchConversation.mockImplementationOnce(() => sync.promise) + const dispatch = makeDispatch() + const fx = effectsFor(dispatch) + const syncing = fx.syncTranscript(undefined, current()) + await fx.newConversation() // a New Chat while the fetch was in flight + sync.resolve(remote) + await syncing + expect(fetchConversation).toHaveBeenCalledWith('a') + expect(dispatch.chat.set).not.toHaveBeenCalled() + }) + + it('drops the response when a turn started mid-fetch', 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()) + sync.resolve(remote) + await syncing + expect(dispatch.chat.set).not.toHaveBeenCalledWith(expect.objectContaining({ messages: remoteAsLocal })) + }) + + it('drops the response when the other window handed its conversation over mid-fetch', async () => { + const sync = deferred() + fetchConversation.mockImplementationOnce(() => sync.promise) + const dispatch = { ...makeDispatch(), chat: { ...makeDispatch().chat, adoptTranscript: vi.fn() } } + const fx = effectsFor(dispatch) + const syncing = fx.syncTranscript(undefined, current()) + await fx.adoptHandoff({ messages: [], conversationId: 'b', title: '' }) + sync.resolve(remote) + await syncing + expect(dispatch.chat.adoptTranscript).toHaveBeenCalled() + 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() + }) +}) + +/* 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({}) + }) +}) + +/* 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 new file mode 100644 index 000000000..ebfed8c55 --- /dev/null +++ b/frontend/src/models/chat.ts @@ -0,0 +1,579 @@ +import { createModel } from '@rematch/core' +import { RootModel } from '.' +import { + streamChat, + confirmTool, + backgroundDisable, + fetchConversation, + listConversations, + deleteConversation, + fetchUsage, + UsageLimitError, + AgentStreamEndedError, + type ConversationSummary, + type Usage, + agentHealth, + AgentAuthError, + AgentHealth, + OrgSelection, +} from '../services/agent' +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 +import { store } from '../store' +import type { State } from '../store' +import { CHAT_PANEL_WIDTH } from '../constants' +import i18n from '../i18n' +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 + name: string + input: Record + status: 'running' | 'done' | 'error' + result?: string +} + +export type ChatAssistantMessage = { role: 'assistant'; text: string; toolCalls: ChatToolCall[]; interrupted?: boolean } +export type ChatTranscriptMessage = { role: 'user'; text: string } | ChatAssistantMessage + +export type IChatState = { + open: boolean + /** Docked column width in px — drag-resized, persisted */ + width: number + messages: ChatTranscriptMessage[] + conversationId: string + turnId: string + 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 */ + /** 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 + health: 'unknown' | AgentHealth +} + +export const defaultChatState: IChatState = { + open: true, + width: CHAT_PANEL_WIDTH, + messages: [], + conversationId: '', + turnId: '', + title: '', + conversations: [], + usage: null, + ownerId: '', + poppedOut: false, + streaming: false, + pendingConfirmation: null, + error: null, + health: 'unknown', +} + +/* 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 { + 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() + return { id: orgId, name } +} + +/* The handoff payload the main window and popout exchange — one definition so + the two sides can never serialize different field sets */ +export const toChatHandoff = (chat: IChatState): ChatHandoff => ({ + messages: chat.messages, + conversationId: chat.conversationId, + title: chat.title, +}) + +/* A turn is live while the agent streams or waits on a tool confirmation: no send, no handoff + between windows until it ends. */ +export const selectTurnActive = (state: { chat: IChatState }) => + state.chat.streaming || !!state.chat.pendingConfirmation + +/* The server transcript in the local shape: tool calls are not replayed, only the text. */ +const toTranscript = (messages: Array<{ role: string; content: string }>): ChatTranscriptMessage[] => + messages.map(m => + m.role === 'assistant' ? { role: 'assistant', text: m.content, toolCalls: [] } : { role: 'user', text: m.content } + ) + +const authRequiredError = () => + i18n.t('notices:chat.authRequired', { + defaultValue: 'The agent refused this session\u2019s credentials — refresh permissions to continue.', + }) +const sessionExpiredError = () => + i18n.t('notices:chat.sessionExpired', { + defaultValue: 'The agent lost its authority mid-turn — your session may have been revoked or refreshed. Try again.', + }) + +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 +/* 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 + 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. */ +const healthProbe = latestWins() +const listLoad = latestWins() +const usageLoad = latestWins() + +export default createModel()({ + state: { ...defaultChatState }, + effects: dispatch => ({ + async send(text: string, state) { + if (selectTurnActive(state)) return + // 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({ + 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) }), + }) + 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) + const org = resolved ? { ...resolved, name: resolved.name || 'Organization' } : undefined + // Coalesce text deltas: one dispatch per ~50ms window instead of one + // per SSE chunk, so streaming doesn't re-render the app per token + let deltaBuffer = '' + let flushTimer: number | null = null + 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 = '' + } + } + try { + await streamChat({ + conversationId, + text, + org, + signal: controller.signal, + onEvent: event => { + 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 { + // 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() + 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.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 + // 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.endTurn((error as Error).message) + } finally { + flushDeltas() + 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) { + const pending = state.chat.pendingConfirmation + if (!pending) return + // Clear synchronously so a double click (or an Approve chased by a + // Deny) can't post a second, contradictory decision while in flight + dispatch.chat.set({ pendingConfirmation: null }) + try { + await confirmTool({ + turnId: state.chat.turnId, + toolUseId: pending.toolUseId, + approved, + }) + } catch (error) { + // Restore the card so the decision isn't lost with the error + dispatch.chat.set({ + pendingConfirmation: pending, + error: error instanceof AgentAuthError ? authRequiredError() : (error as Error).message, + }) + } + }, + 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 + // 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 + 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 + 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() { + nextGeneration() // a New Chat outranks any pick or sync 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(_: void, state) { + if (selectTurnActive(state)) return + // 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). The URL + // is the ONE carrier: the popout resolves its chat org from that scope exactly as this + // window does from the sidebar, so the two can never name different orgs. + if (!openChatPopout(state.accounts.activeId || undefined)) + dispatch.chat.set({ + error: i18n.t('notices:chat.popupBlocked', { + defaultValue: 'Pop out was blocked — allow popups for this site and try again.', + }), + }) + }, + /* The other window's conversation replaces this one: an event that makes every load in flight + unwanted, so it takes a new generation like a pick or a New Chat does. */ + async adoptHandoff(payload: ChatHandoff) { + nextGeneration() + dispatch.chat.adoptTranscript(payload) + }, + /* Hand the conversation back to the main window and close this popout. + Reads the handoff after stop() so the final flushed text is included. */ + async popIn(_: void, state) { + if (selectTurnActive(state)) return + await dispatch.chat.stop() + closePopoutWithHandback(toChatHandoff(store.getState().chat)) + }, + 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 + // 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.take() + const health = await agentHealth() + 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. 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) { + dispatch.chat.set({ health: 'unauthorized', ...(error ? { error } : {}) }) + }, + /* 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, a history pick, a send or a + // handoff while it was in flight has advanced the generation, and applying against that + // snapshot would land the OLD transcript in the new conversation (or repopulate one just + // cleared). Re-read the LIVE store for the comparison below. + if (ticket !== generation) return + const current = store.getState().chat + const messages = toTranscript(remote.messages) + // 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 = 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 !== current.title) dispatch.chat.set({ title }) + } 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 + (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() { + // 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() + }, + /* 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() + dispatch.chat.set({ ownerId: userId, conversations: [], usage: null }) + }, + /* 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.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.take() + try { + const conversations = await listConversations() + if (isLatest()) dispatch.chat.set({ conversations }) + } catch { + /* offline or refused — 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() + // Out-of-order guard: pick A, then B, and A's fetch lands last — A must not replace B. Nor + // 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 + 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. Unless the + // user has already moved on — then it is only noise about a thread they left. + if (superseded()) return + dispatch.chat.set({ error: error instanceof AgentAuthError ? authRequiredError() : (error as Error).message }) + return + } + if (!remote) { + // 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: '', + title: remote.title || '', + streaming: false, + pendingConfirmation: null, + error: null, + messages: toTranscript(remote.messages), + }) + }, + /* Delete a conversation for real (D9). If it's the one on screen, clear to a new chat. */ + 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. 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 { + /* a rejected request is the same failure as a refused one */ + } + if (!deleted) { + dispatch.chat.set({ + error: i18n.t('notices:chat.deleteFailed', { + defaultValue: 'Could not delete the conversation — try again.', + }), + }) + return + } + // 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: 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 + // 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 + 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 + // survived sign-out. Awaiting lets the revoke finish while the tokens are still valid; the + // 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 withTimeout( + backgroundDisable().catch(() => {}), + 3000 + ) + }, + }), + 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 + }, + 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) { + state.streaming = false + state.pendingConfirmation = null + state.error = null + state.health = 'unknown' + return state + }, + /* Hand-off: replace the conversation with the other window's copy (adoptHandoff advances the + generation first — a load in flight for the old conversation must not land on this one). */ + adoptTranscript(state: IChatState, payload: ChatHandoff) { + state.messages = payload.messages + state.conversationId = payload.conversationId + state.title = payload.title + return state + }, + clearConversation(state: IChatState) { + state.messages = [] + state.conversationId = '' + state.turnId = '' + state.title = '' + state.streaming = false + state.pendingConfirmation = null + state.error = null + return state + }, + reset() { + backgroundRevokedFor = null // the next sign-in cycle gets its own revoke + return { ...defaultChatState } + }, + }, +}) 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/connections.ts b/frontend/src/models/connections.ts index 9f3dc1afa..8f3b076b7 100644 --- a/frontend/src/models/connections.ts +++ b/frontend/src/models/connections.ts @@ -2,8 +2,17 @@ import sleep from '../helpers/sleep' import browser from '../services/browser' import structuredClone from '@ungap/structured-clone' import { createModel } from '@rematch/core' -import { parse as urlParse } from 'url' import { alphaSort, pickTruthy } from '../helpers/utilHelper' + +// Browser-native replacement for node's url.parse (which rode in on a dependency shim +// that left with Amplify): same host/hostname/port fields, empty object on bad input. +const urlParse = (value?: string): { host?: string; hostname?: string; port?: string } => { + try { + return value ? new URL(value) : {} + } catch { + return {} + } +} import { DEFAULT_CONNECTION, IP_PRIVATE } from '@common/constants' import { REGEX_HIDDEN_PASSWORD, CERTIFICATE_DOMAIN } from '../constants' import { diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index 140dfea56..5948a90eb 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' @@ -13,6 +14,8 @@ import backend from './backend' 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' @@ -22,7 +25,6 @@ import jobs from './jobs' import keys from './keys' import labels from './labels' import logs from './logs' -import mfa from './mfa' import networks from './networks' import organization from './organization' import partnerStats from './partnerStats' @@ -42,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 @@ -50,6 +53,8 @@ export interface RootModel extends Models { billing: typeof billing binaries: typeof binaries bluetooth: typeof bluetooth + chat: typeof chat + chatLive: typeof chatLive connections: typeof connections contacts: typeof contacts devices: typeof devices @@ -59,7 +64,6 @@ export interface RootModel extends Models { keys: typeof keys labels: typeof labels logs: typeof logs - mfa: typeof mfa networks: typeof networks organization: typeof organization partnerStats: typeof partnerStats @@ -80,6 +84,7 @@ export const models: RootModel = { adminPartners, adminUsers, adminEnterpriseLicenses, + adminAddonLicenses, adminNotices, announcements, applicationTypes, @@ -88,6 +93,8 @@ export const models: RootModel = { billing, binaries, bluetooth, + chat, + chatLive, connections, contacts, devices, @@ -97,7 +104,6 @@ export const models: RootModel = { keys, labels, logs, - mfa, networks, organization, partnerStats, diff --git a/frontend/src/models/mfa.ts b/frontend/src/models/mfa.ts deleted file mode 100644 index a3cb14faf..000000000 --- a/frontend/src/models/mfa.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { createModel } from '@rematch/core' -import { AUTH_API_URL, DEVELOPER_KEY } from '../constants' -import { getToken } from '../services/remoteit' -import { RootModel } from '.' -import axios from 'axios' -import i18n from '../i18n' - -export type IMfa = { - mfaMethod: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'NO_MFA' - verificationCode: string - backupCode?: string - showPhone: boolean - showMFASelection: boolean - showVerificationCode: boolean - showSMSConfig: boolean - lastCode: string | null - totpVerificationCode: string - showAuthenticatorConfig: boolean - showEnableSelection: boolean - error: string | null -} - -const defaultState: IMfa = { - mfaMethod: 'NO_MFA', - verificationCode: '', - backupCode: undefined, - showPhone: false, - showMFASelection: false, - showVerificationCode: false, - showSMSConfig: false, - lastCode: null, - totpVerificationCode: '', - showAuthenticatorConfig: false, - showEnableSelection: false, - error: null, -} - -export default createModel()({ - state: { ...defaultState }, - effects: dispatch => ({ - async getAWSUser(_: void, state) { - const userInfo = await state.auth.authService?.currentUserInfo() - if (!userInfo) { - console.error('Could not getAWSUser', userInfo) - return - } - const response = await axios.get(`${AUTH_API_URL}/mfaPref`, { - headers: { - developerKey: DEVELOPER_KEY, - Authorization: await getToken(), - }, - }) - dispatch.mfa.set({ mfaMethod: response.data.MfaPref }) - if (userInfo.attributes) { - delete userInfo.attributes['identities'] - delete userInfo.attributes['sub'] - } - const AWSUser = { - ...state.auth.AWSUser, - ...userInfo.attributes, - authProvider: userInfo.username?.toLowerCase().includes('google') ? 'Google' : '', - } - await dispatch.auth.set({ AWSUser }) - }, - - async setMFAPreference(mfaMethod: IMfa['mfaMethod']) { - try { - const response = await axios.post( - `${AUTH_API_URL}/mfaPref`, - { MfaPref: mfaMethod }, - { headers: { developerKey: DEVELOPER_KEY, Authorization: await getToken() } } - ) - dispatch.mfa.set({ mfaMethod: response.data.MfaPref, backupCode: response.data.backupCode }) - if (response.data.MfaPref !== 'NO_MFA') { - dispatch.ui.set({ - successMessage: i18n.t('notices:mfa.enabled', { - defaultValue: 'Two-factor authentication enabled successfully.', - }), - }) - } - console.log('SET MFA PREFERENCE', response) - } catch (error) { - if (error instanceof Error) { - dispatch.ui.set({ - errorMessage: i18n.t('notices:mfa.enableError', { - error: error.message, - defaultValue: 'Two-factor authentication enabled error: {{error}}', - }), - }) - } - } - }, - - async updatePhone(phone: string, state) { - try { - await state.auth.authService?.updateCurrentUserAttributes({ phone_number: phone.replace(/\s+/g, '') }) - await dispatch.mfa.getAWSUser() - await state.auth.authService?.verifyCurrentUserAttribute('phone_number') - await dispatch.mfa.setMFAPreference('NO_MFA') - dispatch.ui.set({ - successMessage: i18n.t('notices:mfa.verificationSent', { defaultValue: 'Verification sent.' }), - }) - return true - } catch (error) { - console.error(error) - if (error instanceof Error) { - dispatch.ui.set({ - errorMessage: i18n.t('notices:mfa.updatePhoneError', { - error: error.message, - defaultValue: 'Update phone error: {{error}}', - }), - }) - } - } - }, - - async verifyPhone(verificationCode: string, state) { - try { - await state.auth.authService?.verifyCurrentUserAttributeSubmit('phone_number', verificationCode) - await dispatch.mfa.setMFAPreference('SMS_MFA') - await dispatch.mfa.getAWSUser() - } catch (error) { - console.error(error) - if (error instanceof Error) { - dispatch.ui.set({ - errorMessage: i18n.t('notices:mfa.phoneVerificationError', { - error: error.message, - defaultValue: 'Phone verification error: {{error}}', - }), - }) - } - } - }, - - async getTotpCode(_: void, state) { - return state.auth.authService?.setupTOTP() - }, - - async verifyTotpCode(code: string, state) { - try { - await state.auth.authService?.verifyTotpToken(code) - } catch (error) { - console.error(error) - if (error instanceof Error) { - dispatch.ui.set({ - errorMessage: i18n.t('notices:mfa.invalidTotp', { - error: error.message, - defaultValue: 'Invalid TOTP Code. ({{error}})', - }), - }) - } - return - } - await dispatch.mfa.setMFAPreference('SOFTWARE_TOKEN_MFA') - }, - }), - reducers: { - reset(state: IMfa) { - state = { ...defaultState } - return state - }, - set(state: IMfa, params: Partial) { - Object.keys(params).forEach(key => (state[key] = params[key])) - return state - }, - }, -}) 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/models/ui.ts b/frontend/src/models/ui.ts index ca4c79cbb..590ee52ec 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -48,6 +48,8 @@ export type UIState = { apiGraphqlURL?: IPreferences['apiGraphqlURL'] webSocketURL?: IPreferences['webSocketURL'] apiURL?: IPreferences['apiURL'] + // Test UI: point the Remote.It AI chat at a deployed agent (https only) + agentURL?: string } layout: ILayout silent: string | null @@ -119,7 +121,6 @@ export type UIState = { showDesktopNotice: boolean scriptForm?: IFileForm scriptRunForms: ILookup - viewAsUser: { id: string; email: string } | null logsFilters: LogsFiltersByAccount announcementPresentationTest?: number } @@ -213,7 +214,6 @@ export const defaultState: UIState = { showDesktopNotice: true, scriptForm: undefined, scriptRunForms: {}, - viewAsUser: null, logsFilters: {}, announcementPresentationTest: undefined, } diff --git a/frontend/src/models/user.ts b/frontend/src/models/user.ts index 4964d8e7d..8355ee93c 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 @@ -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: data?.created ? new Date(data.created) : defaultState.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() { @@ -89,7 +97,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/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/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx new file mode 100644 index 000000000..7764a3caf --- /dev/null +++ b/frontend/src/pages/AdminAddonLicensesPage/AdminAddonLicensesListPage.tsx @@ -0,0 +1,472 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material' +import { ADMIN_ADDONS_ROUTE } from '../../constants' +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' +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. */ + +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 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 [searchInput, setSearchInput] = useState(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) + + // 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. 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(() => { + // 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: the URL follows the model's choice. + useEffect(() => { + 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(() => { + 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/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/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index 34f2f8f8c..01b3f8cf8 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, PORTAL_URL } from '../../constants' import { Dispatch, State } from '../../store' -import browser from '../../services/browser' -import { windowOpen } from '../../services/browser' +import browser, { 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,25 @@ 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), 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). 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(origin)}` + ) } return ( @@ -77,14 +83,16 @@ export const AdminUserDetailPage: React.FC = () => { bodyProps={{ verticalOverflow: true }} header={ - - + + , key: string) => { + const next = new Set(set) + next.has(key) ? next.delete(key) : next.add(key) + return next +} +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 }>() @@ -25,10 +66,18 @@ 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) + + // 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 [reachEdit, setReachEdit] = useState<{ all: boolean; ids: Set } | null>(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) useEffect(() => { dispatch.agents.init() @@ -61,7 +110,125 @@ export const ConnectedAppDetailPage: React.FC = () => { ) } - const name = agent.clientName || agent.clientId + const name = agent.app || agent.clientId + const reach = agent.revokeReach + 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(scopes) + // The grant's reach (one scope constraint per grant; every scoped group carries the same) + 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 = + reachEdit !== null && + reachGroup !== null && + (reachEdit.all !== reachGroup.all || + !sameSet( + reachEdit.ids, + reachGroup.accounts.map(a => a.id) + )) + const dirty = + (keepEdit !== null && + !sameSet( + keepEdit, + actions.map(a => a.key) + )) || + (scopeEdit !== null && !sameSet(scopeEdit, scopes)) || + reachDirty + const toggleAction = (key: string) => { + if (!agent.active || saving) return + setKeepEdit(toggled(kept, key)) + } + const toggleScope = (sc: string) => { + if (!agent.active || saving) return + setScopeEdit(toggled(scopesKept, sc)) + } + const toggleReachAll = () => { + // 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. + 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 + // 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. + 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. + 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')] + : []), + ...(reachNow && !reachNow.all ? [...reachNow.ids].filter(outsideCeiling).map(reachLabel) : []), + ] + const discardEdits = () => { + setKeepEdit(null) + setScopeEdit(null) + setReachEdit(null) + setError(null) + } + + const save = async () => { + setSaving(true) + 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) + // 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) { + setSaving(false) + setError( + (r.body as any)?.error_description || t('connectedAppDetailPage.saveFailed', 'That change could not be saved.') + ) + return + } + await dispatch.agents.fetch() + discardEdits() + setSaving(false) + } return ( { <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} + {!agent.active ? ( + <Chip + size="small" + label={t('connectedAppDetailPage.revoked', 'revoked')} + sx={{ marginLeft: 1.5, verticalAlign: 'middle' }} + /> + ) : null} - - - {name} {t('connectedAppDetailPage.signOutBefore', 'will be signed out. New access is blocked immediately; any session already in progress ends within')}{' '} - {accessWindow(ttl)}. - - - {t('connectedAppDetailPage.requestAgain', 'It can request access again by signing in.')} - - - ), - }} - onClick={async () => { - await dispatch.agents.revoke(agent.clientId) - back() - }} - /> } > {t('connectedAppDetailPage.permissions', 'Permissions')} - {agent.capabilities.length ? ( + {allActions.length ? ( <> - {t('connectedAppDetailPage.grantedWhenSignedIn', { - name, - defaultValue: 'Granted when {{name}} signed in. To change them, revoke access and have it sign in again.', - })} + {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.capabilities.map(scope => ( - - ))} + {groups.map((group, i) => { + if (!group.actions.length) return null + const where = + group.resourceLabel && group.resourceLabel !== '(all resources)' ? ` — ${group.resourceLabel}` : '' + // 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 => { + 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 ( + toggleAction(action.key)} + 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 + } + /> + ) + }) + return ( + + + {group.typeLabel} + {where} + {group.apiHost ? ( + + {group.apiHost} + + ) : null} + + {pieces.length > 1 + ? pieces.map(piece => ( + + {piece ?? 'General'} + {chips(group.actions.filter(a => (a.piece ?? null) === piece))} + + )) + : chips(group.actions)} + {group.reach && reachNow ? ( + + {t('connectedAppDetailPage.accounts', 'Accounts')} + + {group.reach.ceilingAll || group.reach.offerAll ? ( + + ) : null} + {reachIds.map(id => { + const label = reachLabel(id) + const outside = outsideCeiling(id) + return ( + toggleReachId(id)} + label={ + outside + ? t('connectedAppDetailPage.addAccount', { label, defaultValue: '{{label}} — add' }) + : label + } + /> + ) + })} + + + ) : null} + {sharedLimit ? ( + + {sharedLimit} + + ) : null} + + ) + })} + {scopes.length ? ( + <> + + {t('connectedAppDetailPage.signInScopes', 'Sign-in scopes')} + + {scopes.map(sc => { + const on = scopesKept.has(sc) + return ( + toggleScope(sc)} label={sc} /> + ) + })} + + ) : 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} + 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}}', + })} + + ), + }} + /> + + + ) : null} - ) : ( + ) : null} + {(agent.scopeGroups ?? []).map((group, i) => ( + + + {group.api} + + {group.actions.map(action => ( + + ))} + + ))} + {!allActions.length && !(agent.scopeGroups ?? []).length && ( {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 +476,96 @@ 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')} - + {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: 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/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index 041a6dab8..627542255 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' @@ -35,13 +35,31 @@ export const DevicesPage: React.FC = ({ restore, select }) => { // has actually finished - a switched-to account swaps in an empty, unloaded model. 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. 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 + // 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) { + if (shouldRedirect && !devices.length && actedFor.current !== activeAccountId) { + actedFor.current = activeAccountId if (defaultAccountId) accounts.select(defaultAccountId) else history.push('/add') } - }, [initialized, history]) + }, [initialized, shouldRedirect, devices.length, defaultAccountId, activeAccountId, history, accounts]) return ( 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 && ( { const { t } = useTranslation() @@ -22,9 +24,20 @@ 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/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index a5af5d845..a9ab8fe01 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -1,19 +1,36 @@ -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, + GRAPHQL_API, + OAUTH_AGENT_RESOURCE, + CLOUD_TREE_RE, + LEGACY_GRAPHQL_RE, + LEGACY_EVENTS_RE, + cloudTreeUrls, + resourceForApiURL, +} from '../constants' import { Dispatch, State } from '../store' +import { UIState } from '../models/ui' import { Typography, List, ListItem, Divider } from '@mui/material' import { getApiURL, getWebSocketURL } from '../helpers/apiHelper' -import { selectLimitsLookup, selectLimits } from '../selectors/organizations' +import { bindableResources } from '../services/permitteerAccount' +import { oidcAccessToken } from '../services/oidc' +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 { useChatEnabled } from '../hooks/useChatEnabled' 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' +import sleep from '../helpers/sleep' export const TestPage: React.FC = () => { const { t } = useTranslation() @@ -22,12 +39,130 @@ export const TestPage: React.FC = () => { const { tests, informed } = useSelector((state: State) => state.plans) const apis = useSelector((state: State) => state.ui.apis) const testUI = useSelector((state: State) => state.ui.testUI) - 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 } }) - 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). + // 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('') + 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.) + // 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(() => { + 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. + for (let i = 0; i < 30; i++) { + await sleep(2000) + if (await backgroundStatus()) break + } + setBackgroundEnrolled(await backgroundStatus()) + } + async function disableBackground() { + await backgroundDisable() + setBackgroundEnrolled(await backgroundStatus()) + } + useEffect(() => { + bindableResources().then(setTargets) + }, []) + + // `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(CLOUD_TREE_RE) + if (cloud) { + const key = `cloud:${cloud[1] || 'prod'}` + pairs.set(key, { key, name: target.name, ...cloudTreeUrls(target.identifier), resources: [target.identifier] }) + continue + } + 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}` + const pair = at(key, stage) + if (gql) { + pair.graphql = target.identifier + pair.name = target.name + } else pair.ws = target.identifier + pair.resources = [...pair.resources, target.identifier] + pairs.set(key, 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. + // 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)) + + async function selectCustom() { + setMintError('') + setCustomMode(true) + await setAPIPreferences({ + switchApi: true, + apiGraphqlURL: apis.apiGraphqlURL || getApiURL() || '', + webSocketURL: apis.webSocketURL || getWebSocketURL() || '', + }) + } + + async function selectStage(pair: StagePair) { + setMintError('') + setCustomMode(false) + const isDefault = pair.graphql === GRAPHQL_API + await setAPIPreferences({ + switchApi: !isDefault, + apiGraphqlURL: pair.graphql!, + ...(pair.ws ? { webSocketURL: pair.ws } : {}), + }) + 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. + if (!isDefault) for (const resource of pair.resources) await oidcAccessToken(resource) + emit('binaries/install') + cloudSync.all() + } catch (error) { + setMintError(error instanceof Error ? error.message : String(error)) + } } return ( @@ -95,27 +230,52 @@ export const TestPage: React.FC = () => { hideIcon /> + - { - setAPIPreference('switchApi', !apis.switchApi) - emit('binaries/install') - }} - toggle={!!apis.switchApi} + {t('testPage.apiTarget', 'API Target')} + + {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 setAPIPreferences({ apiGraphqlURL: url }) + try { + await oidcAccessToken(resourceForApiURL(url)) + } catch (error) { + setMintError(error instanceof Error ? error.message : String(error)) + } emit('binaries/install') cloudSync.all() }} @@ -123,12 +283,12 @@ export const TestPage: React.FC = () => { /> { - setAPIPreference('webSocketURL', url) + setAPIPreferences({ webSocketURL: url.toString() }) emit('binaries/install') }} hideIcon @@ -137,28 +297,78 @@ export const TestPage: React.FC = () => { + + {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. + setAPIPreferences({ agentURL: url === OAUTH_AGENT_RESOURCE ? '' : url }) + }} + hideIcon + /> + {!!agentError && ( + + + {agentError} + + + )} + + + + + + )} {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] } }) + } + /> + ))} /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 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..0139880b3 100644 --- a/frontend/src/platforms/android-screenview/index.tsx +++ b/frontend/src/platforms/android-screenview/index.tsx @@ -21,8 +21,7 @@ platforms.register({ types: { 1213: 'Android ScreenView' }, services: [{ application: 48 }], 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..74ff0c0ce --- /dev/null +++ b/frontend/src/platforms/catalogue.generated.json @@ -0,0 +1,378 @@ +{ + "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.", + "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": [ + { + "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.", + "instructions": "Install the Remote.It Jumpbox extension in Docker Desktop, open it, and enter this code to register.", + "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..c93ba907d 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 @@ -11,16 +13,19 @@ export interface IPlatform { hasScreenView?: boolean override?: React.FC installation?: { - label?: string 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,10 +33,14 @@ 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 = {} - nameLookup: INumberLookup = {} + private lookup: INumberLookup = {} + private nameLookup: INumberLookup = {} installed: string[] = [ 'advantech', 'alpine', @@ -70,28 +79,78 @@ 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), + description: data.description, + instructions: data.instructions, + link: data.link, + } + return { name: data.name, types, services: data.services, installation } + } + + 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) : 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 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] }) } - type(type: number): IPlatform { - return this.get(this.lookup[type] || 'unknown') + // 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]])) + } + + // 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.get(this.lookup[type]).name } get(id: string = 'unknown'): IPlatform { @@ -104,11 +163,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/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/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..80bdffa51 --- /dev/null +++ b/frontend/src/platforms/text.ts @@ -0,0 +1,35 @@ +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 } + + // 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: tr('name', platform.name) ?? '', + description: tr('description', description), + instructions: typeof instructions === 'string' ? tr('instructions', instructions) : instructions, + } +} + +export function usePlatformText(platform: IPlatform): PlatformText { + const { t } = useTranslation() + + return platformText(t, platform) +} 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/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/routers/Router.tsx b/frontend/src/routers/Router.tsx index 98110fda5..51b83e4ac 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -63,11 +63,13 @@ 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' 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() @@ -439,6 +441,9 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { + + + diff --git a/frontend/src/selectors/organizations.ts b/frontend/src/selectors/organizations.ts index a0da32e87..7f944ce97 100644 --- a/frontend/src/selectors/organizations.ts +++ b/frontend/src/selectors/organizations.ts @@ -89,17 +89,34 @@ export const selectLimit = createSelector( (limits, limitName): ILimit | undefined => 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 = {} + // 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 => { + if (limitsOverride[name] !== undefined) result[name] = limitsOverride[name] + }) return result } ) +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 { licenses: licenses.map(license => ({ 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/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/frontend/src/services/agent.test.ts b/frontend/src/services/agent.test.ts new file mode 100644 index 000000000..77b382ea9 --- /dev/null +++ b/frontend/src/services/agent.test.ts @@ -0,0 +1,119 @@ +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. +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', AGENT_URL: '/agent' })) + +import { agentURL, isSecureAgentURL, streamChat, AgentStreamEndedError } 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 + }) +}) + +/* 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 }) + } + // 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[] = [] + 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' } + 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]) + }) + + /* 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 new file mode 100644 index 000000000..9c9bc6343 --- /dev/null +++ b/frontend/src/services/agent.ts @@ -0,0 +1,263 @@ +/** + * 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 — + * 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 { 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 => !!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. */ +export function agentURL(): string { + const override = store.getState().ui.apis.agentURL + if (override && isSecureAgentURL(override)) return override.replace(/\/+$/, '') + return AGENT_URL +} + +/* The agent rejected our credential (401 reauth_required) — sign in again */ +export class AgentAuthError extends Error { + constructor() { + super('Agent authentication required') + } +} + +/* 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' + } +} + +/* A usage window (session/weekly) or the fleet is spent — the turn was refused before it ran. + Carries when it resets so the UI can say "resets at 4:30pm". */ +export class UsageLimitError extends Error { + constructor(message: string, readonly resetsAt: string | null) { + super(message) + } +} + +/* Every call to the agent: the URL off agentURL(), the token minted for the agent AUDIENCE + (signed over the canonical resource, not the transport — a proxy or an override must not + break the proof), and a 401 as AgentAuthError. That refusal is the one server fact the chat + acts on, so it is recorded here (models/chat unauthorized) for every endpoint — the callers + add only what their own screen should say — rather than at whichever catch remembered to. */ +async function agentRequest( + method: string, + path: string, + init: { body?: unknown; signal?: AbortSignal } = {} +): Promise { + const response = await fetch(`${agentURL()}${path}`, { + method, + headers: { + ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(await oidcAuthHeaders(method, `${OAUTH_AGENT_RESOURCE}${path}`, OAUTH_AGENT_RESOURCE)), + }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + signal: init.signal, + }) + if (response.status === 401) { + store.dispatch.chat.unauthorized() + throw new AgentAuthError() + } + return response +} + +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 } + | { type: 'confirmation_required'; id: string; name: string; input: Record } + | { type: 'done'; stopReason: string | null } + | { type: 'error'; message: string } + +export type OrgSelection = { id: string; name: string } + +/* 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 + text: string + org?: OrgSelection + signal?: AbortSignal + onEvent: (event: AgentEvent) => void +}): Promise { + const { conversationId, text, org, signal, onEvent } = options + const path = `/api/conversations/${encodeURIComponent(conversationId)}/messages` + const response = await agentRequest('POST', path, { body: org ? { text, org } : { text }, signal }) + if (response.status === 429 || response.status === 503) { + 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.resetsAt ?? null) + } + if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`) + + 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[] = [] + 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) 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 + 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) { + deliver(buffer.slice(0, index)) + buffer = buffer.slice(index + 2) + } + // 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 */ + } + 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) + // 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 */ +export async function confirmTool(options: { turnId: string; toolUseId: string; approved: boolean }): Promise { + const path = `/api/turns/${encodeURIComponent(options.turnId)}/confirm` + const response = await agentRequest('POST', path, { + body: { toolUseId: options.toolUseId, approved: options.approved }, + }) + if (!response.ok) throw new Error(`Confirm failed (${response.status})`) +} + +export type AgentHealth = 'ok' | 'unauthorized' | 'unreachable' + +export async function agentHealth(): Promise { + try { + const response = await agentRequest('GET', '/api/health') + if (!response.ok) return 'unreachable' + const body = (await response.json()) as { ok?: boolean } + return body.ok ? 'ok' : 'unreachable' + } catch (error) { + return error instanceof AgentAuthError ? 'unauthorized' : 'unreachable' + } +} + +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 agentRequest('GET', '/api/conversations') + // Don't turn a service failure (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 +} + +/* The server-side transcript (D11) — the durable copy this client's display caches. */ +export async function fetchConversation( + conversationId: string +): Promise<{ title: string | null; messages: Array<{ role: string; content: string }> } | null> { + const path = `/api/conversations/${encodeURIComponent(conversationId)}` + const response = await agentRequest('GET', path) + // 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 }> } +} + +/* 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)}` + return (await agentRequest('DELETE', path)).ok +} + +// Usage meter (permitteer docs/usage-limits.md D6). + +export type UsageWindow = { + limitUsd: number + spentUsd: number + remainingUsd: number + resetsAt: string | null + unlimited: boolean +} +export type Usage = { session: UsageWindow; weekly: UsageWindow } + +/* 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 { + 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 + 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 agentRequest('GET', '/api/enrollment') + 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 agentRequest('DELETE', '/api/enrollment') + } catch { + /* best-effort by design */ + } +} 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/chatPopout.test.ts b/frontend/src/services/chatPopout.test.ts new file mode 100644 index 000000000..cb5964c03 --- /dev/null +++ b/frontend/src/services/chatPopout.test.ts @@ -0,0 +1,60 @@ +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() + }) + + /* 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 new file mode 100644 index 000000000..9fcf1f197 --- /dev/null +++ b/frontend/src/services/chatPopout.ts @@ -0,0 +1,247 @@ +// 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' +// Shared with electron/src/ElectronApp.ts, which allows and sizes the window +import { CHAT_POPOUT_PARAM, CHAT_POPOUT_SIZE } from '@common/constants' + +/** + * 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). + */ +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 + +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 +// 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 — 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 +// tab has it): the id of the popout this tab opened, if any +const ownerId = (): string | null => window.sessionStorage.getItem(OWNER_KEY) + +export type ChatHandoff = { + messages: ChatTranscriptMessage[] + conversationId: string + title: string +} + +// Every message except the broadcast 'signout' is directed: it carries the +// popout's id so only the owning tab and its popout react to each other +type PopoutMessage = + | { type: 'hello'; id: string } + | { type: 'adopt'; id: string; payload: ChatHandoff } + | { type: 'handback'; id: string; payload: ChatHandoff } + | { type: 'ping'; id: string } + | { type: 'alive'; id: string } + | { 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=${CHAT_POPOUT_SIZE.width},height=${CHAT_POPOUT_SIZE.height}` +const POLL_INTERVAL = 2000 +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 +let aliveResolve: ((alive: boolean) => void) | null = null +let missedPings = 0 +let suppressHandback = false + +/* One liveness probe: resolves true on the popout's 'alive' reply, false + after PRESENCE_TIMEOUT. The boot presence check and the crash-net poll + both await this instead of threading shared timing flags. */ +const pingPopout = (id: string): Promise => + new Promise(resolve => { + aliveResolve?.(false) // a superseded probe counts as unanswered + aliveResolve = alive => { + aliveResolve = null + resolve(alive) + } + post({ type: 'ping', id }) + window.setTimeout(() => aliveResolve?.(false), PRESENCE_TIMEOUT) + }) + +/* ---------- main-window side ---------- */ + +/* `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) + 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}/?${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 + return true +} + +export function initChatPopoutMain(handlers: PopoutMainHandlers): () => void { + if (!channel) return () => {} + const listener = (event: MessageEvent) => { + const message = event.data + // Only the tab that owns this popout speaks its protocol; every other + // tab hears the channel too and must not adopt, hide its dock, or poll + if (message.type === 'signout' || message.id !== ownerId()) return + switch (message.type) { + case 'hello': + post({ type: 'adopt', id: message.id, payload: handlers.getHandoff() }) + handlers.onPopoutOpened() + startPolling(handlers) + break + case 'handback': + stopPolling() + handlers.adopt(message.payload) + break + case 'alive': + aliveResolve?.(true) + break + } + } + channel.addEventListener('message', listener) + return () => { + channel.removeEventListener('message', listener) + stopPolling() + } +} + +/* Ask whether a popout survives from a previous page load; corrects a stale + persisted poppedOut flag either way */ +export function checkPopoutPresence(handlers: PopoutMainHandlers): void { + const id = ownerId() + if (!channel || !id) { + // Not this tab's popout (or no channel) — treat as absent for this tab + handlers.onPresence(false) + return + } + pingPopout(id).then(present => { + handlers.onPresence(present) + if (present) startPolling(handlers) + }) +} + +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). 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) + return + } + pingPopout(ownerId() || '').then(alive => { + if (!pollTimer) return + if (alive) missedPings = 0 + else if (++missedPings >= 2) lost(handlers) + }) + }, POLL_INTERVAL) +} + +function stopPolling() { + if (pollTimer) window.clearInterval(pollTimer) + pollTimer = undefined + popoutWindow = null + missedPings = 0 +} + +function lost(handlers: PopoutMainHandlers) { + stopPolling() + handlers.onPopoutLost() +} + +/* ---------- popout-window side ---------- */ + +export function initChatPopoutWindow(handlers: PopoutWindowHandlers): () => void { + if (!channel) return () => {} + const messageListener = (event: MessageEvent) => { + const message = event.data + // Directed messages must come from the owning tab; sign-out is broadcast + if (message.type !== 'signout' && message.id !== popoutId) return + switch (message.type) { + case 'adopt': + // Main's copy is authoritative at hand-off; until it arrives the + // window shows its own boot-time transcript + handlers.adopt(message.payload) + break + case 'ping': + post({ type: 'alive', id: popoutId }) + break + case 'signout': + suppressHandback = true // sign-out clears the transcript; nothing to hand back + handlers.onSignout() + break + } + } + const beforeUnloadListener = () => { + if (!suppressHandback) post({ type: 'handback', id: popoutId, payload: handlers.getHandoff() }) + } + channel.addEventListener('message', messageListener) + window.addEventListener('beforeunload', beforeUnloadListener) + post({ type: 'hello', id: popoutId }) + return () => { + channel.removeEventListener('message', messageListener) + window.removeEventListener('beforeunload', beforeUnloadListener) + } +} + +export function popIn(payload: ChatHandoff): void { + post({ type: 'handback', id: popoutId, payload }) + suppressHandback = true // beforeunload would duplicate it (harmless but noisy) + window.close() +} diff --git a/frontend/src/services/cloudController.ts b/frontend/src/services/cloudController.ts index 6ac80e0b2..12aa85ebd 100644 --- a/frontend/src/services/cloudController.ts +++ b/frontend/src/services/cloudController.ts @@ -9,6 +9,8 @@ import { getAccountIds, accountFromDevice } from '../models/accounts' import { getWebSocketURL, getTestHeader } from '../helpers/apiHelper' import { DEVICE_TYPE } from '@common/applications' import { getToken } from './remoteit' +import { oidcAccessToken } from './oidc' +import { resourceForEventsURL } from '../constants' import { version } from '../helpers/versionHelper' import { store } from '../store' import { notify } from './Notifications' @@ -28,6 +30,17 @@ 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 SOMETIMES its own +// 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 resource = resourceForEventsURL(getWebSocketURL() || '') + if (!resource) return await getToken() + const token = await oidcAccessToken(resource) + return token ? 'Bearer ' + token : '' +} + class CloudController { initialized: boolean = false socket?: ReconnectingWebSocket @@ -149,7 +162,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(), }, @@ -528,8 +541,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, }, ], @@ -608,4 +621,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/get.ts b/frontend/src/services/get.ts index 7d40bff5a..134fb51a6 100644 --- a/frontend/src/services/get.ts +++ b/frontend/src/services/get.ts @@ -1,28 +1,21 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { getToken } from './remoteit' +import { getApiURL } from '../helpers/apiHelper' +import { apiHeaders } 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 headers = await apiHeaders('GET', url) + if (!headers) { console.warn('Unable to get token for API request.') return } - const headers: any = { Authorization: token, ...getTestHeader() } - - // Add x-r3-user header if in view-as mode - const viewAsUser = store.getState().ui.viewAsUser - if (viewAsUser) { - headers['X-R3-User'] = viewAsUser.id - } - const request = { - url: getApiURL() + path, + url, method: 'get', headers, } 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/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/frontend/src/services/jobLogs.ts b/frontend/src/services/jobLogs.ts index 6da420f30..7854a52c8 100644 --- a/frontend/src/services/jobLogs.ts +++ b/frontend/src/services/jobLogs.ts @@ -1,7 +1,6 @@ import axios from 'axios' -import { getApiURL, getTestHeader } from '../helpers/apiHelper' -import { getToken } from './remoteit' -import { store } from '../store' +import { getApiURL } from '../helpers/apiHelper' +import { apiHeaders } from './remoteit' export type DeviceLogEntry = { jobDeviceId: string @@ -36,17 +35,11 @@ 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) { - return { kind: 'error', status: 401, message: 'Not signed in' } - } - - const headers: any = { Authorization: token, ...getTestHeader() } - const viewAsUser = store.getState().ui.viewAsUser - if (viewAsUser) headers['X-R3-User'] = viewAsUser.id - + const url = `${getApiURL()}/job/log/all/${jobId}` + const headers = await apiHeaders('GET', url) + if (!headers) return { kind: 'error', status: 401, message: 'Not signed in' } 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/oidc.ts b/frontend/src/services/oidc.ts new file mode 100644 index 000000000..8ad33bf82 --- /dev/null +++ b/frontend/src/services/oidc.ts @@ -0,0 +1,1168 @@ +import browser, { leaveTo } from './browser' +import { isChatPopout } from './chatPopout' +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, + OAUTH_ACCOUNT_RESOURCE, + 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): + * IDENTICAL on web and desktop — the backend is for machine-local concerns, never auth. + * + * 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 + * desktop (ElectronApp's long-standing lane). On desktop the AS journey still runs in + * the SYSTEM browser — the main process bounces issuer-origin navigations out. + * + * Tokens live renderer-side: access tokens in memory, the ROTATING single-use refresh + * token in localStorage (family revocation on reuse is the mitigation). `resource` rides + * every token/refresh request — the identity lane defaults `aud` to the issuer otherwise. + */ + +export type OidcClaims = { + sub?: string + email?: string + email_verified?: boolean + amr?: string[] + [claim: string]: any +} + +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' +// 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' + +/* 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. + + 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. +const AUTO_START_LIMITS: { [reason: string]: number } = { boot: 2 } +const readAutoStarts = (): { [reason: string]: number } => { + try { + return JSON.parse(window.sessionStorage.getItem(AUTO_START_KEY) || '{}') + } catch { + return {} + } +} +const writeAutoStarts = (spent: { [reason: string]: number }) => { + try { + window.sessionStorage.setItem(AUTO_START_KEY, JSON.stringify(spent)) + } catch { + /* blocked storage must not stop someone signing in */ + } +} +/** Has this reason used up its automatic starts? (The sign-in screen reads it to say so.) */ +export const oidcAutoStartExhausted = (reason: string): boolean => + (readAutoStarts()[reason] ?? 0) >= (AUTO_START_LIMITS[reason] ?? 1) +/** Spend one automatic start for `reason`; false when the ledger refuses. */ +const spendAutoStart = (reason: string): boolean => { + if (oidcAutoStartExhausted(reason)) return false + const spent = readAutoStarts() + spent[reason] = (spent[reason] ?? 0) + 1 + writeAutoStarts(spent) + return true +} +export const oidcClearAutoStarts = (): void => { + 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 +// 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' +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 => { + try { + return sessionStorage.getItem(SUPPORT_FLAG) ? window.sessionStorage : window.localStorage + } catch { + return window.localStorage + } +} + +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 } +} + +let access: { [resource: string]: { token: string; exp: number; type?: string } } = {} +let minting: Promise = Promise.resolve() +let discovery: { authorization_endpoint: string; token_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 + /** 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' + this.code = code + this.retryAfter = retryAfter + } +} + +/* 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 plausible(seconds) + const date = Date.parse(header) + 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 + 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 randomB64u = (length: number) => toBase64url(crypto.getRandomValues(new Uint8Array(length))) +const decodeJwt = (jwt?: string): any => { + try { + return jwt ? JSON.parse(decodeBase64url(jwt.split('.')[1])) : undefined + } catch { + return undefined + } +} + +// 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) + return raw ? JSON.parse(raw) : undefined + } catch { + return undefined + } +} + +export const oidcConfigured = () => !!OAUTH_ISSUER +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 + } +} +/** Read-and-remove a same-tab one-shot; storage that throws reads as absent. */ +const takeSession = (key: string): string | undefined => { + try { + 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 + 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 + * 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 + 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! +} + +// 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 + * 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. */ +// 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 +} +/* 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 +} +// 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}` + // 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: timeoutSignal(4000) })).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 + mcpRefreshedAt = Date.now() + 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 — 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'] }, + // 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). + // 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_ACCOUNT_RESOURCE, + 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 + // request of the same type, and the grant row it mints is the revocable object the + // account console shows. + // `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}` : ''}`) + .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. + * + * 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 tokenStore().getItem(DECLARATION_KEY) !== declarationFingerprint() + } catch { + return false + } +} + +/** 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() + +/** 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' + loginHint?: string + supportTicket?: string + 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 + } + // 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. + const [d] = await Promise.all([discover(), 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() } + rememberFlow(flow) + const url = new URL(d.authorization_endpoint) + const params: { [key: string]: string } = { + client_id: OAUTH_CLIENT_ID, + redirect_uri: flow.redirectUri, + response_type: 'code', + 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). + 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 + // scope-`full` (an uncovered resource yields audience-only tokens). + 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 + // 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 + } + for (const key in params) url.searchParams.set(key, params[key]) + await leaveTo(url.toString()) + return true +} + +/** Boot-time completion: when the URL carries ?code&state (web return or the desktop + * deep-link reload), finish the exchange and clean the URL. Returns claims, or + * undefined when this boot isn't a callback. Throws on a failed/denied flow. */ +export async function oidcCompleteFromUrl(): Promise { + const query = new URLSearchParams(window.location.search) + const state = query.get('state') + if (!state || !(query.get('code') || query.get('error'))) return undefined + + const flow = takeFlow(state) + cleanUrl() + if (!flow) throw new OidcError('expired', 'Sign-in state mismatch') + const error = query.get('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', + code: query.get('code') || '', + code_verifier: flow.verifier, + redirect_uri: flow.redirectUri, + resource: OAUTH_GRAPHQL_RESOURCE, + }) + const claims = decodeJwt(body.id_token) + if (claims?.nonce !== flow.nonce) throw new OidcError('expired', 'Sign-in nonce mismatch') + // 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' }, + body: new URLSearchParams({ token: previous, token_type_hint: 'refresh_token', client_id: OAUTH_CLIENT_ID }), + }).catch(() => {}) + } + + 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: tokenExpiry(body), type: body.token_type }, + }) + } 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. + 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 */ + } + access[OAUTH_GRAPHQL_RESOURCE] = { token: body.access_token, exp: tokenExpiry(body), type: body.token_type } + return claims +} + +/** 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 { + // 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 s = stored() + 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 + } + const hit = fresh() + if (hit) return hit + const next = minting.then(() => fresh() ?? refresh(resource)) + minting = next.catch(() => {}) + return next +} + +// 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 +// 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) return refreshOnce(resource) + try { + return await locks.request(REFRESH_LOCK, { signal: timeoutSignal(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 { + const body = await tokenRequest({ + grant_type: 'refresh_token', + refresh_token: current.refresh_token, + resource, + }) + // 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 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 }) + 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) + // 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 + // 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) { + if (await oidcStart({ prompt: 'none', loginHint: email, auto: `recover:${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 '' + } +} + +export function invalidateOidcToken() { + access = {} +} + +/** 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. The explicit "Sign out everywhere" (models/auth globalSignOut) + * ends the sessions at the AS through the account API before it lands here. */ +export { clearLocal as oidcClearLocal } + +function clearLocal() { + clearActivationHint() + 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. 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) +} + +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; picture?: 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, + // Belt on the AS's own https-only guard — this string lands in an . + picture: httpsOnly(claims?.picture), + declaration: reg[sub]?.declaration, + } + writeRegistry(reg) +} + +/** 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 +} + +/** 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, + known: !e.refresh_token && !e.support, + })) + .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. */ +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 + // 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() { + const url = new URL(window.location.href) + url.search = '' + if (url.pathname === '/authCallback' || url.pathname === '/signoutCallback') url.pathname = '/' + 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' +// The pair with its proof header: the header carries the public JWK, constant for the key's +// lifetime, and every API call carries a proof — so it is encoded once, not per request. +type DpopKey = { pair: CryptoKeyPair; header: string } +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) + }) +} + +const utf8 = (s: string) => new TextEncoder().encode(s) + +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')) + const pair = existing?.privateKey + ? existing + : await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']) + if (!existing?.privateKey) await idbReq('readwrite', s => s.put(pair, 'dpop')) + const jwk = (await crypto.subtle.exportKey('jwk', pair.publicKey)) as { + kty: string + crv?: string + x?: string + y?: string + } + const header = toBase64url( + utf8( + JSON.stringify({ alg: 'ES256', typ: 'dpop+jwt', jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } }) + ) + ) + return { pair, header } + } 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 */ + } +} + +async function dpopProof(htm: string, htu: string, accessToken?: string): Promise { + const key = await dpopKey() + if (!key) return null + const { pair, header } = key + const u = new URL(htu) + const payload = toBase64url( + utf8( + JSON.stringify({ + htm, + htu: u.origin + u.pathname, + iat: Math.floor(Date.now() / 1000), + jti: crypto.randomUUID(), + ...(accessToken ? { ath: toBase64url(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}.${toBase64url(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 } + // 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}` } +} + +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 { + // Discovery and the key load are independent; the boot refresh sits on this path. + const [d] = await Promise.all([discover(), dpopKey()]) + 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', ...(proof ? { DPoP: proof } : {}) }, + body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, ...params }), + }) + 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 = responseError(response, detail) + error.oauthError = body.error + throw error + } + 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. +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?: { + sub: string + email?: string | null + name?: string | null + picture?: string | null + current?: boolean + }[] + }>(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() + 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: 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 + // 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. + // + // Two guards, and neither may be reconciled away. `authoritative` says the AS answered from the + // BROWSER's own membership rather than a stale-able per-session snapshot (permitteer + // docs/browser-id-plan.md); without it the list is a copy that may predate the others, and + // trusting it is how a live account gets deleted. `multi` off means the AS answers "just you" by + // design, not "everyone else is gone" — pruning on that would sign out every saved account. An + // AS that says neither (an older deployment) simply never prunes saved entries, which is the + // pre-existing behaviour. Identity-only entries carry no such risk either way: 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 multi = body.multi === true + const authoritative = body.authoritative === true + const mayPruneSaved = multi && authoritative + 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 && !mayPruneSaved) continue + 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 + return await oidcStart({ prompt: 'none', loginHint: e.email }) +} + +// Discovery and the DPoP key are needed before the first token of every boot (mcpDetailReady +// above warms the third piece): fetched while the store rehydrates rather than after it. Last +// in the module so every binding they touch exists. Failures are swallowed here and surface on +// the call that actually needs them. +void discover().catch(() => undefined) +void dpopKey() diff --git a/frontend/src/services/passportSelf.ts b/frontend/src/services/passportSelf.ts new file mode 100644 index 000000000..fd51394ee --- /dev/null +++ b/frontend/src/services/passportSelf.ts @@ -0,0 +1,66 @@ +import { oidcResourceRequest } 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 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 + hint?: string + secret?: string + otpauth?: string + delivery?: 'sms' + options?: string[] | Record + name?: 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 r = await oidcResourceRequest( + OAUTH_PASSPORT_RESOURCE, + path, + body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {} + ) + return { ...r.body, httpStatus: r.status } +} + +export const selfMe = () => call('') +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. */ +export const selfChallenge = (challenge: string, answer: { code?: string; choice?: MfaMethod }) => + call('/challenge', { challenge, ...answer }) +export const selfMfaEnroll = (password: string, method: MfaMethod = 'totp', phone?: string) => + call('/mfa/enroll', { password, method, ...(phone ? { phone } : {}) }) +export const selfMfaConfirm = (challenge: string, code: string) => call('/mfa/confirm', { challenge, code }) +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 }) diff --git a/frontend/src/services/permitteerAccount.ts b/frontend/src/services/permitteerAccount.ts new file mode 100644 index 000000000..df4cc530e --- /dev/null +++ b/frontend/src/services/permitteerAccount.ts @@ -0,0 +1,54 @@ +/* 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 { oidcResourceRequest, OidcResourceResult } from './oidc' +import { OAUTH_ACCOUNT_RESOURCE } from '../constants' + +/** 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 : [] +} + +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 + * family swept and the resource servers told, and, on a Cognito-bridged stage, the pool's + * 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> { + 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') +} + +/** 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' }) +} + +/** 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[], + reach?: { all?: boolean; accounts?: string[] } +): Promise { + return await call(`/apps/${encodeURIComponent(grantId)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ keep, keepScope, ...(reach ? { reach } : {}) }), + }) +} diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index 4dc25bb9b..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 { getToken } from './remoteit' +import { getApiURL } from '../helpers/apiHelper' +import { apiHeaders } from './remoteit' import { store } from '../store' import network from './Network' import sleep from '../helpers/sleep' @@ -14,22 +14,15 @@ 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 headers = await apiHeaders('POST', url) + if (!headers) { console.warn('Unable to get token for API request.', data) return } - - const headers: any = { Authorization: token, ...getTestHeader() } - - // Add x-r3-user header if in view-as mode - const viewAsUser = store.getState().ui.viewAsUser - if (viewAsUser) { - headers['X-R3-User'] = viewAsUser.id - } - + const request = { - url: getApiURL() + path, + url, method: 'post' as 'post', headers, data, @@ -75,12 +68,15 @@ export async function apiError(error: unknown) { } if (error.response?.status === 401 || error.response?.status === 403) { - if (errorCount > 10) { - auth.signOut() - } - console.log('Incrementing error count: ', errorCount) + // Migration reality: legacy endpoints and edge path-allowlists answer 401/403 with + // the session perfectly alive. NEVER tear down from here — checkSession consults + // the OIDC truth (a dead refresh family) and only then signs out LOCALLY; nothing + // 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 }) + // 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({ status: error.response?.status }) } } diff --git a/frontend/src/services/remoteit.ts b/frontend/src/services/remoteit.ts index 25dc77889..bf74759e0 100644 --- a/frontend/src/services/remoteit.ts +++ b/frontend/src/services/remoteit.ts @@ -1,32 +1,34 @@ -import { store } from '../store' +import { oidcAccessToken, oidcAuthHeaders } from './oidc' +import { getApiResource, getTestHeader } from '../helpers/apiHelper' +/** + * 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). + */ export async function getToken(): Promise { - const { auth } = store.dispatch + const token = await oidcAccessToken(getApiResource()) + return token ? 'Bearer ' + token : '' +} - try { - const currentSession = await store.getState().auth.authService?.currentCognitoSession() - if (!currentSession) throw new Error('No current cognito session') - const token = 'Bearer ' + currentSession?.getAccessToken().getJwtToken() - return token - } catch (error) { - console.error('GET TOKEN ERROR', error.message, error.code, error) - if (error.code && error.code == 'NotAuthorizedException') { - auth.signInError('Session Expired') - } - return '' - } +/** 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()) } -export async function hasCredentials() { - const { auth } = store.dispatch - try { - await store.getState().auth.authService?.currentCognitoSession() - return true - } catch (error) { - console.error('HAS CREDENTIALS ERROR', error.message, error) - if (error.code && error.code == 'NotAuthorizedException') { - auth.signInError('Session Expired') - } - return false - } +/** 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/frontend/src/store.ts b/frontend/src/store.ts index c820815be..b89c1bf64 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,8 +1,10 @@ import { numericVersion } from './helpers/versionHelper' import { models, RootModel } from './models' +import { defaultChatState, IChatState } from './models/chat' +import { isChatPopout, popoutScopeId } from './services/chatPopout' import { createLogger, ReduxLoggerOptions } from 'redux-logger' import { init, RematchDispatch, RematchRootState } from '@rematch/core' -import { PersistConfig } from 'redux-persist' +import { createTransform, PersistConfig } from 'redux-persist' import persistPlugin, { getPersistor } from '@rematch/persist' import DateTransform from './helpers/DateTransform' import immerPlugin from '@rematch/immer' @@ -12,14 +14,45 @@ const loggerConfig: ReduxLoggerOptions = { predicate: () => !!(window as any).stateLogging, } +// Persist only the durable chat fields — streaming/pendingConfirmation/error/ +// 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, + title: inbound.title, + ownerId: inbound.ownerId, + open: inbound.open, + width: inbound.width, + poppedOut: inbound.poppedOut, + }), + (outbound: Partial) => ({ ...defaultChatState, ...outbound }), + { 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 popout persists nothing (noopStorage) so it cannot clobber the main window's 'app' key. + storage: isChatPopout ? noopStorage : localForage, whitelist: [ 'accounts', 'announcements', 'applicationTypes', + 'chat', 'connections', 'contacts', 'devices', @@ -34,14 +67,23 @@ const persistConfig: PersistConfig = { 'user', ], throttle: 1000, - transforms: [DateTransform], + transforms: [DateTransform, chatTransform], } 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 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 }, + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index eaba04531..b46d4b03e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,28 +1,56 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' // https://vitejs.dev/config/ -export default defineConfig(({ mode }) => ({ - build: { - outDir: 'build', - minify: mode === 'production', - emptyOutDir: true, - sourcemap: true, - assetsInlineLimit: 0, - rollupOptions: { - output: { - manualChunks(id) { - if (id.includes('node_modules')) { - return 'vendor' - } +export default defineConfig(({ mode }) => { + // loadEnv sees frontend/.env files; process.env would only see shell vars + const env = loadEnv(mode, __dirname, '') + return { + build: { + outDir: 'build', + minify: mode === 'production', + emptyOutDir: true, + sourcemap: true, + assetsInlineLimit: 0, + rollupOptions: { + output: { + manualChunks(id: string) { + if (id.includes('node_modules')) { + // Keep the markdown renderer's parser tree out of the always- + // loaded vendor chunk — it belongs to the lazy-loaded chat panel + // (small shared utils it pulls in may still land in vendor) + if ( + /[\\/]node_modules[\\/](react-markdown|remark-|rehype-|micromark|mdast-|unified|hast-|vfile|unist-)/.test( + id + ) + ) + return + return 'vendor' + } + }, }, }, }, - }, - plugins: [react()], - resolve: { - alias: { '@common': path.resolve(__dirname, '../common/src') }, - }, - type: 'module', -})) + plugins: [react()], + resolve: { + alias: { '@common': path.resolve(__dirname, '../common/src') }, + }, + server: { + // 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: env.AGENT_PROXY_TARGET || 'http://localhost:3001', + changeOrigin: true, + rewrite: (p: string) => p.replace(/^\/agent/, ''), + }, + }, + }, + type: 'module', + } +}) 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 3970e1a6a..4a6677309 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "js-yaml": "^4.3.2", "npm-run-all": "4.1.5", "onchange": "^7.1.0", + "prettier": "^2.8.8", "ts-node": "^10.9.2", "typescript": "^5.8.3" } @@ -168,7 +169,6 @@ "version": "3.48.10", "dependencies": { "@airbrake/browser": "^2.1.9", - "@aws-amplify/auth": "^5.6.21", "@capacitor-community/bluetooth-le": "^7.1.1", "@capacitor/app": "^7.0.1", "@capacitor/app-launcher": "^7.0.1", @@ -217,6 +217,7 @@ "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", @@ -226,6 +227,7 @@ "redux": "^5.0.1", "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", + "remark-gfm": "^4.0.0", "reselect": "^5.1.1", "screenfull": "^6.0.2", "seedrandom": "^3.0.5", @@ -252,8 +254,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": { @@ -266,6 +270,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@airbrake/browser": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@airbrake/browser/-/browser-2.1.9.tgz", @@ -295,1587 +306,1548 @@ "node": ">=10" } }, - "node_modules/@aws-amplify/auth": { - "version": "5.6.23", - "resolved": "https://registry.npmjs.org/@aws-amplify/auth/-/auth-5.6.23.tgz", - "integrity": "sha512-FfULGVaQBDEuCsU1fFBd/uQ6hOgEUU7Kkvxs4sGjPXf/PZeVjIbAXmSIWs0zvpYt8H2/OvBIKabTbW5HEuQuqg==", - "license": "Apache-2.0", + "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": { - "@aws-amplify/core": "5.8.18", - "amazon-cognito-identity-js": "6.3.20", - "buffer": "4.9.2", - "tslib": "^1.8.0", - "url": "0.11.0" + "@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/@aws-amplify/auth/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/@aws-amplify/core": { - "version": "5.8.18", - "resolved": "https://registry.npmjs.org/@aws-amplify/core/-/core-5.8.18.tgz", - "integrity": "sha512-7n/Wk7x24qFCpnhGzwJlRnO5ajssxhHIzsxCyczgOH3+nB5IeW4/n1AsfdyGdYidYrIhWFGyVBVMGQYmDEp3Ww==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "1.2.2", - "@aws-sdk/client-cloudwatch-logs": "3.982.0", - "@aws-sdk/types": "3.893.0", - "@aws-sdk/util-hex-encoding": "3.374.0", - "@types/node-fetch": "2.6.4", - "isomorphic-unfetch": "^3.0.0", - "react-native-url-polyfill": "^1.3.0", - "tslib": "^1.8.0", - "universal-cookie": "^7.2.2", - "zen-observable-ts": "0.8.19" - } - }, - "node_modules/@aws-amplify/core/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" + "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/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", + "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": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "@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/@aws-crypto/sha256-browser/node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, + "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": ">=16.0.0" + "node": "20 || >=22" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } + "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/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=14.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-crypto/sha256-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-1.2.2.tgz", - "integrity": "sha512-Nr1QJIbW/afYYGzYvrF70LtaHrIRtd4TNAglX8BvlfxJLZ45SAmueIKYl5tWoNBPzp65ymXGFK0Bb1vZUpuc9g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^1.2.2", - "@aws-sdk/types": "^3.1.0", - "tslib": "^1.11.1" + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@aws-crypto/util": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-1.2.2.tgz", - "integrity": "sha512-H8PjG5WJ4wz0UXAFXeJjWCW1vkvIJ3qUUD+rGRwJ2/hj+xT58Qle2MTql/2MGzkU+1JLAFuR6aJpLAjHwhmwwg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.1.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" }, - "node_modules/@aws-crypto/util/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } }, - "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.982.0.tgz", - "integrity": "sha512-GqftH+v8eYjyD41rCWY6IOpjy43xL8bJYqKgxB6grVPhI4GM+ZBjjSPfWaHWtQje7SHsDuQybIMrdpmkymDjLA==", - "license": "Apache-2.0", + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/credential-provider-node": "^3.972.5", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.6", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.982.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.4", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.22.0", - "@smithy/eventstream-serde-browser": "^4.2.8", - "@smithy/eventstream-serde-config-resolver": "^4.3.8", - "@smithy/eventstream-serde-node": "^4.2.8", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.12", - "@smithy/middleware-retry": "^4.4.29", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.28", - "@smithy/util-defaults-mode-node": "^4.2.31", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { - "node": ">=16.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/core": { - "version": "3.975.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", - "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", - "license": "Apache-2.0", + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@aws-sdk/xml-builder": "^3.972.34", - "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.2", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "node": ">=6.9.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.57", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", - "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" - }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/credential-provider-env/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" - }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", - "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/node-http-handler": "^4.9.4", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" - }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/credential-provider-http/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" - }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", - "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", - "license": "Apache-2.0", + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/credential-provider-env": "^3.972.57", - "@aws-sdk/credential-provider-http": "^3.972.59", - "@aws-sdk/credential-provider-login": "^3.972.63", - "@aws-sdk/credential-provider-process": "^3.972.57", - "@aws-sdk/credential-provider-sso": "^3.973.1", - "@aws-sdk/credential-provider-web-identity": "^3.972.63", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/credential-provider-imds": "^4.4.7", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.0.0" } }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.63", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", - "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.67.tgz", - "integrity": "sha512-oYlzWst56rlhhjbYnexwv5hVLYe1cW4liLObhDfxDLI4RAQzleMVHQgQgx7XsC4HKj4e3kjT8v9DId+Pi/dndw==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.57", - "@aws-sdk/credential-provider-http": "^3.972.59", - "@aws-sdk/credential-provider-ini": "^3.973.1", - "@aws-sdk/credential-provider-process": "^3.972.57", - "@aws-sdk/credential-provider-sso": "^3.973.1", - "@aws-sdk/credential-provider-web-identity": "^3.972.63", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/credential-provider-imds": "^4.4.7", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.12.13" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-node/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.57", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", - "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-process/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.10.4" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", - "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/token-providers": "3.1083.0", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.63", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", - "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.10.4" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.32.tgz", - "integrity": "sha512-IrcMmLEFPISSaF1tZe/BjqHoqvc6DJlHLEbBNR4guSdc6f08GZOvTKU3U0JKQzHSx7rp22XVO+FTkTnPc36O8w==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.10.4" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.31.tgz", - "integrity": "sha512-CDEFV+r8QSqVR+R+scNsuguVsf3o2mLjxJ+D0l/FxHrJ7gl3VkDp8ThDlYjMKzfrNElfmLKh8GP2m2r08lLv2w==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.33.tgz", - "integrity": "sha512-bqBEw3pj9EQqhZKMa2GZ3gx5TEYlsoiaaBjuRsul1F5C5rE6AHK1z06Kj+T0YYuO6PTz8NKRTAq01Kjb7MHPwA==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.61", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.61.tgz", - "integrity": "sha512-yiq7OHjB4quojzG4O9ySUDY9pJkwG0SMHhvEN/RUkf5R/fbvAGtqsuKr9U5TED8GmH/FO/j9yfiGKstYwfFDRg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", - "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.39", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/node-http-handler": "^4.9.4", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.35.tgz", - "integrity": "sha512-tpTFUpJ/hQdQU+/A1JYlGcSujXuMNdp3SQ1kggZtzPcM410fqMvzMj2WBUBUfNaUr4roVNILcu3+J80NlV+x9g==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", - "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-sdk/types": "^3.974.0", - "@smithy/signature-v4": "^5.6.3", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1083.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", - "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/nested-clients": "^3.997.31", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" - }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/token-providers/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/types": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.893.0.tgz", - "integrity": "sha512-Aht1nn5SnA0N+Tjv0dzhAY7CQbxVtmq1bBR6xI0MhG7p2XYVh1wXuKTzrldEvQWwA3odOYunAfT9aBiKZx9qIg==", - "license": "Apache-2.0", + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.5.0", - "tslib": "^2.6.2" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.982.0.tgz", - "integrity": "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==", - "license": "Apache-2.0", + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", - "tslib": "^2.6.2" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { - "node": ">=20.0.0" + "node": ">=6.9.0" } }, - "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", - "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" }, - "node_modules/@aws-sdk/util-hex-encoding": { - "version": "3.374.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.374.0.tgz", - "integrity": "sha512-14X7MDYCFle2Cuq0/Hvz2CHQoYVeoKKBY2Uf+wn0lKnKU+f0K81xRObUM/A7bLmZX4jFRk83gyE8Rj3BOqBdfA==", - "deprecated": "This package has moved to @smithy/util-hex-encoding", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-hex-encoding": "^1.0.1", - "tslib": "^2.5.0" - }, - "engines": { - "node": ">=14.0.0" - } + "node_modules/@blakeembrey/deque": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz", + "integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==", + "dev": true, + "license": "Apache-2.0" }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", - "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", - "license": "Apache-2.0", + "node_modules/@blakeembrey/template": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.2.0.tgz", + "integrity": "sha512-w/63nURdkRPpg3AXbNr7lPv6HgOuVDyefTumiXsbXxtIwcuk5EXayWR5OpSwDjsQPgaYsfUSedMduaNOjAYY8A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@capacitor-community/bluetooth-le": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.1.1.tgz", + "integrity": "sha512-Z5beNgHNQodKaWrjplYUHji3f15nU2/JCA+INzgybK6mBssq/WsHzBw8eBP/CXzAv1XZD6tXMZlLumzAXgZuhw==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@types/web-bluetooth": "^0.0.20" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@capacitor/core": ">=7.0.0" } }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.32.tgz", - "integrity": "sha512-shbyj1ClfB3prlP4Vi9Y5PSqDVtNzl1PhwzuxswyOJ6OqRjAPsyqiLV6OPGOadlcwv2mWl6pPd0oTZk6GkkG1Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.975.1", - "tslib": "^2.6.2" + "node_modules/@capacitor/android": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-7.4.2.tgz", + "integrity": "sha512-FZ7M9NwFkljR7EP5eXiE32mAIfZNcYw2CzRMCG3rQu0u0ZaIoeOeq5/oK4YcDnGpNmu8jpngKJqZ+9OiSQSwDg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^7.4.0" } }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.47", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.47.tgz", - "integrity": "sha512-AmMLp6bKGzqeeNZ2YMypX8WmLDc9o5v/UCf/MZtT5VlpR2XZQEcelp+IsIGptKUVHxfpUDE7DxG/AdMYqX3N/g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.975.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" + "node_modules/@capacitor/app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-7.0.1.tgz", + "integrity": "sha512-ArlVZAAla4MwQoKh26x2AaTDOBh5Vhp1VhMKR3RwqZSsZnazKTFGNrPbr9Ez5r1knnEDfApyjwp1uZnXK1WTYQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" } }, - "node_modules/@aws-sdk/util-utf8-browser": { - "version": "3.259.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", - "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.3.1" + "node_modules/@capacitor/app-launcher": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app-launcher/-/app-launcher-7.0.1.tgz", + "integrity": "sha512-XeAdZxLddXvuMMppr0r9UfPle6m2G2CbZlKQiODHqL/6EQyN5w8SdRyORrUdx3//6nsO4T5a9gJ3EIBYXY4QJQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" } }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", - "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", - "license": "Apache-2.0", + "node_modules/@capacitor/assets": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/assets/-/assets-3.0.5.tgz", + "integrity": "sha512-ohz/OUq61Y1Fc6aVSt0uDrUdeOA7oTH4pkWDbv/8I3UrPjH7oPkzYhShuDRUjekNp9RBi198VSFdt0CetpEOzw==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.16.0", - "tslib": "^2.6.2" + "@capacitor/cli": "^5.3.0", + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@trapezedev/project": "^7.0.10", + "commander": "8.3.0", + "debug": "4.3.4", + "fs-extra": "10.1.0", + "node-fetch": "2.7.0", + "node-html-parser": "5.4.2", + "sharp": "0.32.6", + "tslib": "2.6.2", + "yargs": "17.7.2" + }, + "bin": { + "capacitor-assets": "bin/capacitor-assets" }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", - "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" + "node": ">=10.3.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "node_modules/@capacitor/assets/node_modules/@capacitor/cli": { + "version": "5.7.8", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.7.8.tgz", + "integrity": "sha512-qN8LDlREMhrYhOvVXahoJVNkP8LP55/YPRJrzTAFrMqlNJC18L3CzgWYIblFPnuwfbH/RxbfoZT/ydkwgVpMrw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@ionic/cli-framework-output": "^2.2.5", + "@ionic/utils-fs": "^3.1.6", + "@ionic/utils-subprocess": "^2.1.11", + "@ionic/utils-terminal": "^2.3.3", + "commander": "^9.3.0", + "debug": "^4.3.4", + "env-paths": "^2.2.0", + "kleur": "^4.1.4", + "native-run": "^2.0.0", + "open": "^8.4.0", + "plist": "^3.0.5", + "prompts": "^2.4.2", + "rimraf": "^4.4.1", + "semver": "^7.3.7", + "tar": "^6.1.11", + "tslib": "^2.4.0", + "xml2js": "^0.5.0" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, + "node_modules/@capacitor/assets/node_modules/@capacitor/cli/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^12.20.0 || >=14" } }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, + "node_modules/@capacitor/assets/node_modules/@ionic/utils-process": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.11.tgz", + "integrity": "sha512-Uavxn+x8j3rDlZEk1X7YnaN6wCgbCwYQOeIjv/m94i1dzslqWhqIHEqxEyeE8HsT5Negboagg7GtQiABy+BLbA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.4", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" }, "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": ">=16.0.0" } }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "node_modules/@capacitor/assets/node_modules/@ionic/utils-stream": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.6.tgz", + "integrity": "sha512-4+Kitey1lTA1yGtnigeYNhV/0tggI3lWBMjC7tBs1K9GXa/q7q4CtOISppdh8QgtOhrhAXS2Igp8rbko/Cj+lA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" + "debug": "^4.0.0", + "tslib": "^2.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, + "node_modules/@capacitor/assets/node_modules/@ionic/utils-subprocess": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-2.1.14.tgz", + "integrity": "sha512-nGYvyGVjU0kjPUcSRFr4ROTraT3w/7r502f5QJEsMRKTqa4eEzCshtwRk+/mpASm0kgBN5rrjYA5A/OZg8ahqg==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.11", + "@ionic/utils-stream": "3.1.6", + "@ionic/utils-terminal": "2.3.4", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "node_modules/@capacitor/assets/node_modules/@ionic/utils-terminal": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.4.tgz", + "integrity": "sha512-cEiMFl3jklE0sW60r8JHH3ijFTwh/jkdEKWbylSyExQwZ8pPuwoXz7gpkWoJRLuoRHHSvg+wzNYyPJazIHfoJA==", "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "node_modules/@capacitor/assets/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "balanced-match": "^1.0.0" + } + }, + "node_modules/@capacitor/assets/node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", + "node_modules/@capacitor/assets/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "license": "ISC", "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=16 || 14 >=14.17" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", + "node_modules/@capacitor/assets/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "license": "ISC", "engines": { - "node": ">=6.9.0" + "node": ">=8" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", + "node_modules/@capacitor/assets/node_modules/rimraf": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", + "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", + "license": "ISC", + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, "engines": { - "node": ">=6.9.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "node_modules/@capacitor/assets/node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, "engines": { - "node": ">=6.9.0" + "node": ">=4.0.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, + "node_modules/@capacitor/assets/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=4.0" } }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, + "node_modules/@capacitor/browser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-7.0.1.tgz", + "integrity": "sha512-N6KEVLw2enTnourQzYJLvAkSds2Ed21zqsvHnSImrVDenzX8fUj032kMt4EdewmxfxiEwRa911BT1VOPBi0fEA==", "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@capacitor/core": ">=7.0.0" } }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "node_modules/@capacitor/cli": { + "version": "7.6.7", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.6.7.tgz", + "integrity": "sha512-nV9j1+431/rsiabGs1UK0SbETrl2JfAf3SSlsM9RbMMjAL7WmruxnlJcbB9IXPoa6L99GljSO5q/9UCNvYz6Ag==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" }, "bin": { - "parser": "bin/babel-parser.js" + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" }, "engines": { - "node": ">=6.0.0" + "node": ">=20.0.0" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, + "node_modules/@capacitor/cli/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=18" } }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, + "node_modules/@capacitor/cli/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "ms": "^2.1.3" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, + "node_modules/@capacitor/cli/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=14.14" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, + "node_modules/@capacitor/cli/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, + "node_modules/@capacitor/cli/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "minipass": "^7.1.2" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">= 18" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", + "node_modules/@capacitor/cli/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==", + "license": "MIT" + }, + "node_modules/@capacitor/cli/node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=18" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, + "node_modules/@capacitor/cli/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@capacitor/cli/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/clipboard": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/clipboard/-/clipboard-7.0.1.tgz", + "integrity": "sha512-n4XEHma7apLOYvyeaR9S5u3uGzDYG7WeQxmtZlwP01HneIzMnusVgw4Im6I+pMBcoUN9TfVdf6eqKph97B1bAw==", "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@capacitor/core": ">=7.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, + "node_modules/@capacitor/core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.2.tgz", + "integrity": "sha512-akCf9A1FUR8AWTtmgGjHEq6LmGsjA2U7igaJ9PxiCBfyxKqlDbuGHrlNdpvHEjV5tUPH3KYtkze6gtFcNKPU9A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "tslib": "^2.1.0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, + "node_modules/@capacitor/ios": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.4.2.tgz", + "integrity": "sha512-Edd4aZ6IJi4O/7dJIsSIuuo6LXvVVP5V++0Vs1w5bWOGbGhWZXLF0lt0KGFgSUxTuyL6xURGUygkD6dCp35QAQ==", "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@capacitor/core": "^7.4.0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, + "node_modules/@capacitor/splash-screen": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-7.0.1.tgz", + "integrity": "sha512-Nbqw9bEIe7uHj/HOT81mf4jT6uK1YykozpQw/uIKQDueMg6RJYaJK2/TMajIOohLk8fJF4TYIc1i9nGjNLnfGg==", "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@capacitor/core": ">=7.0.0" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, + "node_modules/@capacitor/status-bar": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.1.tgz", + "integrity": "sha512-iDv3mXYo9CdxYRVwt3/pRyuk25p7Sn4GfaS/zMZyVIqTzsvKLCIIH3GdKK+ta+nsNcAVpCw/t5jFEBt1D18ctA==", "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@capacitor/core": ">=7.0.0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=0.1.90" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@jridgewell/trace-mapping": "0.3.9" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=12" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "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, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=20.19.0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "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", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, "engines": { - "node": ">=6.9.0" + "node": ">=20.19.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "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": { - "@babel/helper-plugin-utils": "^7.27.1" + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=20.19.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "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", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, "engines": { - "node": ">=6.9.0" + "node": ">=20.19.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "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", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" }, - "engines": { - "node": ">=6.9.0" + "bin": { + "asar": "bin/asar.js" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=10.12.0" } }, - "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">= 6" } }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" }, - "engines": { - "node": ">=6.9.0" + "bin": { + "electron-fuses": "dist/bin.js" } }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=10" } }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@blakeembrey/deque": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz", - "integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@blakeembrey/template": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.2.0.tgz", - "integrity": "sha512-w/63nURdkRPpg3AXbNr7lPv6HgOuVDyefTumiXsbXxtIwcuk5EXayWR5OpSwDjsQPgaYsfUSedMduaNOjAYY8A==", + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@capacitor-community/bluetooth-le": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.1.1.tgz", - "integrity": "sha512-Z5beNgHNQodKaWrjplYUHji3f15nU2/JCA+INzgybK6mBssq/WsHzBw8eBP/CXzAv1XZD6tXMZlLumzAXgZuhw==", "license": "MIT", "dependencies": { - "@types/web-bluetooth": "^0.0.20" + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, - "peerDependencies": { - "@capacitor/core": ">=7.0.0" + "engines": { + "node": ">=6 <7 || >=8" } }, - "node_modules/@capacitor/android": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-7.4.2.tgz", - "integrity": "sha512-FZ7M9NwFkljR7EP5eXiE32mAIfZNcYw2CzRMCG3rQu0u0ZaIoeOeq5/oK4YcDnGpNmu8jpngKJqZ+9OiSQSwDg==", + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, "license": "MIT", - "peerDependencies": { - "@capacitor/core": "^7.4.0" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/@capacitor/app": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-7.0.1.tgz", - "integrity": "sha512-ArlVZAAla4MwQoKh26x2AaTDOBh5Vhp1VhMKR3RwqZSsZnazKTFGNrPbr9Ez5r1knnEDfApyjwp1uZnXK1WTYQ==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@capacitor/app-launcher": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/app-launcher/-/app-launcher-7.0.1.tgz", - "integrity": "sha512-XeAdZxLddXvuMMppr0r9UfPle6m2G2CbZlKQiODHqL/6EQyN5w8SdRyORrUdx3//6nsO4T5a9gJ3EIBYXY4QJQ==", + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" + "engines": { + "node": ">= 4.0.0" } }, - "node_modules/@capacitor/assets": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@capacitor/assets/-/assets-3.0.5.tgz", - "integrity": "sha512-ohz/OUq61Y1Fc6aVSt0uDrUdeOA7oTH4pkWDbv/8I3UrPjH7oPkzYhShuDRUjekNp9RBi198VSFdt0CetpEOzw==", + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, "license": "MIT", "dependencies": { - "@capacitor/cli": "^5.3.0", - "@ionic/utils-array": "2.1.6", - "@ionic/utils-fs": "3.1.7", - "@trapezedev/project": "^7.0.10", - "commander": "8.3.0", - "debug": "4.3.4", - "fs-extra": "10.1.0", - "node-fetch": "2.7.0", - "node-html-parser": "5.4.2", - "sharp": "0.32.6", - "tslib": "2.6.2", - "yargs": "17.7.2" - }, - "bin": { - "capacitor-assets": "bin/capacitor-assets" + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" }, "engines": { - "node": ">=10.3.0" + "node": ">= 10.0.0" } }, - "node_modules/@capacitor/assets/node_modules/@capacitor/cli": { - "version": "5.7.8", - "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.7.8.tgz", - "integrity": "sha512-qN8LDlREMhrYhOvVXahoJVNkP8LP55/YPRJrzTAFrMqlNJC18L3CzgWYIblFPnuwfbH/RxbfoZT/ydkwgVpMrw==", + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { - "@ionic/cli-framework-output": "^2.2.5", - "@ionic/utils-fs": "^3.1.6", - "@ionic/utils-subprocess": "^2.1.11", - "@ionic/utils-terminal": "^2.3.3", - "commander": "^9.3.0", + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", "debug": "^4.3.4", - "env-paths": "^2.2.0", - "kleur": "^4.1.4", - "native-run": "^2.0.0", - "open": "^8.4.0", - "plist": "^3.0.5", - "prompts": "^2.4.2", - "rimraf": "^4.4.1", - "semver": "^7.3.7", - "tar": "^6.1.11", - "tslib": "^2.4.0", - "xml2js": "^0.5.0" + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" }, "bin": { - "cap": "bin/capacitor", - "capacitor": "bin/capacitor" + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" }, "engines": { - "node": ">=16.0.0" + "node": ">=12.0.0" } }, - "node_modules/@capacitor/assets/node_modules/@capacitor/cli/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/@capacitor/assets/node_modules/@ionic/utils-process": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.11.tgz", - "integrity": "sha512-Uavxn+x8j3rDlZEk1X7YnaN6wCgbCwYQOeIjv/m94i1dzslqWhqIHEqxEyeE8HsT5Negboagg7GtQiABy+BLbA==", - "license": "MIT", - "dependencies": { - "@ionic/utils-object": "2.1.6", - "@ionic/utils-terminal": "2.3.4", - "debug": "^4.0.0", - "signal-exit": "^3.0.3", - "tree-kill": "^1.2.2", - "tslib": "^2.0.1" + "node": ">= 8.0.0" }, - "engines": { - "node": ">=16.0.0" + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@capacitor/assets/node_modules/@ionic/utils-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.6.tgz", - "integrity": "sha512-4+Kitey1lTA1yGtnigeYNhV/0tggI3lWBMjC7tBs1K9GXa/q7q4CtOISppdh8QgtOhrhAXS2Igp8rbko/Cj+lA==", + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.0.0", - "tslib": "^2.0.1" + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=16.0.0" + "node": ">=22.12.0" } }, - "node_modules/@capacitor/assets/node_modules/@ionic/utils-subprocess": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-2.1.14.tgz", - "integrity": "sha512-nGYvyGVjU0kjPUcSRFr4ROTraT3w/7r502f5QJEsMRKTqa4eEzCshtwRk+/mpASm0kgBN5rrjYA5A/OZg8ahqg==", + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "dev": true, "license": "MIT", "dependencies": { - "@ionic/utils-array": "2.1.6", - "@ionic/utils-fs": "3.1.7", - "@ionic/utils-process": "2.1.11", - "@ionic/utils-stream": "3.1.6", - "@ionic/utils-terminal": "2.3.4", - "cross-spawn": "^7.0.3", - "debug": "^4.0.0", - "tslib": "^2.0.1" + "semver": "^7.6.3" }, "engines": { - "node": ">=16.0.0" + "node": ">=22.12.0" } }, - "node_modules/@capacitor/assets/node_modules/@ionic/utils-terminal": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.4.tgz", - "integrity": "sha512-cEiMFl3jklE0sW60r8JHH3ijFTwh/jkdEKWbylSyExQwZ8pPuwoXz7gpkWoJRLuoRHHSvg+wzNYyPJazIHfoJA==", + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, "license": "MIT", "dependencies": { - "@types/slice-ansi": "^4.0.0", - "debug": "^4.0.0", - "signal-exit": "^3.0.3", - "slice-ansi": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "tslib": "^2.0.1", - "untildify": "^4.0.0", - "wrap-ansi": "^7.0.0" + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=16.4" } }, - "node_modules/@capacitor/assets/node_modules/brace-expansion": { + "node_modules/@electron/universal/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/@capacitor/assets/node_modules/glob": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", - "license": "ISC", + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=14.14" } }, - "node_modules/@capacitor/assets/node_modules/minimatch": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", - "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1884,618 +1856,48 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@capacitor/assets/node_modules/minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "license": "ISC", - "engines": { - "node": ">=8" + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" } }, - "node_modules/@capacitor/assets/node_modules/rimraf": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", - "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", - "license": "ISC", - "dependencies": { - "glob": "^9.2.0" - }, - "bin": { - "rimraf": "dist/cjs/src/bin.js" - }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@capacitor/assets/node_modules/xml2js": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", - "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/@capacitor/assets/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@capacitor/browser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-7.0.1.tgz", - "integrity": "sha512-N6KEVLw2enTnourQzYJLvAkSds2Ed21zqsvHnSImrVDenzX8fUj032kMt4EdewmxfxiEwRa911BT1VOPBi0fEA==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" - } - }, - "node_modules/@capacitor/cli": { - "version": "7.6.7", - "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.6.7.tgz", - "integrity": "sha512-nV9j1+431/rsiabGs1UK0SbETrl2JfAf3SSlsM9RbMMjAL7WmruxnlJcbB9IXPoa6L99GljSO5q/9UCNvYz6Ag==", - "license": "MIT", - "dependencies": { - "@ionic/cli-framework-output": "^2.2.8", - "@ionic/utils-subprocess": "^3.0.1", - "@ionic/utils-terminal": "^2.3.5", - "commander": "^12.1.0", - "debug": "^4.4.0", - "env-paths": "^2.2.0", - "fs-extra": "^11.2.0", - "kleur": "^4.1.5", - "native-run": "^2.0.3", - "open": "^8.4.0", - "plist": "^3.1.0", - "prompts": "^2.4.2", - "rimraf": "^6.0.1", - "semver": "^7.6.3", - "tar": "^7.5.3", - "tslib": "^2.8.1", - "xml2js": "^0.6.2" - }, - "bin": { - "cap": "bin/capacitor", - "capacitor": "bin/capacitor" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@capacitor/cli/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@capacitor/cli/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@capacitor/cli/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@capacitor/cli/node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@capacitor/cli/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/@capacitor/cli/node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@capacitor/cli/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==", - "license": "MIT" - }, - "node_modules/@capacitor/cli/node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@capacitor/cli/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/@capacitor/cli/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@capacitor/clipboard": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/clipboard/-/clipboard-7.0.1.tgz", - "integrity": "sha512-n4XEHma7apLOYvyeaR9S5u3uGzDYG7WeQxmtZlwP01HneIzMnusVgw4Im6I+pMBcoUN9TfVdf6eqKph97B1bAw==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" - } - }, - "node_modules/@capacitor/core": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.2.tgz", - "integrity": "sha512-akCf9A1FUR8AWTtmgGjHEq6LmGsjA2U7igaJ9PxiCBfyxKqlDbuGHrlNdpvHEjV5tUPH3KYtkze6gtFcNKPU9A==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@capacitor/ios": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.4.2.tgz", - "integrity": "sha512-Edd4aZ6IJi4O/7dJIsSIuuo6LXvVVP5V++0Vs1w5bWOGbGhWZXLF0lt0KGFgSUxTuyL6xURGUygkD6dCp35QAQ==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": "^7.4.0" - } - }, - "node_modules/@capacitor/splash-screen": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-7.0.1.tgz", - "integrity": "sha512-Nbqw9bEIe7uHj/HOT81mf4jT6uK1YykozpQw/uIKQDueMg6RJYaJK2/TMajIOohLk8fJF4TYIc1i9nGjNLnfGg==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" - } - }, - "node_modules/@capacitor/status-bar": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.1.tgz", - "integrity": "sha512-iDv3mXYo9CdxYRVwt3/pRyuk25p7Sn4GfaS/zMZyVIqTzsvKLCIIH3GdKK+ta+nsNcAVpCw/t5jFEBt1D18ctA==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", - "license": "MIT", - "dependencies": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@electron/asar/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@electron/fuses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", - "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.1", - "fs-extra": "^9.0.1", - "minimist": "^1.2.5" - }, - "bin": { - "electron-fuses": "dist/bin.js" - } - }, - "node_modules/@electron/fuses/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@electron/get/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@electron/get/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/osx-sign": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", - "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "compare-version": "^0.1.2", - "debug": "^4.3.4", - "fs-extra": "^10.0.0", - "isbinaryfile": "^4.0.8", - "minimist": "^1.2.6", - "plist": "^3.0.5" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/@electron/rebuild": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", - "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.1.1", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^12.2.0", - "read-binary-file-arch": "^1.0.6" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@electron/rebuild/node_modules/node-abi": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", - "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.6.3" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@electron/universal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", - "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.3.1", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.3.1", - "dir-compare": "^4.2.0", - "fs-extra": "^11.1.1", - "minimatch": "^9.0.3", - "plist": "^3.1.0" - }, - "engines": { - "node": ">=16.4" - } - }, - "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/universal/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" } }, "node_modules/@emotion/hash": { @@ -3117,6 +2519,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -4810,139 +4230,55 @@ "license": "MIT", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" + "linux" ] }, - "node_modules/@rollup/rollup-openharmony-arm64": { + "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "linux" ] }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { + "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { + "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ - "ia32" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-x64-gnu": { + "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -4950,13 +4286,13 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { + "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -4964,478 +4300,131 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.6.8", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.6.8.tgz", - "integrity": "sha512-jNAgbTd8u1bHkAu9fvIpq2rMVXTVm8DylDM59kUS9J94zBZLcdA7j+Vhc+RYg/8PVlNAPAQTGMjpgPqeYmRzcg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.29.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.3.tgz", - "integrity": "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.8.tgz", - "integrity": "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.4.8.tgz", - "integrity": "sha512-2ZZe4NSupcDg2UKLpnsuBEOA9yLbZ9ZmYc64HCYPYbvBHg62P8+FUmzkhvwtXm9uLKM3MoUxHw2WID/QzNOe1Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.5.8.tgz", - "integrity": "sha512-y08FJiync2Z2r5/srXEjo7GJVVpBHB8iNb5x3nYTaVqbQ9AjnUvU3C/0U00IEE+6g77JQJ66gC6XS+ZrAVLWnQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.4.8.tgz", - "integrity": "sha512-wm8iDHlXpjmJVL8bcmjsW3mfgn4RVZKh2Wai6Ta1Vp/e+H7n1ZLobeBjFTqcJikbwx9prXz1QJzB5Z1h8YUZWg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.5.tgz", - "integrity": "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.4.8.tgz", - "integrity": "sha512-vJDrMFt3Q02naTTveiv1WFJpI1z6fEs+TH2UdcxNoJhqTFR1YNAz+rhwEDzgKGkqPF/0fhWl6SG6jePB1EgydQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.4.8.tgz", - "integrity": "sha512-xqzcxU2pMl17yQ5bvJbsiiTua/wgt5VluguIOW9kCGCBhBazkwceLj9BlH883K0ASxZAhftvRmp7wTRg1ZbpKg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.4.8.tgz", - "integrity": "sha512-qLNmtCNnB6kdZu5AiC9p7MrPJS9lFOjOKazpuDPkEqAPSAlAKq7ipgoyMLb1CBxi3rzNUcrvjvqo/B0lpsTxIw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.6.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.6.8.tgz", - "integrity": "sha512-9tOPEQz4g8Xv1t5UEwL0KmQ6Z4U4WS5Q1EXOSUePGt2pgqOWli7zLxmCFkZtEutZLSsIWtDHwbHyhVfclkMs1Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.7.8.tgz", - "integrity": "sha512-oEPZ4i9E7h5GgtASPr6QoZdt8XCkjcjgj7lhEkWEHnMwUftz9FQ9FGOztek4wnoOuB1LxA6eO+gO1ORAGUeL6A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.4.8.tgz", - "integrity": "sha512-qAH6hKorKtjV2lPiEOEB58edF/NvY29twTu1aGOwSQx8lGjhomF+Z7xpBhJH8v4IGUMSP0aUA+N9N4IpE9xpkg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.4.8.tgz", - "integrity": "sha512-I8NOxdzliRUdniI4/QTEfXj5jonF2aILJOIGkcV4ZWeqMMP98ySKg4SeiIN1WAnLJUxjHNr7UnMUcPegxFtxMw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.5.8.tgz", - "integrity": "sha512-Kran9kcysdZdoiVZQ3EXfHTbZ48kJ6Er2sZ4eHaL2LSsqVlucn/8xrgxj36/z9qlrToNGNf+na4u6xp1Nz08KQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.5.tgz", - "integrity": "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.5.8.tgz", - "integrity": "sha512-SyFyL/ajuzJ79W+BpseFFbSz8/Rwj+QGqUlayeZE0jS2aCcjzV5tJBcbHgfypvSHpe7s2JmUHZmfpICaxwEs5g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.4.tgz", - "integrity": "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.14.8", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.14.8.tgz", - "integrity": "sha512-hnZ6wFwLHQYL/+wdvT6qD1Fkiclobv7Y3iBB2fyfr+osJcD5n4IMAOPaIFUj7WgxOwU/e13FubyebUigzMLXCQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", - "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.4.8.tgz", - "integrity": "sha512-BcniaGClLgBsy5Jf7dfkoId8B4n/8oL3xBzVeF1/I252yZvyrxyKG7ZvcfCGTGEahSigsz40ujf6R/3G1U0kig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.5.8.tgz", - "integrity": "sha512-vJ3w2vD2BzEGbnMfaBcrtPXi37IUtG9qQgVasMRiF+Ef+0S5fMgaEFcwBGYg7RkN7wKBAcg2r+ulF/4aj3+VYw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.4.8.tgz", - "integrity": "sha512-o6rhfnt15hwj2/1KjQ0PsvdEpxG9TYzR8pk6K0XnrtUDCJcWREq6uCSVHboRsOdghdp3zaVY5CfYV+wUvDUdrg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.4.8.tgz", - "integrity": "sha512-HoShD7ykB2wBA98UyyKGLUUKmsHovubDzGmCmpMemrTCICKHdfMsUyngJFEPEiJ2ujtRDEgpQD04cDOYY9u9rQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.5.8.tgz", - "integrity": "sha512-QjnRq1unlnaEq+z8ksNzg8Q6+IozO6z9QxyZXCKcarJquEfbqq4NZ2XQgYlCgQWK8f6GOyCdw84ju9E6GJDj9Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.4.8.tgz", - "integrity": "sha512-xNA1Wfkpq8yQmICs1RyXze+ZUKrRP1YlTqDXFE9YAMUeDy+rTuTx/Id5NRzWpkTYqW7205VAHcy6lu/13JAEbg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@smithy/util-endpoints": { - "version": "3.6.8", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.6.8.tgz", - "integrity": "sha512-KbkiYv00N9RbQVMm/k9ftoC9jWqCf+h9NBVN+ekbRKs5B+Dtzzkwhgfe1a+bn6ftaIkTF0V0d5IqGLb2ZL6Cvg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" }, - "node_modules/@smithy/util-hex-encoding": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-1.1.0.tgz", - "integrity": "sha512-7UtIE9eH0u41zpB60Jzr0oNCQ3hMJUabMcKRUVjmyHTXiWDE4vjSqN6qlih7rCNeKGbioS7f/y2Jgym4QZcKFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.5.0" - }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.4.8.tgz", - "integrity": "sha512-+N4NUzQgfqSwj9weJMcdnTXc9qEKOm/3XgRApX2G0eLU/fWt22GXdVxEBobZXU20OglTAOMuiUiiyEQoJ693bw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" + "node": ">=10" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@smithy/util-retry": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.5.8.tgz", - "integrity": "sha512-fPHkNhF3rt3mJHarO7aKfBmGLMnu2NNfGlfCPm/EUNHPGkrdzsdIsPXEgjd+RSEt6FpI93wlMH1XdtaG/XdWWQ==", - "license": "Apache-2.0", + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "type-detect": "4.0.8" } }, - "node_modules/@smithy/util-utf8": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.4.8.tgz", - "integrity": "sha512-hwVELJTTRUqwrEvMI73PwsP27cO1HgkAKDoKelhMS3biTd8z5iXj76UF7oemuDba3X5eHZqjedeyBUhJLns+Kg==", - "license": "Apache-2.0", + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@smithy/core": "^3.29.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@sinonjs/commons": "^3.0.0" } }, "node_modules/@socket.io/component-emitter": { @@ -5675,6 +4664,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", @@ -5685,12 +4685,6 @@ "@types/node": "*" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT" - }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -5957,19 +4951,33 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, "license": "MIT", "dependencies": { "@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", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -6031,6 +5039,15 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -6159,6 +5176,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -6176,7 +5202,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -6188,16 +5213,6 @@ "undici-types": "~7.8.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, "node_modules/@types/node/node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", @@ -6456,6 +5471,12 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -6531,18 +5552,133 @@ "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.19", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "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" }, - "engines": { - "node": "^14.18.0 || >=16.0.0" + "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" }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@vscode/sudo-prompt": { @@ -6659,19 +5795,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/amazon-cognito-identity-js": { - "version": "6.3.20", - "resolved": "https://registry.npmjs.org/amazon-cognito-identity-js/-/amazon-cognito-identity-js-6.3.20.tgz", - "integrity": "sha512-akaaLpDqz4i0m2XG4+x+XcHAlboRt3rydVrSiEFCKvaGZSmZdvnYW4SXqm2OGeVdZK0R1nIXjp8yEpID3rHC5Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "1.2.2", - "buffer": "4.9.2", - "fast-base64-decode": "^1.0.0", - "isomorphic-unfetch": "^3.0.0", - "js-cookie": "^3.0.7" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7170,6 +6293,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", @@ -7452,6 +6585,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -7572,6 +6715,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", @@ -7733,12 +6886,6 @@ "license": "MIT", "optional": true }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -7942,17 +7089,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -8036,6 +7172,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", @@ -8178,6 +7324,33 @@ "@capacitor/core": ">=7.0.0" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "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", @@ -8205,6 +7378,46 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -8214,6 +7427,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", @@ -8666,6 +7889,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -9137,6 +8370,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", @@ -9149,6 +8396,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", @@ -9574,6 +8847,67 @@ "node": ">=8" } }, + "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": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "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", @@ -9688,6 +9022,26 @@ "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", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -9724,6 +9078,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", @@ -9832,6 +9196,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -9865,6 +9238,19 @@ "integrity": "sha512-ndLq+hZriMCFgF/6eTYt8x+oe1O0F2AaIREWhupxu000y+rP5tswLzQfBbhXRBt5corTeNHGbbhkZRwPqJBU7A==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", @@ -10874,6 +10260,26 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "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", @@ -11034,6 +10440,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", @@ -11174,6 +10590,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -11195,12 +10617,6 @@ "@types/yauzl": "^2.9.1" } }, - "node_modules/fast-base64-decode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", - "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11577,22 +10993,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz", - "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -12420,10 +11820,50 @@ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/he": { @@ -12539,6 +11979,19 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12555,6 +12008,16 @@ "void-elements": "3.1.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -13017,6 +12480,12 @@ "node": ">=10" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -13062,6 +12531,30 @@ "node": ">=8" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -13221,6 +12714,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -13330,6 +12833,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -13429,6 +12942,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-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -13657,16 +13177,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/isomorphic-unfetch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", - "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.6.1", - "unfetch": "^4.2.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -14458,12 +13968,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-cookie": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", - "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -14492,6 +13996,109 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.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", + "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-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "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", @@ -14833,6 +14440,16 @@ "node": ">= 12.0.0" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -14845,6 +14462,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", @@ -14927,6 +14551,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -14962,40 +14596,317 @@ "dev": true, "license": "MIT" }, - "node_modules/matcher/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "node_modules/matcher/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "license": "BSD-3-Clause", + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "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": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -15046,178 +14957,741 @@ "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "license": "ISC" + }, + "node_modules/meow/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/meow/node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "license": "ISC" - }, - "node_modules/meow/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" + "micromark-util-types": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/meow/node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/meow/node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/meow/node_modules/read-pkg/node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "license": "BSD-2-Clause", + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/meow/node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/meow/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "license": "ISC", - "bin": { - "semver": "bin/semver" + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/meow/node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -16333,6 +16807,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -16510,6 +17009,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", @@ -16977,6 +17493,16 @@ "signal-exit": "^3.0.2" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -17013,6 +17539,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -17098,15 +17625,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "engines": { - "node": ">=0.4.x" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -17388,16 +17906,31 @@ "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "license": "MIT" }, - "node_modules/react-native-url-polyfill": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz", - "integrity": "sha512-w9JfSkvpqqlix9UjDvJjm1EjSt652zVQ6iwCIj1cVVkwXf4jQhQgTNXY6EVTwuAmUjg6BC6k9RHCBynoLFo3IQ==", + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", "license": "MIT", "dependencies": { - "whatwg-url-without-unicode": "8.0.0-3" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" }, "peerDependencies": { - "react-native": "*" + "@types/react": ">=18", + "react": ">=18" } }, "node_modules/react-redux": { @@ -17822,6 +18355,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remoteit": { "resolved": "electron", "link": true @@ -18650,6 +19249,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", @@ -19052,6 +19664,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", @@ -19383,6 +20002,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -19475,6 +20104,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", @@ -19500,6 +20136,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", @@ -19676,6 +20319,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -19755,6 +20412,44 @@ "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", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -19799,6 +20494,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", @@ -20034,6 +20736,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", @@ -20064,6 +20780,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", @@ -20124,6 +20890,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", @@ -20139,6 +20918,16 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -20157,6 +20946,16 @@ "node": ">= 14.0.0" } }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -20550,21 +21349,46 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/unfetch": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", - "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", - "license": "MIT" - }, "node_modules/unique-string": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", @@ -20580,14 +21404,72 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/universal-cookie": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz", - "integrity": "sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ==", + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.7.2" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/universalify": { @@ -20687,22 +21569,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", - "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==", - "license": "MIT", - "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", - "license": "MIT" - }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", @@ -20834,6 +21700,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vinyl": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", @@ -21037,6 +21931,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", @@ -21062,6 +22004,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", @@ -21071,6 +22124,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", @@ -21165,53 +22231,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/whatwg-url-without-unicode": { - "version": "8.0.0-3", - "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", - "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", - "license": "MIT", - "dependencies": { - "buffer": "^5.4.3", - "punycode": "^2.1.1", - "webidl-conversions": "^5.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/whatwg-url-without-unicode/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -21329,6 +22348,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", @@ -21522,6 +22558,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", @@ -21553,6 +22599,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", @@ -21672,28 +22725,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zen-observable": { - "version": "0.8.15", - "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", - "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", - "license": "MIT" - }, - "node_modules/zen-observable-ts": { - "version": "0.8.19", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.19.tgz", - "integrity": "sha512-u1a2rpE13G+jSzrg3aiCqXU5tN2kw41b+cBZGmnc+30YimdkKiDj9bTowcB41eL77/17RF/h+393AuVgShyheQ==", + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", "license": "MIT", - "dependencies": { - "tslib": "^1.9.3", - "zen-observable": "^0.8.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/zen-observable-ts/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/zxcvbn": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", diff --git a/package.json b/package.json index 3ab5d112c..3e50e9e59 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,13 @@ "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", - "version": "npm version $npm_package_version --workspaces && sh version.sh $npm_package_version" + "test": "npm run test -w=electron && npm run test -w=frontend", + "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", @@ -56,6 +61,7 @@ "js-yaml": "^4.3.2", "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/readme.md b/readme.md index be1c3ccf2..b36f87840 100644 --- a/readme.md +++ b/readme.md @@ -100,8 +100,47 @@ 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 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 +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) diff --git a/scripts/platforms-generate.mjs b/scripts/platforms-generate.mjs new file mode 100644 index 000000000..ad30b20db --- /dev/null +++ b/scripts/platforms-generate.mjs @@ -0,0 +1,282 @@ +#!/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.' + ) + } + return fromData(unwrapCli(stdout), 'exec-gql') +} + +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('; ')) + 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). +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)' + }` + ) + } +} diff --git a/types.d.ts b/types.d.ts index 66d8988d6..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 = { @@ -827,32 +827,58 @@ 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 + appOrigin?: string | null // the client's VERIFIABLE origin (claude.ai) — null for first-party + active: boolean + givenAt?: string + updatedAt?: string + lastUsedAt?: string | null + // The AS shape: every list below may be absent on an older deployment + scopes?: string[] + groups?: { + typeLabel?: string + resourceLabel?: string + api?: string | null + apiHost?: string | 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 + // "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 + 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 } + } + + type IGrantAction = { + key: string + label: string + piece?: string | null // consent's grammar (devices.connect → "Devices") + 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 } type IRouteType = 'failover' | 'p2p' | 'proxy' | 'public'