[eric] sign-in flow polish: localhost activate + auto-refresh + clean signout

This commit is contained in:
ciregenz
2026-05-07 12:44:45 -07:00
parent fb78f013f3
commit 577501c19f
3 changed files with 62 additions and 1 deletions
+27 -1
View File
@@ -168,7 +168,14 @@ async def signin_activate(body: SigninActivateRequest):
@auth.router.post("/signout")
async def signout():
"""Revoke the cloud-side bearer + clear local identity state."""
"""Revoke the cloud-side bearer + clear local identity state.
Also stops every in-flight agent session so any 9Router subprocess
that captured the now-revoked bearer at spawn time can't keep using
it. Without this, a signed-out user's old chat tabs would keep
making /v1/messages calls with a token the cloud has revoked,
surfacing as 401s in the agent UI ("Invalid bearer token").
"""
settings_obj = load_settings()
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
proxy = _proxy_url()
@@ -184,6 +191,25 @@ async def signout():
# the cloud token is invalidated lazily on next use anyway.
logger.warning("cloud signout failed (clearing local anyway): %s", e)
# Stop every running agent session BEFORE clearing local state. The
# next message in any tab will then re-launch the session, which
# re-reads settings (now empty) and re-spawns 9Router subprocesses
# with whatever the user re-signs-in with — or with own_key mode
# if they don't sign back in. Best-effort: failures here shouldn't
# block the sign-out itself.
try:
from backend.apps.agents.agent_manager import agent_manager
running = list(agent_manager.tasks.keys())
for session_id in running:
try:
await agent_manager.stop_agent(session_id)
except Exception as e:
logger.warning("signout: stop_agent(%s) failed: %s", session_id, e)
if running:
logger.info("signout: stopped %d in-flight agent session(s)", len(running))
except Exception as e:
logger.warning("signout: agent shutdown skipped: %s", e)
settings_obj.user_id = None
settings_obj.user_email = None
settings_obj.signin_method = None
+8
View File
@@ -99,6 +99,14 @@ _AUTH_EXEMPT_EXACT = {
# has no way to inject our bearer token; the install_id check inside
# the handler is what binds the request to this user.
"/api/tools/oauth/cloud-claim",
# Bearer-handoff endpoints called by api.openswarm.com's success page
# AFTER Stripe checkout / Google sign-in / magic-link sign-in. The
# request POSTs the just-minted cloud bearer; the handler then re-
# validates it against the cloud (/api/me or /api/auth/signin-activate).
# The browser has no way to attach our per-install token here — the
# cloud-validated bearer in the body is the actual auth mechanism.
"/api/subscription/activate",
"/api/auth/signin-activate",
"/api/version",
}
+27
View File
@@ -191,6 +191,19 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
})
.catch(() => { /* offline — next launch will reconcile */ });
}, [dispatch]);
// Refetch settings when the window regains focus. Catches every out-of-
// band settings mutation that doesn't come through a renderer-dispatched
// thunk: Stripe checkout's bearer-handoff page POSTing /api/subscription/
// activate, the new sign-in flow's bearer-handoff POSTing /api/auth/
// signin-activate, manual ~/.openswarm/settings.json edits, etc. Throttled
// by the browser's natural focus cadence (one refetch per Cmd-Tab back).
useEffect(() => {
const onFocus = () => { dispatch(fetchSettings()); };
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
}, [loaded, theme, setThemeMode]);
@@ -220,6 +233,7 @@ interface IdentityStatus {
}
const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const [status, setStatus] = useState<IdentityStatus | null>(null);
@@ -249,6 +263,19 @@ const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children })
return () => { cancelled = true; };
}, [settingsLoaded, alreadySignedIn]);
// While the gate is showing, poll settings every 2s so the moment the
// sign-in flow completes (browser POSTs /api/auth/signin-activate, local
// backend persists user_id to settings.json), we re-read settings and
// the gate auto-dismisses without the user clicking anything. Cheap —
// /api/settings is a static file read on the local backend. Stops as
// soon as the user is authed or the user has skipped.
useEffect(() => {
if (!settingsLoaded || alreadySignedIn) return;
if (status?.authed) return;
const id = setInterval(() => { dispatch(fetchSettings()); }, 2000);
return () => clearInterval(id);
}, [dispatch, settingsLoaded, alreadySignedIn, status?.authed]);
// Read the persisted "remind me later" timestamp from localStorage so
// soft-gate skip survives reloads but doesn't bloat AppSettings.
useEffect(() => {