[eric] account flow + settings tweaks

This commit is contained in:
ciregenz
2026-05-07 09:57:49 -07:00
parent acd179d20e
commit 7a6a49655c
12 changed files with 1127 additions and 23 deletions
View File
+250
View File
@@ -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}
+11 -1
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
+270
View File
@@ -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
+91
View File
@@ -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>
+303
View File
@@ -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 doesnt 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>
);
}
+57 -12
View File
@@ -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);
+31 -2
View File
@@ -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(