mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -07:00
* 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.
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
"""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"""<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Voicebox Cloud</title>
|
|
<style>
|
|
body {{ margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background:#0b0b0d; color:#e7e7ea; }}
|
|
.card {{ max-width:28rem; padding:2.5rem; text-align:center; }}
|
|
h1 {{ font-size:1.5rem; margin:0 0 .5rem; color:{accent}; }}
|
|
p {{ color:#a1a1aa; line-height:1.5; }}
|
|
</style></head>
|
|
<body><div class="card"><h1>{heading}</h1><p>{sub}</p></div></body></html>"""
|
|
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))
|