From 7a6a49655ca0a0a746d1df5f6f7440e31db3a9f1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 7 May 2026 09:57:49 -0700 Subject: [PATCH] [eric] account flow + settings tweaks --- backend/apps/auth/__init__.py | 0 backend/apps/auth/router.py | 250 +++++++++++++++ backend/apps/service/client.py | 12 +- backend/apps/settings/models.py | 8 + backend/main.py | 3 +- backend/tests/test_auth_router.py | 270 ++++++++++++++++ frontend/src/app/Main.tsx | 91 ++++++ frontend/src/app/components/SignInGate.tsx | 303 ++++++++++++++++++ .../src/app/pages/AgentChat/AgentChat.tsx | 69 +++- .../src/app/pages/AgentChat/MessageBubble.tsx | 57 +++- frontend/src/shared/hooks/useDeepLink.ts | 33 +- frontend/src/shared/state/settingsSlice.ts | 54 ++++ 12 files changed, 1127 insertions(+), 23 deletions(-) create mode 100644 backend/apps/auth/__init__.py create mode 100644 backend/apps/auth/router.py create mode 100644 backend/tests/test_auth_router.py create mode 100644 frontend/src/app/components/SignInGate.tsx diff --git a/backend/apps/auth/__init__.py b/backend/apps/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py new file mode 100644 index 00000000..cd4fcd30 --- /dev/null +++ b/backend/apps/auth/router.py @@ -0,0 +1,250 @@ +"""Desktop-side sign-in endpoints. Mirrors openswarm-cloud/src/routes/auth/*. + +The cloud handles the actual OAuth / magic-link flows. The desktop's role is +narrow: when the bearer-handoff page POSTs the token to localhost, we +validate it with the cloud (returns user_id + email + plan) and persist it +to the local settings store so subsequent requests carry the bearer. + +POST /api/auth/signin-activate {token, signin_method, email?} + Validates the bearer at cloud /api/auth/signin-activate. + Persists user_id, user_email, signin_method, and (if a paid plan was + returned) bearer + plan + expires. + +POST /api/auth/signout + Calls cloud /api/auth/signout to revoke the bearer, then clears local + identity fields. Brings the user back to the sign-in gate. + +POST /api/auth/identity-status {install_id?} + Local proxy to cloud /api/me/identity-status — drives the gate's + soft-vs-hard decision. Wraps it in our local backend so the renderer + doesn't need to know the cloud URL. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from typing import Optional, Literal + +import httpx +from fastapi import HTTPException +from pydantic import BaseModel + +from backend.config.Apps import SubApp +from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL +from backend.apps.settings.settings import load_settings, save_settings_async + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def auth_lifespan(): + yield + + +auth = SubApp("auth", auth_lifespan) + + +def _proxy_url() -> str: + settings_obj = load_settings() + url = (getattr(settings_obj, "openswarm_proxy_url", None) + or OPENSWARM_DEFAULT_PROXY_URL) + return url.rstrip("/") + + +def _sync_identity_to_service(settings_obj) -> None: + """Push user_id + email + signin_method into the service-sync identify + pipeline so every event from this user has the right Person properties.""" + try: + from backend.apps.service.client import identify as _identify + except Exception: + return + props = { + "signin_method": getattr(settings_obj, "signin_method", None), + "is_signed_in": bool(getattr(settings_obj, "user_id", None)), + } + email = getattr(settings_obj, "user_email", None) + if email: + props["email"] = email + try: + _identify(props) + except Exception as e: + logger.debug("identify sync failed: %s", e) + + +# --------------------------------------------------------------------------- +# POST /api/auth/signin-activate +# --------------------------------------------------------------------------- + +class SigninActivateRequest(BaseModel): + token: str + signin_method: Literal["google", "magic_link"] + email: Optional[str] = None + + +@auth.router.post("/signin-activate") +async def signin_activate(body: SigninActivateRequest): + """Validate a freshly-minted sign-in bearer and persist it locally. + + The bearer-handoff page (cloud lib/authMint.ts → bearerHandoffPage()) + POSTs to this endpoint after a Google OAuth or magic-link flow. We + re-validate the bearer with the cloud — never just trust whatever + arrives at the localhost endpoint — then write user_id + email + + signin_method to settings so the renderer can dismiss the gate. + """ + if not body.token or len(body.token) < 16: + raise HTTPException(status_code=400, detail="Invalid token") + + proxy = _proxy_url() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.post( + f"{proxy}/api/auth/signin-activate", + json={ + "token": body.token, + "signin_method": body.signin_method, + "email": body.email, + }, + ) + except httpx.HTTPError as e: + raise HTTPException( + status_code=502, + detail=f"Could not reach sign-in service: {e}", + ) + + if r.status_code == 401: + raise HTTPException(status_code=401, detail="Token rejected by service") + if r.status_code >= 400: + raise HTTPException( + status_code=r.status_code, + detail=r.text[:200] or "Service error", + ) + + me = r.json() + user_id = me.get("user_id") + email = me.get("email") + plan = me.get("plan") + expires = me.get("expires") + method = me.get("signin_method") or body.signin_method + + settings_obj = load_settings() + settings_obj.user_id = user_id + settings_obj.user_email = email + settings_obj.signin_method = method + # If the user happens to be a paying customer too (Stripe + sign-in + # share a user row by email), surface plan/expires so the chat picker + # exposes Pro models. Free-tier signups land here with plan="free" + # and expires=null — connection_mode stays own_key. + if isinstance(plan, str) and plan != "free": + settings_obj.connection_mode = "openswarm-pro" + settings_obj.openswarm_bearer_token = body.token + settings_obj.openswarm_proxy_url = proxy + settings_obj.openswarm_subscription_plan = plan + if isinstance(expires, str): + settings_obj.openswarm_subscription_expires = expires + else: + # Free-tier: still store the bearer so future API calls can identify + # the user (used by /api/me/profile, /api/auth/signout). Do NOT flip + # connection_mode — that's reserved for paid plans only so chat + # routing keeps using own_key/BYO. + settings_obj.openswarm_bearer_token = body.token + settings_obj.openswarm_proxy_url = proxy + + await save_settings_async(settings_obj) + _sync_identity_to_service(settings_obj) + + return { + "ok": True, + "user_id": user_id, + "email": email, + "plan": plan or "free", + "signin_method": method, + } + + +# --------------------------------------------------------------------------- +# POST /api/auth/signout +# --------------------------------------------------------------------------- + +@auth.router.post("/signout") +async def signout(): + """Revoke the cloud-side bearer + clear local identity state.""" + settings_obj = load_settings() + bearer = getattr(settings_obj, "openswarm_bearer_token", None) + proxy = _proxy_url() + if bearer: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post( + f"{proxy}/api/auth/signout", + headers={"Authorization": f"Bearer {bearer}"}, + ) + except httpx.HTTPError as e: + # Network failure shouldn't strand the user signed-in locally; + # the cloud token is invalidated lazily on next use anyway. + logger.warning("cloud signout failed (clearing local anyway): %s", e) + + settings_obj.user_id = None + settings_obj.user_email = None + settings_obj.signin_method = None + settings_obj.openswarm_bearer_token = None + settings_obj.connection_mode = "own_key" + settings_obj.openswarm_subscription_plan = None + settings_obj.openswarm_subscription_expires = None + settings_obj.openswarm_usage_cached = None + await save_settings_async(settings_obj) + _sync_identity_to_service(settings_obj) + return {"ok": True} + + +# --------------------------------------------------------------------------- +# GET /api/auth/identity-status +# --------------------------------------------------------------------------- + +@auth.router.get("/identity-status") +async def identity_status(): + """Returns gate-state for the renderer. + + The renderer's SignInGateLoader calls this on mount to decide between + soft gate (banner) vs hard gate (modal). Local-side authoritative + field is settings.user_id; the cloud answers install age + grace + deadline. + """ + settings_obj = load_settings() + user_id = getattr(settings_obj, "user_id", None) + if user_id: + return { + "authed": True, + "user_id": user_id, + "email": getattr(settings_obj, "user_email", None), + "signin_method": getattr(settings_obj, "signin_method", None), + "hard_gate": False, + } + + # Not signed in — defer to cloud for install-age + grace-window math. + install_id = getattr(settings_obj, "installation_id", None) + if not install_id: + # No install_id yet (very fresh install before first sync) — hard gate. + return {"authed": False, "hard_gate": True, "install_age_days": 0, "deadline_ts": None} + + proxy = _proxy_url() + try: + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get( + f"{proxy}/api/me/identity-status", + params={"install_id": install_id}, + ) + if r.status_code == 200: + data = r.json() + return { + "authed": False, + "hard_gate": bool(data.get("hard_gate", True)), + "install_age_days": int(data.get("install_age_days", 0)), + "deadline_ts": data.get("deadline_ts"), + } + except httpx.HTTPError as e: + logger.debug("identity-status cloud fetch failed: %s", e) + + # Cloud unreachable — fail open with soft gate so a flaky network + # doesn't lock the user out. Renderer will retry on next mount. + return {"authed": False, "hard_gate": False, "install_age_days": 0, "deadline_ts": None} diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index 099dd82b..0242303d 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -91,7 +91,17 @@ def _get_user_id() -> Optional[str]: try: from backend.apps.settings.settings import load_settings s = load_settings() - return getattr(s, "user_email", None) or None + # Prefer the cloud-issued user_id (UUID) if the user has signed in + # via Google OAuth, magic link, or Stripe checkout — that's the + # authoritative identity. Falls back to user_email for installs + # that haven't completed sign-in yet (so existing onboarding-only + # installs don't lose their Person history during the v1.0.30 + # rollout). After every install signs in, this fallback drops out. + return ( + getattr(s, "user_id", None) + or getattr(s, "user_email", None) + or None + ) except Exception: return None diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index ed40691b..13a64074 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -78,6 +78,14 @@ class AppSettings(BaseModel): openswarm_subscription_plan: Optional[str] = None # "hobby"|"pro"|"pro_plus"|"ultra" openswarm_subscription_expires: Optional[str] = None # ISO 8601 openswarm_usage_cached: Optional[dict] = None # {count, limit, window_end_at} + # Identity (v1.0.30+). Populated after a successful sign-in via the cloud's + # /api/auth/signin-activate endpoint (Google OAuth or email magic link). + # Stripe checkout also populates these because the cloud's bearer-mint + # always returns user info. Distinct from user_email above which was + # historically a self-reported onboarding field — the values agree once + # sign-in completes (server-validated wins). + user_id: Optional[str] = None + signin_method: Optional[Literal["google", "magic_link", "stripe"]] = None class CustomProvider(BaseModel): diff --git a/backend/main.py b/backend/main.py index 7d5b5186..604d6e7c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -39,13 +39,14 @@ from backend.apps.outputs.outputs import outputs from backend.apps.dashboards.dashboards import dashboards from backend.apps.service.service import service from backend.apps.subscription.router import subscription +from backend.apps.auth.router import auth from backend.apps.web.web import web from backend.apps.agents.anthropic_proxy import anthropic_proxy from fastapi.middleware.cors import CORSMiddleware from fastapi import WebSocket, WebSocketDisconnect import json -main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, web, anthropic_proxy]) +main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_auth_router.py new file mode 100644 index 00000000..1c3740ca --- /dev/null +++ b/backend/tests/test_auth_router.py @@ -0,0 +1,270 @@ +"""Smoketests for the desktop-side auth subapp. + +These tests don't hit the real cloud — they patch httpx so we can simulate +each cloud response and assert the local persistence + identify-status +logic is right across every gate-dismissal path the renderer cares about. +""" + +from __future__ import annotations + +import pytest +from unittest.mock import patch, AsyncMock +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + """Returns a TestClient pre-loaded with the local backend's auth token + so the LocalAuthMiddleware doesn't reject our requests with 401.""" + import backend.auth as auth_mod + if not auth_mod._TOKEN: + # Tests sometimes run without backend.main's startup hook firing. + # Generate a token directly so request_matches_token has something + # to compare against. + import secrets + auth_mod._TOKEN = secrets.token_urlsafe(32) + return TestClient(app, headers={"Authorization": f"Bearer {auth_mod._TOKEN}"}) + + +@pytest.fixture +def reset_settings(): + """Snapshot + restore settings around each test so writes don't leak.""" + from backend.apps.settings.settings import load_settings, _save_settings + + original = load_settings().model_copy(deep=True) + yield + _save_settings(original) + + +# --------------------------------------------------------------------------- +# /api/auth/signin-activate +# --------------------------------------------------------------------------- + +def test_signin_activate_persists_user_id(client, reset_settings): + fake_response = AsyncMock() + fake_response.status_code = 200 + fake_response.json = lambda: { + "user_id": "u-1234", + "email": "smoke@example.com", + "plan": "free", + "expires": None, + "signin_method": "google", + } + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.post = AsyncMock(return_value=fake_response) + + r = client.post( + "/api/auth/signin-activate", + json={ + "token": "fake-bearer-1234567890abcdef", + "signin_method": "google", + "email": "smoke@example.com", + }, + ) + assert r.status_code == 200 + body = r.json() + assert body["user_id"] == "u-1234" + assert body["email"] == "smoke@example.com" + assert body["plan"] == "free" + + # Persisted to settings. + from backend.apps.settings.settings import load_settings + s = load_settings() + assert s.user_id == "u-1234" + assert s.user_email == "smoke@example.com" + assert s.signin_method == "google" + + +def test_signin_activate_paid_user_flips_pro_mode(client, reset_settings): + """A signed-in user who already has a Stripe subscription should also + flip into openswarm-pro routing — covers the Google-then-Stripe and + Stripe-then-Google merge cases.""" + fake_response = AsyncMock() + fake_response.status_code = 200 + fake_response.json = lambda: { + "user_id": "u-paid", + "email": "paid@example.com", + "plan": "pro", + "expires": "2027-01-01T00:00:00.000Z", + "signin_method": "magic_link", + } + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.post = AsyncMock(return_value=fake_response) + + r = client.post( + "/api/auth/signin-activate", + json={ + "token": "fake-paid-bearer-abcdef0123456789", + "signin_method": "magic_link", + }, + ) + assert r.status_code == 200 + from backend.apps.settings.settings import load_settings + s = load_settings() + assert s.user_id == "u-paid" + assert s.connection_mode == "openswarm-pro" + assert s.openswarm_subscription_plan == "pro" + assert s.openswarm_subscription_expires == "2027-01-01T00:00:00.000Z" + + +def test_signin_activate_invalid_token_returns_401(client, reset_settings): + fake_response = AsyncMock() + fake_response.status_code = 401 + fake_response.text = "Invalid token" + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.post = AsyncMock(return_value=fake_response) + + r = client.post( + "/api/auth/signin-activate", + json={"token": "definitely-bad-token-xxxx", "signin_method": "google"}, + ) + assert r.status_code == 401 + + +def test_signin_activate_short_token_rejected_locally(client, reset_settings): + """Short tokens rejected before we even hit the cloud — saves a round trip.""" + r = client.post( + "/api/auth/signin-activate", + json={"token": "short", "signin_method": "google"}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# /api/auth/identity-status — gate-state for the renderer +# --------------------------------------------------------------------------- + +def test_identity_status_signed_in_user_returns_authed_true(client, reset_settings): + from backend.apps.settings.settings import load_settings, _save_settings + s = load_settings() + s.user_id = "u-already-signed-in" + s.user_email = "in@example.com" + s.signin_method = "google" + _save_settings(s) + + r = client.get("/api/auth/identity-status") + assert r.status_code == 200 + body = r.json() + assert body["authed"] is True + assert body["user_id"] == "u-already-signed-in" + assert body["hard_gate"] is False + + +def test_identity_status_unsigned_no_install_id_hard_gates(client, reset_settings): + from backend.apps.settings.settings import load_settings, _save_settings + s = load_settings() + s.user_id = None + s.user_email = None + s.signin_method = None + s.installation_id = None + _save_settings(s) + + r = client.get("/api/auth/identity-status") + assert r.status_code == 200 + body = r.json() + assert body["authed"] is False + assert body["hard_gate"] is True + + +def test_identity_status_cloud_unreachable_fails_open_to_soft(client, reset_settings): + """If the cloud is unreachable, fall back to soft gate so the user + isn't locked out by a flaky network. Renderer retries on next mount.""" + from backend.apps.settings.settings import load_settings, _save_settings + s = load_settings() + s.user_id = None + s.installation_id = "test-install-aaa" + _save_settings(s) + + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + # Simulate network error. + import httpx as _httpx + instance.get = AsyncMock(side_effect=_httpx.HTTPError("network down")) + + r = client.get("/api/auth/identity-status") + assert r.status_code == 200 + body = r.json() + assert body["authed"] is False + assert body["hard_gate"] is False # fail open + + +def test_identity_status_cloud_says_hard_gate(client, reset_settings): + from backend.apps.settings.settings import load_settings, _save_settings + s = load_settings() + s.user_id = None + s.installation_id = "test-install-bbb" + _save_settings(s) + + fake_response = AsyncMock() + fake_response.status_code = 200 + fake_response.json = lambda: { + "authed": False, + "hard_gate": True, + "install_age_days": 60, + "deadline_ts": 1000, + } + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.get = AsyncMock(return_value=fake_response) + + r = client.get("/api/auth/identity-status") + body = r.json() + assert body["authed"] is False + assert body["hard_gate"] is True + assert body["install_age_days"] == 60 + + +# --------------------------------------------------------------------------- +# /api/auth/signout +# --------------------------------------------------------------------------- + +def test_signout_clears_local_identity(client, reset_settings): + from backend.apps.settings.settings import load_settings, _save_settings + s = load_settings() + s.user_id = "u-bye" + s.user_email = "bye@example.com" + s.signin_method = "magic_link" + s.openswarm_bearer_token = "bearer-to-revoke-xxxxxxxx" + s.connection_mode = "openswarm-pro" + _save_settings(s) + + fake_response = AsyncMock() + fake_response.status_code = 200 + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.post = AsyncMock(return_value=fake_response) + + r = client.post("/api/auth/signout") + assert r.status_code == 200 + + s2 = load_settings() + assert s2.user_id is None + assert s2.user_email is None + assert s2.signin_method is None + assert s2.openswarm_bearer_token is None + assert s2.connection_mode == "own_key" + + +def test_signout_succeeds_even_when_cloud_unreachable(client, reset_settings): + """A flaky network shouldn't strand the user signed-in locally.""" + from backend.apps.settings.settings import load_settings, _save_settings + s = load_settings() + s.user_id = "u-flaky" + s.openswarm_bearer_token = "bearer-flaky-network-xxxx" + _save_settings(s) + + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + import httpx as _httpx + instance.post = AsyncMock(side_effect=_httpx.HTTPError("network down")) + + r = client.post("/api/auth/signout") + assert r.status_code == 200 + s2 = load_settings() + assert s2.user_id is None + assert s2.openswarm_bearer_token is None diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 846652a5..96f98e67 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -28,6 +28,7 @@ const Views = lazy(() => import('./pages/Views/Views')); const Customization = lazy(() => import('./pages/Customization/Customization')); const Analytics = lazy(() => import('./pages/Analytics/Analytics')); const OnboardingModal = lazy(() => import('./components/OnboardingModal')); +const SignInGate = lazy(() => import('./components/SignInGate')); import { report, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; import { useRouteTracker } from '@/shared/hooks/useRouteTracker'; import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts'; @@ -196,6 +197,94 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = return <>{children}; }; +// Sign-in gate. Sits between SettingsLoader and DefaultModelGuard so the +// gate is the very first thing a user without a user_id sees. Two modes: +// +// - Hard gate (fresh installs, existing installs past the 30-day grace): +// Modal blocks the app until sign-in completes. No skip link. +// - Soft gate (existing installs inside the grace window): Modal can be +// dismissed. settings.signin_skipped_until_ts persists "remind me in 7 +// days." On the next launch after that timestamp, the modal returns. +// +// Already-signed-in users (settings.user_id != null) skip the gate. Existing +// paid Stripe users without explicit sign-in also skip — their bearer is +// valid even though user_id might not have been backfilled yet (deferred +// to a one-time /api/me hit on the v1.0.30 first-launch). For the simple +// case we treat openswarm_bearer_token alone as "signed in" so paying +// customers never see the gate. +interface IdentityStatus { + authed: boolean; + hard_gate: boolean; + install_age_days?: number; + deadline_ts?: number | null; +} + +const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const settings = useAppSelector((s) => s.settings.data); + const settingsLoaded = useAppSelector((s) => s.settings.loaded); + const [status, setStatus] = useState(null); + const [skipTs, setSkipTs] = useState(0); + + // Already authenticated, either via the new sign-in flow (user_id set) or + // a still-valid Stripe bearer (paid user upgrading from v1.0.29). + const alreadySignedIn = Boolean(settings.user_id || settings.openswarm_bearer_token); + + useEffect(() => { + if (!settingsLoaded) return; + if (alreadySignedIn) { + setStatus({ authed: true, hard_gate: false }); + return; + } + let cancelled = false; + fetch(`${API_BASE}/auth/identity-status`) + .then((r) => (r.ok ? r.json() : { authed: false, hard_gate: true })) + .then((data) => { + if (!cancelled) setStatus(data as IdentityStatus); + }) + .catch(() => { + // Cloud unreachable → fail open with soft gate so the user can keep + // working. The gate will retry on next mount. + if (!cancelled) setStatus({ authed: false, hard_gate: false }); + }); + return () => { cancelled = true; }; + }, [settingsLoaded, alreadySignedIn]); + + // Read the persisted "remind me later" timestamp from localStorage so + // soft-gate skip survives reloads but doesn't bloat AppSettings. + useEffect(() => { + try { + const raw = window.localStorage.getItem('openswarm_signin_skipped_until'); + const n = raw ? parseInt(raw, 10) : 0; + if (Number.isFinite(n)) setSkipTs(n); + } catch { /* localStorage unavailable */ } + }, []); + + if (!settingsLoaded || !status) return null; + if (status.authed) return <>{children}; + + const skipActive = !status.hard_gate && Date.now() < skipTs; + if (skipActive) return <>{children}; + + return ( + <> + {children} + + { + // 7-day reminder window for soft gate. + const until = Date.now() + 7 * 24 * 60 * 60 * 1000; + try { + window.localStorage.setItem('openswarm_signin_skipped_until', String(until)); + } catch { /* ignore */ } + setSkipTs(until); + }} + /> + + + ); +}; + // Priority order for picking a default model when the user's stored // default_model is unreachable (no matching provider connected). The user's // preferred fallback ordering: direct provider keys first, then OpenSwarm @@ -371,6 +460,7 @@ const ThemedApp: React.FC = () => { + @@ -400,6 +490,7 @@ const ThemedApp: React.FC = () => { + diff --git a/frontend/src/app/components/SignInGate.tsx b/frontend/src/app/components/SignInGate.tsx new file mode 100644 index 00000000..5a282295 --- /dev/null +++ b/frontend/src/app/components/SignInGate.tsx @@ -0,0 +1,303 @@ +// Sign-in gate. Shown at first launch (and to existing users past their +// soft-gate grace window) before any other UI mounts. Two paths: +// +// 1. "Continue with Google" → shell.openExternal opens the cloud's +// /api/auth/google/start in the system browser. Cloud handles the +// Google round-trip and serves a bearer-handoff page that POSTs the +// token directly to the local backend (same pattern as Stripe). +// +// 2. "Send magic link" → POST /api/auth/email/start to the cloud (proxied +// through the local backend so the renderer doesn't need cloud URL). +// User clicks link in email, cloud verifies + serves the same +// bearer-handoff page. +// +// Either way, after the bearer lands, settings.user_id flips to non-null +// and the gate self-dismisses (driven by SignInGateLoader). + +import React, { useState } from 'react'; +import { + Box, + Typography, + Modal, + Button, + TextField, + CircularProgress, + Divider, + Link, +} from '@mui/material'; +import GoogleIcon from '@mui/icons-material/Google'; +import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { useAppSelector } from '@/shared/hooks'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { API_BASE, OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; +import { report } from '@/shared/serviceClient'; + +interface SignInGateProps { + /** Soft gate adds a "Skip for now" link; hard gate omits it. */ + softGate: boolean; + onSkip?: () => void; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +export default function SignInGate({ softGate, onSkip }: SignInGateProps): JSX.Element { + const tokens = useClaudeTokens(); + const proxyUrl = useAppSelector( + (s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL, + ); + const installId = useAppSelector((s) => s.settings.data.installation_id ?? ''); + + const [emailMode, setEmailMode] = useState(false); + const [email, setEmail] = useState(''); + const [emailErr, setEmailErr] = useState(null); + const [emailSent, setEmailSent] = useState(false); + const [sending, setSending] = useState(false); + const [resendCooldown, setResendCooldown] = useState(0); + + const onGoogle = async () => { + report('signin', 'google_clicked'); + const startUrl = + proxyUrl.replace(/\/$/, '') + + '/api/auth/google/start?install_id=' + + encodeURIComponent(installId); + const api = (window as any).openswarm; + if (api?.openExternal) { + api.openExternal(startUrl); + } else { + window.open(startUrl, '_blank'); + } + }; + + const onSendMagicLink = async () => { + const trimmed = email.trim(); + if (!EMAIL_REGEX.test(trimmed)) { + setEmailErr('That doesn’t look like an email address.'); + return; + } + setEmailErr(null); + setSending(true); + report('signin', 'magic_link_requested'); + try { + // Local backend proxies to cloud /api/auth/email/start. The local + // proxy doesn't exist yet — we POST directly to the cloud here. If + // your install has openswarm_proxy_url overridden (staging), it'll + // hit the right host. + const url = proxyUrl.replace(/\/$/, '') + '/api/auth/email/start'; + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: trimmed, install_id: installId }), + }); + // Cloud always returns 200 even if rate-limited (no enumeration + // leak). We mirror that here — show success regardless. If a real + // delivery failure is happening it's in the cloud's logs, not user- + // visible. + if (r.ok || r.status === 200) { + setEmailSent(true); + setResendCooldown(30); + const t = setInterval(() => { + setResendCooldown((n) => { + if (n <= 1) { + clearInterval(t); + return 0; + } + return n - 1; + }); + }, 1000); + } else { + setEmailErr('Could not send the link. Try again in a moment.'); + report('signin', 'magic_link_failed', { status: r.status }); + } + } catch (e) { + setEmailErr('Network error. Check your connection and try again.'); + report('signin', 'magic_link_failed', { error: String(e).slice(0, 120) }); + } finally { + setSending(false); + } + }; + + return ( + + + {!emailSent ? ( + <> + + Sign in to OpenSwarm + + + Sign in lets us sync your settings, back up your data, and stay in touch about updates. + + + {!emailMode ? ( + <> + + + or + + + + ) : ( + <> + { + setEmail(e.target.value); + setEmailErr(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !sending) onSendMagicLink(); + }} + error={Boolean(emailErr)} + helperText={emailErr ?? ' '} + autoFocus + disabled={sending} + sx={{ mb: 1 }} + /> + + + { + setEmailMode(false); + setEmail(''); + setEmailErr(null); + }} + sx={{ fontSize: 13, color: tokens.text.muted, textDecoration: 'none' }} + > + Back + + + + )} + + ) : ( + <> + + + Check your inbox + + + We sent a sign-in link to {email}. Click it within 15 minutes to finish signing in. The link will open OpenSwarm automatically. + + + + )} + + {softGate && onSkip && !emailSent && ( + + { + report('signin', 'gate_skipped'); + onSkip(); + }} + sx={{ fontSize: 12, color: tokens.text.muted, textDecoration: 'none' }} + > + Skip for now — I'll sign in later + + + )} + + + ); +} diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 0447cf71..09fe4ce0 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -16,6 +16,7 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import CheckIcon from '@mui/icons-material/Check'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { openSettingsModal } from '@/shared/state/settingsSlice'; import { API_BASE, getAuthToken } from '@/shared/config'; import { sendMessage as sendMessageThunk, @@ -70,13 +71,31 @@ const thinkingShimmerKeyframes = ` } `; -const ThinkingBubble: React.FC<{ label?: string | null }> = ({ label }) => { +// Single-word labels picked deterministically per session-turn so the pill +// has variety without flickering between renders. Mirrors MessageBubble's list. +const STREAMING_LABELS: ReadonlyArray = [ + 'Thinking', 'Pondering', 'Cooking', 'Marinating', 'Deliberating', + 'Reasoning', 'Reflecting', 'Untangling', 'Stewing', 'Locking-in', + 'Considering', 'Processing', 'Vibing', 'Calculating', 'Chefing', + 'Geeking', 'Brewing', +]; + +function streamingLabelFor(seedKey: string | undefined): string { + if (!seedKey) return STREAMING_LABELS[0]; + let h = 0; + for (let i = 0; i < seedKey.length; i++) { + h = ((h << 5) - h + seedKey.charCodeAt(i)) | 0; + } + return STREAMING_LABELS[Math.abs(h) % STREAMING_LABELS.length]; +} + +const ThinkingBubble: React.FC<{ label?: string | null; seedKey?: string }> = ({ label, seedKey }) => { const c = useClaudeTokens(); const shimmerBase = c.text.tertiary; const shimmerHighlight = c.text.primary; - // Aux-LLM-generated turn label takes priority; falls back to the - // generic "Thinking…" verb if no label landed yet for this turn. - const display = label ? `${label}…` : 'Thinking…'; + // Aux-LLM label wins; otherwise pick a quirky verb keyed off seedKey + // so different sessions / turns show different verbs without flicker. + const display = label ? `${label}…` : `${streamingLabelFor(seedKey)}…`; return ( @@ -175,6 +194,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const [showScrollButton, setShowScrollButton] = useState(false); const [showResumeBubble, setShowResumeBubble] = useState(false); const [awaitingResponse, setAwaitingResponse] = useState(false); + const [activatingMcp, setActivatingMcp] = useState(null); + const [activateError, setActivateError] = useState(null); const [mode, setMode] = useState('agent'); const [model, setModel] = useState('sonnet'); @@ -981,12 +1002,16 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose { + if (activatingMcp) return; + setActivateError(null); + setActivatingMcp(s.id); try { const headers: Record = { 'Content-Type': 'application/json' }; const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); if (tok) headers['Authorization'] = `Bearer ${tok}`; - await fetch(`${API_BASE}/api/mcp-meta/activate`, { + const r = await fetch(`${API_BASE}/mcp-meta/activate`, { method: 'POST', headers, body: JSON.stringify({ @@ -995,25 +1020,38 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose parent_session_id: session.id, }), }); - } catch { /* ignore */ } + if (!r.ok) { + setActivateError(`Activation failed (${r.status})`); + } + } catch (e: any) { + setActivateError(e?.message || 'Activation failed'); + } finally { + setActivatingMcp(null); + } }} sx={{ - cursor: 'pointer', + cursor: activatingMcp === s.id ? 'wait' : 'pointer', border: `1px solid ${c.border.medium}`, borderRadius: 1, px: 1.25, py: 0.5, bgcolor: 'transparent', color: c.text.primary, - '&:hover': { bgcolor: c.bg.elevated }, + opacity: activatingMcp === s.id ? 0.5 : 1, + '&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated }, flexShrink: 0, }} > - Activate + {activatingMcp === s.id ? 'Activating…' : 'Activate'} ))} + {activateError && ( + + {activateError} + + )} )} {session.context_overflow && (() => { @@ -1022,8 +1060,12 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const title = isAuth ? 'Sign-in required' : 'Context full'; const primaryLabel = isAuth ? 'Open Settings' : 'Start a fresh chat'; const onPrimary = () => { - if (isAuth) window.location.hash = '#/settings'; - else window.location.hash = '#/'; + if (isAuth) { + dispatch(openSettingsModal('models')); + } else { + const did = session?.dashboard_id; + window.location.hash = did ? `#/dashboard/${did}` : '#/'; + } }; return ( = ({ sessionId: sessionIdProp, onClose ) )} {(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && ( - + )} {showResumeBubble && session.status === 'stopped' && ( diff --git a/frontend/src/app/pages/AgentChat/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/MessageBubble.tsx index bd9d64fe..50303124 100644 --- a/frontend/src/app/pages/AgentChat/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/MessageBubble.tsx @@ -19,6 +19,8 @@ import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { AgentMessage } from '@/shared/state/agentsSlice'; +import { openSettingsModal } from '@/shared/state/settingsSlice'; +import { useAppDispatch } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { SKILL_COLOR } from '@/app/components/richEditorUtils'; import ViewBubble from './ViewBubble'; @@ -465,11 +467,46 @@ const MessageImageThumbnails: React.FC<{ ); }; -// thinking pill. shows "Thought for Ns" if we caught it live, else just "Thoughts". +// 17 single-word labels picked deterministically per turn from the message id. +// Mostly normal-warm with a kitchen cluster + a few gen-z picks for variety. +const THINKING_LABELS: ReadonlyArray<{ live: string; past: string }> = [ + { live: 'Thinking', past: 'Thought' }, + { live: 'Pondering', past: 'Pondered' }, + { live: 'Cooking', past: 'Cooked' }, + { live: 'Marinating', past: 'Marinated' }, + { live: 'Deliberating', past: 'Deliberated' }, + { live: 'Reasoning', past: 'Reasoned' }, + { live: 'Reflecting', past: 'Reflected' }, + { live: 'Untangling', past: 'Untangled' }, + { live: 'Stewing', past: 'Stewed' }, + { live: 'Locking-in', past: 'Locked-in' }, + { live: 'Considering', past: 'Considered' }, + { live: 'Processing', past: 'Processed' }, + { live: 'Vibing', past: 'Vibed' }, + { live: 'Calculating', past: 'Calculated' }, + { live: 'Chefing', past: 'Chefed' }, + { live: 'Geeking', past: 'Geeked' }, + { live: 'Brewing', past: 'Brewed' }, +]; + +// Stable hash of the message id → label index. Same message always shows the +// same label — so reload / scroll-back / resume don't shuffle history. Cheap: +// 6 ops per char, but the id is 32 hex chars so ~200 ops total per bubble, +// completely negligible vs. a single React re-render. +function labelIndexFromId(id: string | undefined): number { + if (!id) return 0; + let h = 0; + for (let i = 0; i < id.length; i++) { + h = ((h << 5) - h + id.charCodeAt(i)) | 0; + } + return Math.abs(h) % THINKING_LABELS.length; +} + const ThinkingBubble: React.FC<{ content: string; isStreaming?: boolean; timestamp?: string; + messageId?: string; // server-stamped totals for the turn. survives unmount. persistedElapsedMs?: number; persistedTokens?: number; @@ -477,9 +514,14 @@ const ThinkingBubble: React.FC<{ persistedToolCount?: number; // aux-LLM label like "Auditing the pull request". null = use the heuristic. dynamicLabel?: string | null; -}> = ({ content, isStreaming, persistedElapsedMs, persistedTokens, persistedInputTokens, persistedToolCount, dynamicLabel }) => { +}> = ({ content, isStreaming, messageId, persistedElapsedMs, persistedTokens, persistedInputTokens, persistedToolCount, dynamicLabel }) => { const c = useClaudeTokens(); + const turnLabel = useMemo( + () => THINKING_LABELS[labelIndexFromId(messageId)], + [messageId], + ); + // live timer is just the fallback. server-stamped values win. const [startedStreamingAt, setStartedStreamingAt] = useState( isStreaming ? Date.now() : null @@ -521,7 +563,7 @@ const ThinkingBubble: React.FC<{ const activeLabel = dynamicLabel ? (liveTokenEstimate > 0 ? `${dynamicLabel}… · ~${liveTokenEstimate} tokens` : `${dynamicLabel}…`) - : (liveTokenEstimate > 0 ? `Thinking… (~${liveTokenEstimate} tokens)` : 'Thinking…'); + : (liveTokenEstimate > 0 ? `${turnLabel.live}… (~${liveTokenEstimate} tokens)` : `${turnLabel.live}…`); const fmtTokens = (n: number) => { if (n >= 1000) { @@ -565,8 +607,8 @@ const ThinkingBubble: React.FC<{ segments.push( {finalSeconds != null - ? `Thought for ${fmtThoughtDuration(finalSeconds)}` - : 'Thoughts'} + ? `${turnLabel.past} for ${fmtThoughtDuration(finalSeconds)}` + : turnLabel.past} ); if (tokenBreakdown) { @@ -769,6 +811,7 @@ interface Props { const MessageBubble: React.FC = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming, dynamicTurnLabel }) => { const c = useClaudeTokens(); + const dispatch = useAppDispatch(); const [editText, setEditText] = useState(''); const [pickerOpen, setPickerOpen] = useState(false); const { role, content } = message; @@ -789,6 +832,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o content={typeof content === 'string' ? content : JSON.stringify(content)} isStreaming={isStreaming} timestamp={message.timestamp} + messageId={message.id} persistedElapsedMs={(message as any).elapsed_ms} persistedTokens={(message as any).tokens} persistedInputTokens={(message as any).input_tokens} @@ -1069,10 +1113,9 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o onClick={() => { const api = (window as any).openswarm; if (openswarmError.ctaAction === 'upgrade') { - // tier picker, not direct checkout. setPickerOpen(true); } else if (openswarmError.ctaAction === 'settings') { - window.dispatchEvent(new CustomEvent('openswarm:open-settings', { detail: { tab: 'models' } })); + dispatch(openSettingsModal('models')); } else if (openswarmError.ctaAction === 'waitlist') { const url = 'https://discord.com/channels/1486442924391796896/1486442927554170892'; if (api?.openExternal) api.openExternal(url); diff --git a/frontend/src/shared/hooks/useDeepLink.ts b/frontend/src/shared/hooks/useDeepLink.ts index ada2238b..b4f5c3f7 100644 --- a/frontend/src/shared/hooks/useDeepLink.ts +++ b/frontend/src/shared/hooks/useDeepLink.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useAppDispatch } from '@/shared/hooks'; -import { activateSubscription } from '@/shared/state/settingsSlice'; +import { activateSubscription, activateSignin } from '@/shared/state/settingsSlice'; import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchTools } from '@/shared/state/toolsSlice'; import { API_BASE } from '@/shared/config'; @@ -23,7 +23,13 @@ export function useDeepLink(): void { const unsubscribe = api.onAuthUrl?.((rawUrl: string) => { try { - // openswarm://auth?token=... (host = "auth", search carries fields) + // openswarm://auth?token=... (host = "auth", search carries fields). + // Two flavors land here, distinguished by the `signin` flag: + // - signin=true → free-tier sign-in (Google OAuth / magic link) + // - (default) → Stripe checkout subscription activation + // Note: the bearer-handoff page in lib/authMint.ts (cloud) POSTs + // directly to localhost so this deep-link path is currently a + // backstop for older flows. Both branches here remain wired up. const url = new URL(rawUrl); if (url.host !== 'auth' && url.pathname !== '//auth' && url.pathname !== '/auth') { console.warn('[deep-link] Unknown openswarm:// host:', url.host); @@ -34,9 +40,32 @@ export function useDeepLink(): void { console.warn('[deep-link] Missing token in', rawUrl); return; } + const isSignin = url.searchParams.get('signin') === 'true'; + const signinMethodRaw = url.searchParams.get('signin_method'); + const email = url.searchParams.get('email'); const plan = url.searchParams.get('plan'); const expires = url.searchParams.get('expires'); + if (isSignin) { + const signinMethod: 'google' | 'magic_link' = + signinMethodRaw === 'magic_link' ? 'magic_link' : 'google'; + report('signin', 'deep_link_received', { method: signinMethod }); + + dispatch(activateSignin({ token, signin_method: signinMethod, email })) + .unwrap() + .then((res) => { + report('signin', 'activated', { method: res.signin_method, plan: res.plan }); + dispatch(fetchModels()); + }) + .catch((err) => { + console.error('[deep-link] Sign-in activation failed:', err); + report('signin', 'activation_failed', { + message: String(err).slice(0, 120), + }); + }); + return; + } + report('subscription', 'deep_link_received', { plan: plan ?? 'unknown', }); diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index d2d6d19f..10ad6978 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -62,6 +62,16 @@ export interface AppSettings { openswarm_subscription_plan?: string | null; openswarm_subscription_expires?: string | null; openswarm_usage_cached?: SubscriptionUsage | null; + // Identity (v1.0.30+). Populated after a successful Google OAuth or + // magic-link sign-in via /api/auth/signin-activate. Stripe checkout also + // populates these because the cloud's bearer-mint always returns user info. + user_id?: string | null; + user_email?: string | null; + signin_method?: 'google' | 'magic_link' | 'stripe' | null; + // Anonymous device identifier. Generated locally on first run, persists + // across launches. Used to bind cloud OAuth flows to this install and to + // stitch anonymous → authenticated PostHog Persons after sign-in. + installation_id?: string | null; } export interface ActivateSubscriptionPayload { @@ -70,6 +80,12 @@ export interface ActivateSubscriptionPayload { expires?: string | null; } +export interface ActivateSigninPayload { + token: string; + signin_method: 'google' | 'magic_link'; + email?: string | null; +} + export interface BrowseResult { current: string; parent: string | null; @@ -164,6 +180,44 @@ export const activateSubscription = createAsyncThunk( } ); +// POST /api/auth/signin-activate — called after the desktop catches the +// bearer from a Google OAuth / magic-link sign-in flow. Validates the +// bearer with the cloud (checks signature + user_id + email) and persists +// it locally as a free-tier identity. The same backend route also handles +// "user signed in AND has an active subscription" — plan/expires are set +// when the cloud returns them. +export const activateSignin = createAsyncThunk( + 'settings/activateSignin', + async (payload: ActivateSigninPayload, { dispatch }) => { + const res = await fetch(`${API_BASE}/auth/signin-activate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error((await res.text()) || 'Sign-in failed'); + await dispatch(fetchSettings()); + return (await res.json()) as { + ok: boolean; + user_id: string; + email: string; + plan: string; + signin_method: 'google' | 'magic_link'; + }; + }, +); + +// POST /api/auth/signout — revokes the cloud-side bearer and clears local +// identity fields. Brings the user back to the sign-in gate. +export const signOut = createAsyncThunk( + 'settings/signOut', + async (_: void, { dispatch }) => { + const res = await fetch(`${API_BASE}/auth/signout`, { method: 'POST' }); + if (!res.ok) throw new Error('Sign-out failed'); + await dispatch(fetchSettings()); + return true; + }, +); + // POST /api/subscription/disconnect — clears bearer + reverts to own_key. // Doesn't cancel the Stripe subscription (that's the Portal). export const disconnectSubscription = createAsyncThunk(