mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 10:47:44 +02:00
[eric] auth: drop the mandatory first-launch sign-in gate, app works signed-out
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user