diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index 0242303d..d3d3da4f 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -95,7 +95,7 @@ def _get_user_id() -> Optional[str]: # 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 + # installs don't lose their Person history during the v1.0.29 # rollout). After every install signs in, this fallback drops out. return ( getattr(s, "user_id", None) diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 13a64074..09c9ef44 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -78,7 +78,7 @@ 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 + # Identity (v1.0.29+). 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 diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 96f98e67..76afa7f9 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -209,7 +209,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = // 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 +// to a one-time /api/me hit on the v1.0.29 first-launch). For the simple // case we treat openswarm_bearer_token alone as "signed in" so paying // customers never see the gate. interface IdentityStatus { diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 8311c06b..5949be4d 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -41,7 +41,8 @@ import LinearProgress from '@mui/material/LinearProgress'; import Collapse from '@mui/material/Collapse'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice'; +import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, signOut, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice'; +import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { fetchModels } from '@/shared/state/modelsSlice'; import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice'; import { fetchModes } from '@/shared/state/modesSlice'; @@ -193,6 +194,134 @@ const clampPickerPlan = (plan: string | null | undefined): OpenSwarmPlan => { return 'pro_plus'; }; +// ── Account card ── +// +// Shown at the top of the General tab. Three states: +// - Signed in (settings.user_id present): show email + signin method +// + Sign out button. +// - Paid user with no signed-in identity yet (bearer set, user_id null): +// same email shown, with a one-click "Link your account" CTA that +// fires a Google sign-in so analytics finally has a Person row. +// - Not signed in: small "Sign in to OpenSwarm" CTA that opens the gate. +const AccountCard: React.FC = () => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const settings = useAppSelector((s) => s.settings.data); + const [signingOut, setSigningOut] = useState(false); + + const userEmail = settings.user_email ?? null; + const userId = settings.user_id ?? null; + const signinMethod = settings.signin_method ?? null; + const hasBearer = Boolean(settings.openswarm_bearer_token); + const installId = settings.installation_id ?? ''; + const proxyUrl = settings.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL; + + const methodLabel = (() => { + switch (signinMethod) { + case 'google': return 'Signed in with Google'; + case 'magic_link': return 'Signed in via email link'; + case 'stripe': return 'Signed in via Stripe checkout'; + default: return null; + } + })(); + + const onSignOut = async () => { + setSigningOut(true); + try { + await dispatch(signOut()).unwrap(); + } catch (e) { + console.error('Sign out failed:', e); + } finally { + setSigningOut(false); + } + }; + + const onSignIn = () => { + 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'); + }; + + // Not signed in at all (no bearer, no user_id) — small inline CTA. + if (!userId && !hasBearer) { + return ( + + Not signed in + + Sign in to sync settings across devices and back up your data. + + + + ); + } + + return ( + + + + + {userEmail || 'Signed in'} + + {methodLabel && ( + {methodLabel} + )} + {!userId && hasBearer && ( + + Subscription connected. Sign in to also link this device to your account. + + )} + + + {!userId && hasBearer && ( + + )} + + + + + ); +}; + const OpenSwarmProCard: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); @@ -1442,6 +1571,10 @@ const Settings: React.FC = () => { {activeTab === 'general' ? ( + {/* ── Account ── */} + Account + + {/* ── Agent Defaults ── */} Agent Defaults diff --git a/frontend/src/shared/config.ts b/frontend/src/shared/config.ts index 9cb8378d..ea95b677 100644 --- a/frontend/src/shared/config.ts +++ b/frontend/src/shared/config.ts @@ -3,7 +3,12 @@ const host = window.location.hostname || 'localhost'; export const API_BASE = `http://${host}:${port}/api`; export const WS_BASE = `ws://${host}:${port}`; -export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai'; +// Must match openswarm-cloud's PUBLIC_BASE_URL (fly.toml) and the redirect +// URI registered on the Google OAuth client. The historical `.ai` value +// resolved to NXDOMAIN — fine while no frontend caller used it directly, +// but the v1.0.29 sign-in gate is the first frontend caller that +// constructs URLs from this constant, so the typo had to go. +export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.com'; // Per-install auth token. Fetched from Electron's main process via the // preload contextBridge. We cache it after first resolution so every diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 10ad6978..a374f048 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -62,7 +62,7 @@ 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 + // Identity (v1.0.29+). 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;