[eric] account UI + proxy URL fix

This commit is contained in:
ciregenz
2026-05-07 11:17:01 -07:00
parent 7a6a49655c
commit fb78f013f3
6 changed files with 144 additions and 6 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+134 -1
View File
@@ -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 (
<Box sx={{ p: 2, mb: 2, borderRadius: `${c.radius.lg}px`, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.primary, mb: 0.5 }}>Not signed in</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.25 }}>
Sign in to sync settings across devices and back up your data.
</Typography>
<Button
variant="outlined"
size="small"
onClick={onSignIn}
sx={{
textTransform: 'none',
fontSize: '0.8rem',
borderColor: c.border.medium,
color: c.text.primary,
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary, bgcolor: 'transparent' },
}}
>
Sign in to OpenSwarm
</Button>
</Box>
);
}
return (
<Box sx={{ p: 2, mb: 2, borderRadius: `${c.radius.lg}px`, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontSize: '0.9rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{userEmail || 'Signed in'}
</Typography>
{methodLabel && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mt: 0.25 }}>{methodLabel}</Typography>
)}
{!userId && hasBearer && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mt: 0.25 }}>
Subscription connected. Sign in to also link this device to your account.
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{!userId && hasBearer && (
<Button
variant="outlined"
size="small"
onClick={onSignIn}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
borderColor: c.border.medium,
color: c.text.primary,
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary, bgcolor: 'transparent' },
}}
>
Link account
</Button>
)}
<Button
variant="text"
size="small"
onClick={onSignOut}
disabled={signingOut}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
color: c.text.muted,
'&:hover': { color: c.status.error, bgcolor: 'transparent' },
}}
>
{signingOut ? <CircularProgress size={14} sx={{ color: c.text.muted }} /> : 'Sign out'}
</Button>
</Box>
</Box>
</Box>
);
};
const OpenSwarmProCard: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -1442,6 +1571,10 @@ const Settings: React.FC = () => {
{activeTab === 'general' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
{/* ── Account ── */}
<Typography sx={sectionSx}>Account</Typography>
<AccountCard />
{/* ── Agent Defaults ── */}
<Typography sx={sectionSx}>Agent Defaults</Typography>
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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;