From f2cf2a729d733acd7c759d85c6ace2d602f50d6e Mon Sep 17 00:00:00 2001
From: Jamie Pine <32987599+jamiepine@users.noreply.github.com>
Date: Sun, 5 Jul 2026 03:18:30 -0700
Subject: [PATCH] Add "Log in with browser" cloud device login (#812)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add "Log in with browser" cloud device login
Connects the desktop app to Voicebox Cloud without the user ever handling an
API key. One button in Settings → General opens the system browser to
voicebox.sh, the user authorizes while signed in, and the credential lands
back in the app automatically.
Backend (FastAPI):
- /cloud/login/start opens the browser to the cloud authorize page with a
state we mint; the existing loopback server catches the redirect at
/cloud/callback and exchanges the one-time code (server-to-server, over TLS)
for a voicebox_ API key, verifies it against the API, and stores it.
- /cloud/status and /cloud/disconnect back the settings UI.
- state round-trip guards against login-CSRF; the key never crosses a browser
URL and is never exposed to the frontend (status returns a prefix only).
- CloudSettings singleton row; config gains VOICEBOX_CLOUD_URL /
VOICEBOX_CLOUD_API_URL (default the prod hosts, overridable for dev).
Frontend (React):
- CloudSection in Settings → General: "Log in with browser", polls status,
shows the connected device + a dashboard link. API keys are the advanced
path only, surfaced in the web dashboard.
The key is stored in the local app DB for now; OS keychain is a marked
follow-up.
* Address review feedback on cloud login
- time out status polling after 2 min so an abandoned browser flow
doesn't leave the button stuck on "Waiting for browser…"
- handle non-JSON / non-object payloads from the exchange and account
endpoints instead of 500ing after the state is consumed
- make singleton row creation race-safe (IntegrityError -> re-query)
- clear device_name on disconnect along with the rest of the metadata
- serve the dashboard URL from /cloud/status so the Manage link follows
VOICEBOX_CLOUD_URL instead of hardcoding production
- keep a "Disconnecting…" label on the disconnect button while pending
* Remove orphaned react-qr-code entries from lockfile
bun.lock was out of date with package.json (react-qr-code was removed
without reinstalling), failing the frozen-lockfile install in CI.
---
app/src/components/ServerTab/CloudSection.tsx | 151 +++++++++++++++
app/src/components/ServerTab/GeneralPage.tsx | 3 +
app/src/lib/api/client.ts | 17 ++
app/src/lib/api/types.ts | 15 ++
backend/config.py | 14 ++
backend/database/__init__.py | 2 +
backend/database/models.py | 22 +++
backend/models.py | 21 ++
backend/routes/__init__.py | 2 +
backend/routes/cloud.py | 75 +++++++
backend/services/cloud.py | 183 ++++++++++++++++++
bun.lock | 5 -
12 files changed, 505 insertions(+), 5 deletions(-)
create mode 100644 app/src/components/ServerTab/CloudSection.tsx
create mode 100644 backend/routes/cloud.py
create mode 100644 backend/services/cloud.py
diff --git a/app/src/components/ServerTab/CloudSection.tsx b/app/src/components/ServerTab/CloudSection.tsx
new file mode 100644
index 00000000..2821261e
--- /dev/null
+++ b/app/src/components/ServerTab/CloudSection.tsx
@@ -0,0 +1,151 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Cloud, Loader2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { useToast } from '@/components/ui/use-toast';
+import { apiClient } from '@/lib/api/client';
+import { SettingRow, SettingSection } from './SettingRow';
+
+// "Log in with browser" device pairing. The backend opens the system browser
+// and completes the code exchange; here we just kick it off and poll status
+// until the link goes live. The API key never touches the frontend.
+export function CloudSection() {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const [polling, setPolling] = useState(false);
+
+ const { data: status } = useQuery({
+ queryKey: ['cloud-status'],
+ queryFn: () => apiClient.getCloudStatus(),
+ refetchInterval: polling ? 2000 : false,
+ });
+
+ const connected = status?.connected ?? false;
+
+ // Once the browser flow completes, stop polling and celebrate.
+ useEffect(() => {
+ if (connected && polling) {
+ setPolling(false);
+ toast({
+ title: 'Connected to Voicebox Cloud',
+ description: `Linked as ${status?.device_name ?? 'this device'}.`,
+ });
+ }
+ }, [connected, polling, status?.device_name, toast]);
+
+ // Give up after two minutes so an abandoned browser flow doesn't leave the
+ // button stuck on "Waiting for browser…". The backend state stays valid for
+ // ten, so the user can simply start again.
+ useEffect(() => {
+ if (!polling) return;
+ const timeoutId = window.setTimeout(() => {
+ setPolling(false);
+ toast({
+ title: 'Sign-in timed out',
+ description: 'The browser sign-in was not completed. Try again.',
+ variant: 'destructive',
+ });
+ }, 120_000);
+ return () => window.clearTimeout(timeoutId);
+ }, [polling, toast]);
+
+ const startLogin = useMutation({
+ mutationFn: () => apiClient.startCloudLogin(),
+ onSuccess: () => {
+ setPolling(true);
+ toast({
+ title: 'Continue in your browser',
+ description: 'Authorize this device, then return here.',
+ });
+ },
+ onError: (error: Error) =>
+ toast({
+ title: 'Could not start sign-in',
+ description: error.message,
+ variant: 'destructive',
+ }),
+ });
+
+ const disconnect = useMutation({
+ mutationFn: () => apiClient.disconnectCloud(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
+ toast({
+ title: 'Disconnected',
+ description:
+ 'This device is no longer linked. The key stays valid until revoked in your account.',
+ });
+ },
+ onError: (error: Error) =>
+ toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }),
+ });
+
+ const busy = startLogin.isPending || polling;
+
+ return (
+
+ disconnect.mutate()}
+ size="sm"
+ variant="outline"
+ >
+ {disconnect.isPending ? (
+ <>
+
+ Disconnecting…
+ >
+ ) : (
+ 'Disconnect'
+ )}
+
+ ) : (
+
+ )
+ }
+ />
+
+ {connected && (
+
+
+ Open account dashboard ↗
+
+
+ )}
+
+ );
+}
diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx
index 70d35090..0d8d7af7 100644
--- a/app/src/components/ServerTab/GeneralPage.tsx
+++ b/app/src/components/ServerTab/GeneralPage.tsx
@@ -14,6 +14,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
+import { CloudSection } from './CloudSection';
import { LanguageSelect } from './LanguageSelect';
import { SettingRow, SettingSection } from './SettingRow';
import { ThemeSelect } from './ThemeSelect';
@@ -207,6 +208,8 @@ export function GeneralPage() {
/>
+
+
{platform.metadata.isTauri && }
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index afd7c84a..f89a17a3 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -51,6 +51,8 @@ import type {
MCPClientBinding,
MCPClientBindingListResponse,
MCPClientBindingUpsert,
+ CloudLoginStartResponse,
+ CloudStatus,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -938,6 +940,21 @@ class ApiClient {
return response.blob();
}
+
+ // Cloud (backup & sync) — browser-based device login. startCloudLogin opens
+ // the system browser server-side; the UI then polls getCloudStatus until the
+ // backend completes the exchange and the link goes live.
+ async getCloudStatus(): Promise {
+ return this.request('/cloud/status');
+ }
+
+ async startCloudLogin(): Promise {
+ return this.request('/cloud/login/start', { method: 'POST' });
+ }
+
+ async disconnectCloud(): Promise {
+ return this.request('/cloud/disconnect', { method: 'POST' });
+ }
}
export const apiClient = new ApiClient();
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index 00d84ed5..4a970749 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -542,3 +542,18 @@ export interface MCPClientBindingUpsert {
export interface MCPClientBindingListResponse {
items: MCPClientBinding[];
}
+
+/* ─── Cloud (backup & sync) ───────────────────────────────────────────── */
+
+export interface CloudLoginStartResponse {
+ authorize_url: string;
+}
+
+export interface CloudStatus {
+ connected: boolean;
+ device_name: string | null;
+ account_user_id: string | null;
+ key_prefix: string | null;
+ connected_at: string | null;
+ dashboard_url: string;
+}
diff --git a/backend/config.py b/backend/config.py
index 1929edbd..8d2df51a 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -138,3 +138,17 @@ def get_models_dir() -> Path:
path = _data_dir / "models"
path.mkdir(parents=True, exist_ok=True)
return path
+
+
+# Voicebox Cloud (backup & sync). Two hosts: the web app owns auth + device
+# pairing (voicebox.sh), the API owns sync + account endpoints
+# (api.voicebox.sh). Override both for local development, e.g.
+# VOICEBOX_CLOUD_URL=http://localhost:17592 VOICEBOX_CLOUD_API_URL=http://localhost:17593
+def get_cloud_web_url() -> str:
+ """Base URL of the Voicebox Cloud web app (auth + /connect + exchange)."""
+ return os.environ.get("VOICEBOX_CLOUD_URL", "https://voicebox.sh").rstrip("/")
+
+
+def get_cloud_api_url() -> str:
+ """Base URL of the Voicebox Cloud API (bearer-authenticated sync/account)."""
+ return os.environ.get("VOICEBOX_CLOUD_API_URL", "https://api.voicebox.sh").rstrip("/")
diff --git a/backend/database/__init__.py b/backend/database/__init__.py
index bfb4b124..fd1252bf 100644
--- a/backend/database/__init__.py
+++ b/backend/database/__init__.py
@@ -11,6 +11,7 @@ from .models import (
Capture,
CaptureSettings,
ChannelDeviceMapping,
+ CloudSettings,
EffectPreset,
Generation,
GenerationSettings,
@@ -32,6 +33,7 @@ __all__ = [
"Capture",
"CaptureSettings",
"ChannelDeviceMapping",
+ "CloudSettings",
"EffectPreset",
"Generation",
"GenerationSettings",
diff --git a/backend/database/models.py b/backend/database/models.py
index 6ef2213e..b85a55b1 100644
--- a/backend/database/models.py
+++ b/backend/database/models.py
@@ -234,6 +234,28 @@ class GenerationSettings(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+class CloudSettings(Base):
+ """Singleton row holding the link to a Voicebox Cloud account.
+
+ Populated by the "Log in with browser" pairing flow (see services/cloud.py):
+ the browser hands back a one-time code, which the backend exchanges for an
+ ``api_key`` it stores here. The key is a bearer credential for
+ api.voicebox.sh — auth only, never an encryption key (E2E key material lives
+ elsewhere). Stored in the local app database alongside the user's other data;
+ moving it to the OS keychain is a future hardening step. The ``id`` is
+ always 1; a null ``api_key`` means "not connected".
+ """
+
+ __tablename__ = "cloud_settings"
+
+ id = Column(Integer, primary_key=True, default=1)
+ api_key = Column(String, nullable=True)
+ device_name = Column(String, nullable=True)
+ account_user_id = Column(String, nullable=True)
+ connected_at = Column(DateTime, nullable=True)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, personality default).
diff --git a/backend/models.py b/backend/models.py
index 01f59b03..7970ce41 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -794,3 +794,24 @@ class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: List[AvailableEffect]
+
+
+# ─── Cloud (backup & sync) ──────────────────────────────────────────────
+
+
+class CloudLoginStartResponse(BaseModel):
+ """Returned when the desktop kicks off browser login. The backend has
+ already opened the browser; the URL is included for fallback/debugging."""
+
+ authorize_url: str
+
+
+class CloudStatusResponse(BaseModel):
+ """Current link between this device and a Voicebox Cloud account."""
+
+ connected: bool
+ device_name: Optional[str] = None
+ account_user_id: Optional[str] = None
+ key_prefix: Optional[str] = None
+ connected_at: Optional[datetime] = None
+ dashboard_url: str
diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py
index e551594e..42999d2d 100644
--- a/backend/routes/__init__.py
+++ b/backend/routes/__init__.py
@@ -24,6 +24,7 @@ def register_routers(app: FastAPI) -> None:
from .speak import router as speak_router
from .mcp_bindings import router as mcp_bindings_router
from .events import router as events_router
+ from .cloud import router as cloud_router
app.include_router(health_router)
app.include_router(profiles_router)
@@ -44,3 +45,4 @@ def register_routers(app: FastAPI) -> None:
app.include_router(speak_router)
app.include_router(mcp_bindings_router)
app.include_router(events_router)
+ app.include_router(cloud_router)
diff --git a/backend/routes/cloud.py b/backend/routes/cloud.py
new file mode 100644
index 00000000..3abf2ef5
--- /dev/null
+++ b/backend/routes/cloud.py
@@ -0,0 +1,75 @@
+"""Voicebox Cloud device login routes.
+
+The browser-based pairing flow:
+ 1. POST /cloud/login/start — opens the browser to the cloud authorize page.
+ 2. GET /cloud/callback — the browser lands here with a one-time code;
+ the backend exchanges it for an API key.
+ 3. GET /cloud/status — the UI polls this to learn when it connected.
+ 4. POST /cloud/disconnect — forget the local credential.
+"""
+
+import socket
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+from sqlalchemy.orm import Session
+
+from .. import models
+from ..database import get_db
+from ..services import cloud as cloud_service
+
+router = APIRouter(prefix="/cloud", tags=["cloud"])
+
+
+def _callback_url(request: Request) -> str:
+ # Always loopback — the cloud only redirects codes to 127.0.0.1/localhost.
+ port = request.url.port or 17493
+ return f"http://127.0.0.1:{port}/cloud/callback"
+
+
+@router.post("/login/start", response_model=models.CloudLoginStartResponse)
+async def start_cloud_login(request: Request):
+ device_name = socket.gethostname() or "Desktop"
+ authorize_url = cloud_service.start_login(_callback_url(request), device_name)
+ return models.CloudLoginStartResponse(authorize_url=authorize_url)
+
+
+@router.get("/callback", response_class=HTMLResponse)
+async def cloud_callback(
+ request: Request,
+ code: str = "",
+ state: str = "",
+ db: Session = Depends(get_db),
+):
+ ok, message = await cloud_service.handle_callback(db, code=code, state=state)
+ heading = "You're connected" if ok else "Couldn't connect"
+ accent = "#16a34a" if ok else "#dc2626"
+ sub = (
+ "Voicebox is now linked to your account. You can close this tab and return to the app."
+ if ok
+ else message
+ )
+ html = f"""
+
+
+Voicebox Cloud
+
+"""
+ return HTMLResponse(content=html, status_code=200 if ok else 400)
+
+
+@router.get("/status", response_model=models.CloudStatusResponse)
+async def cloud_status(db: Session = Depends(get_db)):
+ return models.CloudStatusResponse(**cloud_service.get_status(db))
+
+
+@router.post("/disconnect", response_model=models.CloudStatusResponse)
+async def cloud_disconnect(db: Session = Depends(get_db)):
+ cloud_service.disconnect(db)
+ return models.CloudStatusResponse(**cloud_service.get_status(db))
diff --git a/backend/services/cloud.py b/backend/services/cloud.py
new file mode 100644
index 00000000..1485b4df
--- /dev/null
+++ b/backend/services/cloud.py
@@ -0,0 +1,183 @@
+"""
+Voicebox Cloud device login — the "Log in with browser" flow.
+
+The desktop opens the browser to ``{web}/connect``; the user authorizes while
+signed in; the cloud redirects a single-use code back to this backend's loopback
+callback. We exchange that code (server-to-server, over TLS) for a ``voicebox_…``
+API key, verify the key against the API, and store it locally. The key never
+travels through a browser URL, and an unfinished flow leaves nothing behind.
+
+The ``state`` we mint and round-trip prevents login-CSRF: a callback whose state
+we didn't issue (e.g. an attacker tricking the user into hitting the loopback
+callback with their own code) is rejected.
+"""
+
+import logging
+import secrets
+import time
+import webbrowser
+from urllib.parse import urlencode
+
+import httpx
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from .. import config
+from ..database import CloudSettings as DBCloudSettings
+
+logger = logging.getLogger(__name__)
+
+SINGLETON_ID = 1
+PENDING_TTL_SECONDS = 600 # the whole browser flow must finish within 10 min
+
+# state -> expiry epoch. In-memory: a single backend process owns the flow, and a
+# dropped pairing should simply be restarted.
+_pending: dict[str, float] = {}
+
+
+def _prune() -> None:
+ now = time.time()
+ for state, expiry in list(_pending.items()):
+ if expiry < now:
+ _pending.pop(state, None)
+
+
+def _json_dict(response: httpx.Response) -> dict | None:
+ """Parsed JSON body, or None when it isn't a JSON object."""
+ try:
+ payload = response.json()
+ except ValueError:
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+def _consume_state(state: str) -> bool:
+ """Validate and single-use-consume a pending state."""
+ _prune()
+ expiry = _pending.pop(state, None)
+ return expiry is not None and expiry >= time.time()
+
+
+def start_login(callback_url: str, device_name: str) -> str:
+ """Mint a state, build the authorize URL, and open the browser.
+
+ Returns the authorize URL (also opened here) so the caller can surface it as
+ a fallback if the browser didn't open.
+ """
+ state = secrets.token_urlsafe(24)
+ _prune()
+ _pending[state] = time.time() + PENDING_TTL_SECONDS
+
+ params = urlencode({"redirect_uri": callback_url, "state": state, "name": device_name})
+ authorize_url = f"{config.get_cloud_web_url()}/connect?{params}"
+
+ try:
+ webbrowser.open(authorize_url)
+ except Exception: # pragma: no cover - platform dependent
+ logger.exception("failed to open browser for cloud login")
+
+ return authorize_url
+
+
+async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str]:
+ """Exchange the code for an API key and store it. Returns (ok, message)."""
+ if not _consume_state(state):
+ return False, "This sign-in link is invalid or has expired. Start again from the app."
+ if not code:
+ return False, "Missing authorization code."
+
+ web = config.get_cloud_web_url()
+ api = config.get_cloud_api_url()
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ exchanged = await client.post(f"{web}/api/connect/exchange", json={"code": code})
+ if exchanged.status_code != 200:
+ logger.warning("cloud exchange rejected code: %s", exchanged.status_code)
+ return False, "Could not complete sign-in — the code was rejected."
+ payload = _json_dict(exchanged)
+ if payload is None:
+ logger.warning("cloud exchange returned a non-JSON payload")
+ return False, "Voicebox Cloud returned an unexpected response."
+ api_key = payload.get("key")
+ device_name = payload.get("label")
+ if not api_key:
+ return False, "Voicebox Cloud did not return a key."
+
+ # Confirm the freshly minted key actually authenticates the API.
+ me = await client.get(
+ f"{api}/v1/account/me",
+ headers={"Authorization": f"Bearer {api_key}"},
+ )
+ if me.status_code != 200:
+ logger.warning("minted key failed verification: %s", me.status_code)
+ return False, "Sign-in succeeded but the key could not be verified."
+ # The 200 above proves the key works; the user id is best-effort.
+ data = (_json_dict(me) or {}).get("data")
+ account_user_id = data.get("userId") if isinstance(data, dict) else None
+ except httpx.HTTPError:
+ logger.exception("network error during cloud exchange")
+ return False, "Could not reach Voicebox Cloud. Check your connection and try again."
+
+ _store_key(db, api_key=api_key, device_name=device_name, account_user_id=account_user_id)
+ logger.info("connected to Voicebox Cloud as device %r", device_name)
+ return True, "Connected"
+
+
+def _get_or_create_row(db: Session) -> DBCloudSettings:
+ row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).first()
+ if row is None:
+ row = DBCloudSettings(id=SINGLETON_ID)
+ db.add(row)
+ try:
+ db.commit()
+ except IntegrityError:
+ # Another request created the singleton concurrently.
+ db.rollback()
+ row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).one()
+ else:
+ db.refresh(row)
+ return row
+
+
+def _store_key(db: Session, *, api_key: str, device_name: str | None, account_user_id: str | None):
+ from datetime import datetime
+
+ row = _get_or_create_row(db)
+ row.api_key = api_key
+ row.device_name = device_name
+ row.account_user_id = account_user_id
+ row.connected_at = datetime.utcnow()
+ db.commit()
+
+
+def get_status(db: Session) -> dict:
+ """Local view of the cloud link — never returns the full key."""
+ row = _get_or_create_row(db)
+ connected = bool(row.api_key)
+ # Prefix only: "voicebox_" (9) + 8 chars, matching the cloud's key_prefix.
+ key_prefix = row.api_key[:17] if row.api_key else None
+ return {
+ "connected": connected,
+ "device_name": row.device_name if connected else None,
+ "account_user_id": row.account_user_id if connected else None,
+ "key_prefix": key_prefix,
+ "connected_at": row.connected_at if connected else None,
+ "dashboard_url": f"{config.get_cloud_web_url()}/account",
+ }
+
+
+def disconnect(db: Session) -> None:
+ """Forget the local credential. The key remains valid on the server until
+ revoked from the account dashboard — surface that in the UI."""
+ row = _get_or_create_row(db)
+ row.api_key = None
+ row.device_name = None
+ row.account_user_id = None
+ row.connected_at = None
+ db.commit()
+
+
+def get_api_key(db: Session) -> str | None:
+ """The stored bearer key, for the (future) sync client. None if not linked."""
+ row = _get_or_create_row(db)
+ return row.api_key
diff --git a/bun.lock b/bun.lock
index c8b3dfda..74d751ec 100644
--- a/bun.lock
+++ b/bun.lock
@@ -57,7 +57,6 @@
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-i18next": "^17.0.4",
- "react-qr-code": "^2.0.18",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
@@ -1006,8 +1005,6 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
- "qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="],
-
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
@@ -1022,8 +1019,6 @@
"react-loaders": ["react-loaders@3.0.1", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
- "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="],
-
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],