mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
[eric] account flow + settings tweaks
This commit is contained in:
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<IdentityStatus | null>(null);
|
||||
const [skipTs, setSkipTs] = useState<number>(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}
|
||||
<Suspense fallback={null}>
|
||||
<SignInGate
|
||||
softGate={!status.hard_gate}
|
||||
onSkip={() => {
|
||||
// 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);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 = () => {
|
||||
<RouteTrackerMount />
|
||||
<ShortcutsProvider>
|
||||
<SettingsLoader>
|
||||
<SignInGateLoader>
|
||||
<DefaultModelGuard>
|
||||
<UpdateListener>
|
||||
<DeepLinkListener>
|
||||
@@ -400,6 +490,7 @@ const ThemedApp: React.FC = () => {
|
||||
</DeepLinkListener>
|
||||
</UpdateListener>
|
||||
</DefaultModelGuard>
|
||||
</SignInGateLoader>
|
||||
</SettingsLoader>
|
||||
</ShortcutsProvider>
|
||||
</HashRouter>
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Modal
|
||||
open
|
||||
disableEscapeKeyDown={!softGate}
|
||||
hideBackdrop={false}
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
slotProps={{ backdrop: { sx: { backgroundColor: 'rgba(0,0,0,0.55)' } } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 440,
|
||||
mx: 2,
|
||||
backgroundColor: tokens.bg.surface,
|
||||
color: tokens.text.primary,
|
||||
border: `1px solid ${tokens.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{!emailSent ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontFamily: '"Charter", Georgia, serif', fontWeight: 500, mb: 1 }}
|
||||
>
|
||||
Sign in to OpenSwarm
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: tokens.text.muted, mb: 3, lineHeight: 1.5 }}
|
||||
>
|
||||
Sign in lets us sync your settings, back up your data, and stay in touch about updates.
|
||||
</Typography>
|
||||
|
||||
{!emailMode ? (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<GoogleIcon />}
|
||||
onClick={onGoogle}
|
||||
sx={{
|
||||
py: 1.4,
|
||||
backgroundColor: tokens.text.primary,
|
||||
color: tokens.text.inverse,
|
||||
textTransform: 'none',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
'&:hover': { backgroundColor: tokens.text.primary, opacity: 0.9 },
|
||||
}}
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 2.5, color: tokens.text.muted, fontSize: 12 }}>or</Divider>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<EmailOutlinedIcon />}
|
||||
onClick={() => {
|
||||
setEmailMode(true);
|
||||
report('signin', 'email_mode_opened');
|
||||
}}
|
||||
sx={{
|
||||
py: 1.4,
|
||||
borderColor: tokens.border.subtle,
|
||||
color: tokens.text.primary,
|
||||
textTransform: 'none',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
'&:hover': { borderColor: tokens.text.primary, backgroundColor: 'transparent' },
|
||||
}}
|
||||
>
|
||||
Continue with email
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
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 }}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={onSendMagicLink}
|
||||
disabled={sending || !email.trim()}
|
||||
sx={{
|
||||
py: 1.4,
|
||||
backgroundColor: tokens.text.primary,
|
||||
color: tokens.text.inverse,
|
||||
textTransform: 'none',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
'&:hover': { backgroundColor: tokens.text.primary, opacity: 0.9 },
|
||||
}}
|
||||
>
|
||||
{sending ? <CircularProgress size={20} sx={{ color: tokens.bg.surface }} /> : 'Send sign-in link'}
|
||||
</Button>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Link
|
||||
component="button"
|
||||
onClick={() => {
|
||||
setEmailMode(false);
|
||||
setEmail('');
|
||||
setEmailErr(null);
|
||||
}}
|
||||
sx={{ fontSize: 13, color: tokens.text.muted, textDecoration: 'none' }}
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon sx={{ fontSize: 40, color: '#22c55e', mb: 1 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontFamily: '"Charter", Georgia, serif', fontWeight: 500, mb: 1 }}
|
||||
>
|
||||
Check your inbox
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: tokens.text.muted, mb: 3, lineHeight: 1.5 }}
|
||||
>
|
||||
We sent a sign-in link to <strong style={{ color: tokens.text.primary }}>{email}</strong>. Click it within 15 minutes to finish signing in. The link will open OpenSwarm automatically.
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="text"
|
||||
disabled={resendCooldown > 0 || sending}
|
||||
onClick={() => {
|
||||
setEmailSent(false);
|
||||
onSendMagicLink();
|
||||
}}
|
||||
sx={{ color: tokens.text.muted, textTransform: 'none', fontSize: 13 }}
|
||||
>
|
||||
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't get it? Resend"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{softGate && onSkip && !emailSent && (
|
||||
<Box sx={{ mt: 3, pt: 2, borderTop: `1px solid ${tokens.border.subtle}` }}>
|
||||
<Link
|
||||
component="button"
|
||||
onClick={() => {
|
||||
report('signin', 'gate_skipped');
|
||||
onSkip();
|
||||
}}
|
||||
sx={{ fontSize: 12, color: tokens.text.muted, textDecoration: 'none' }}
|
||||
>
|
||||
Skip for now — I'll sign in later
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string> = [
|
||||
'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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
<style>{thinkingShimmerKeyframes}</style>
|
||||
@@ -175,6 +194,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [showResumeBubble, setShowResumeBubble] = useState(false);
|
||||
const [awaitingResponse, setAwaitingResponse] = useState(false);
|
||||
const [activatingMcp, setActivatingMcp] = useState<string | null>(null);
|
||||
const [activateError, setActivateError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState('agent');
|
||||
const [model, setModel] = useState('sonnet');
|
||||
|
||||
@@ -981,12 +1002,16 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
<Typography
|
||||
component="button"
|
||||
variant="caption"
|
||||
disabled={activatingMcp === s.id}
|
||||
onClick={async () => {
|
||||
if (activatingMcp) return;
|
||||
setActivateError(null);
|
||||
setActivatingMcp(s.id);
|
||||
try {
|
||||
const headers: Record<string, string> = { '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<AgentChatProps> = ({ 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'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{activateError && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: c.status.error }}>
|
||||
{activateError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{session.context_overflow && (() => {
|
||||
@@ -1022,8 +1060,12 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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 (
|
||||
<Box sx={{
|
||||
@@ -1170,7 +1212,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
)
|
||||
)}
|
||||
{(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && (
|
||||
<ThinkingBubble label={session.turn_label?.label} />
|
||||
<ThinkingBubble
|
||||
label={session.turn_label?.label}
|
||||
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
|
||||
/>
|
||||
)}
|
||||
{showResumeBubble && session.status === 'stopped' && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
|
||||
@@ -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<number | null>(
|
||||
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(
|
||||
<span key="duration">
|
||||
{finalSeconds != null
|
||||
? `Thought for ${fmtThoughtDuration(finalSeconds)}`
|
||||
: 'Thoughts'}
|
||||
? `${turnLabel.past} for ${fmtThoughtDuration(finalSeconds)}`
|
||||
: turnLabel.past}
|
||||
</span>
|
||||
);
|
||||
if (tokenBreakdown) {
|
||||
@@ -769,6 +811,7 @@ interface Props {
|
||||
|
||||
const MessageBubble: React.FC<Props> = 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<Props> = 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<Props> = 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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user