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 + +

{heading}

{sub}

""" + 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=="],