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;