[eric] auth: drop the mandatory first-launch sign-in gate, app works signed-out

This commit is contained in:
ciregenz
2026-06-04 04:17:31 -07:00
parent 9fd8936cd6
commit f59ab9d4dd
4 changed files with 5 additions and 177 deletions
+2 -60
View File
@@ -12,12 +12,7 @@ POST /api/auth/signin-activate {token, signin_method, email?}
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.
identity fields.
"""
from __future__ import annotations
@@ -90,7 +85,7 @@ async def signin_activate(body: SigninActivateRequest):
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.
signin_method to settings so the renderer flips to signed-in.
"""
if not body.token or len(body.token) < 16:
raise HTTPException(status_code=400, detail="Invalid token")
@@ -240,56 +235,3 @@ async def signout():
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}
-84
View File
@@ -135,90 +135,6 @@ def test_signin_activate_short_token_rejected_locally(client, reset_settings):
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
# ---------------------------------------------------------------------------
+2 -30
View File
@@ -33,7 +33,6 @@ const Analytics = React.lazy(() => import('./pages/Analytics/Analytics'));
const OnboardingRoot = React.lazy(() =>
import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })),
);
const SignInGate = React.lazy(() => import('./components/overlays/SignInGate'));
if (typeof window !== 'undefined') {
// Diagnostic global error capture. The packaged bundle has no source maps, so without these handlers the only thing that reaches main-process stderr is "Uncaught TypeError: ... (bundle.js:2)" with zero stack context. Forward error.stack and Redux action.type when available so we can pinpoint the offender across the chat-spawn / workflow rendering paths even in minified prod.
@@ -250,36 +249,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
if (!loaded) return;
(window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates);
}, [loaded, allowExperimentalUpdates]);
// Hold paint until settings land so the user's theme renders first; Electron's ready-to-show relies on this.
if (!loaded) return null;
return <>{children}</>;
};
/** Mandatory sign-in gate; first thing shown when settings lack a user_id or bearer. */
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 alreadySignedIn = Boolean(settings.user_id || settings.openswarm_bearer_token);
useEffect(() => {
if (!settingsLoaded || alreadySignedIn) return;
const id = setInterval(() => { dispatch(fetchSettings()); }, 2000);
return () => clearInterval(id);
}, [dispatch, settingsLoaded, alreadySignedIn]);
if (!settingsLoaded) return null;
if (alreadySignedIn) return <>{children}</>;
return (
<>
{children}
<Suspense fallback={null}>
<SignInGate />
</Suspense>
</>
);
};
const DEFAULT_MODEL_PRIORITY: string[] = [
'Anthropic',
'OpenAI',
@@ -488,7 +462,6 @@ const ThemedApp: React.FC = () => {
<RouteTrackerMount />
<ShortcutsProvider>
<SettingsLoader>
<SignInGateLoader>
<DefaultModelGuard>
<UpdateListener>
<CrashRecoveryChip />
@@ -519,7 +492,6 @@ const ThemedApp: React.FC = () => {
</DeepLinkListener>
</UpdateListener>
</DefaultModelGuard>
</SignInGateLoader>
</SettingsLoader>
</ShortcutsProvider>
</HashRouter>
@@ -29,7 +29,6 @@ const OnboardingRoot: React.FC = () => {
const store = useStore<RootState>() as Store<RootState>;
const tokens = useClaudeTokens();
const progress = useAppSelector((s) => s.onboardingProgress);
const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
@@ -210,8 +209,7 @@ const OnboardingRoot: React.FC = () => {
return () => onboardingDirector.detach();
}, [store, tokens.accent.primary]);
// Wait for sign-in state so we don't render under the SignInGate's z-index.
if (!settingsLoaded || !userId) return null;
if (!settingsLoaded) return null;
if (!progress.initialized) return null;
return (