[eric] auth: every user signs in, including upgraders who skipped onboarding entirely

This commit is contained in:
ciregenz
2026-08-03 13:17:03 -07:00
parent 32e6756edd
commit eba1206e47
6 changed files with 97 additions and 13 deletions
+4
View File
@@ -29,6 +29,7 @@ import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboar
const Analytics = React.lazy(() => import('./pages/Analytics/Analytics'));
const OnboardingV3Root = React.lazy(() => import('./components/OnboardingV3/OnboardingV3Root'));
const SignInRequiredGate = React.lazy(() => import('./components/overlays/SignInRequiredGate'));
const OnboardingRoot = React.lazy(() =>
import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })),
);
@@ -547,6 +548,9 @@ const ThemedApp: React.FC = () => {
<OnboardingV3Root />
</Suspense>
</OnboardingErrorGuard>
<Suspense fallback={null}>
<SignInRequiredGate />
</Suspense>
</DeepLinkListener>
</UpdateListener>
</DefaultModelGuard>
@@ -24,7 +24,7 @@ type Stage = 'choose' | 'email_form' | 'code_form';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
export default function SignInDialog({ onClose, initialStage = 'choose' }: { onClose: () => void; initialStage?: Stage }): JSX.Element {
export default function SignInDialog({ onClose, initialStage = 'choose', mandatory = false }: { onClose: () => void; initialStage?: Stage; mandatory?: boolean }): JSX.Element {
const tokens = useClaudeTokens();
const dispatch = useAppDispatch();
const proxyUrl = useAppSelector(
@@ -156,7 +156,9 @@ export default function SignInDialog({ onClose, initialStage = 'choose' }: { onC
return (
<Modal
open
onClose={onClose}
// A mandatory sign-in has no way out on purpose, so Esc and a backdrop click must not be one.
onClose={mandatory ? undefined : onClose}
disableEscapeKeyDown={mandatory}
hideBackdrop={false}
// Must clear the onboarding curtain (z ~2147483000): at MUI's default 1300 this dialog opened
// INVISIBLY behind it, so "Continue with email" looked dead during onboarding.
@@ -178,14 +180,16 @@ export default function SignInDialog({ onClose, initialStage = 'choose' }: { onC
outline: 'none',
}}
>
<IconButton
size="small"
onClick={onClose}
aria-label="Close"
sx={{ position: 'absolute', top: 10, right: 10, color: tokens.text.tertiary }}
>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
{!mandatory && (
<IconButton
size="small"
onClick={onClose}
aria-label="Close"
sx={{ position: 'absolute', top: 10, right: 10, color: tokens.text.tertiary }}
>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
{stage === 'code_form' ? (
<>
<Typography
@@ -0,0 +1,21 @@
// Everyone gets an account, upgraders included.
//
// A fresh install signs in inside onboarding (BeatSignIn's Continue is disabled until it lands).
// A veteran never saw that: OnboardingV3Root writes `onboarding_v3: 'skipped'` the moment it finds
// v2 history, so the whole flow, sign-in included, is skipped and the user runs unsigned forever.
// That is the hole this closes, and it is why the gate lives here rather than inside onboarding.
import React from 'react';
import SignInDialog from '@/app/components/overlays/SignInDialog';
import { shouldRequireSignIn } from '@/app/components/overlays/shouldRequireSignIn';
import { useAppSelector } from '@/shared/hooks';
export default function SignInRequiredGate(): JSX.Element | null {
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
const onboardingActive = useAppSelector((s) => s.onboardingV3.flowActive);
if (!shouldRequireSignIn({ settingsLoaded, userId, onboardingActive })) return null;
return <SignInDialog mandatory onClose={() => undefined} />;
}
@@ -0,0 +1,35 @@
// Run: node --test frontend/src/app/components/overlays/shouldRequireSignIn.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldRequireSignIn } from './shouldRequireSignIn.ts';
const base = { settingsLoaded: true, userId: null as string | null, onboardingActive: false };
test('the veteran this exists for: settings loaded, no account, no onboarding', () => {
assert.equal(shouldRequireSignIn(base), true);
});
test('a signed-in user is never walled', () => {
assert.equal(shouldRequireSignIn({ ...base, userId: 'u-1' }), false);
});
test('nothing shows until settings are read, so a launch does not flash a login wall', () => {
assert.equal(shouldRequireSignIn({ ...base, settingsLoaded: false }), false);
});
test('a backend that never answers leaves the app usable rather than bricked', () => {
// settingsLoaded stays false forever in that case; the wall must stay down, not go up.
assert.equal(shouldRequireSignIn({ settingsLoaded: false, userId: null, onboardingActive: false }), false);
});
test('onboarding owns the screen, so the gate stands down while its own sign-in beat runs', () => {
assert.equal(shouldRequireSignIn({ ...base, onboardingActive: true }), false);
});
test('a fresh install that finishes onboarding signed in stays down afterwards', () => {
assert.equal(shouldRequireSignIn({ settingsLoaded: true, userId: 'u-2', onboardingActive: false }), false);
});
test('an empty-string user id is not an account', () => {
assert.equal(shouldRequireSignIn({ ...base, userId: '' }), true);
});
@@ -0,0 +1,18 @@
// Whether the mandatory sign-in wall should be up right now.
//
// Split out of the component so the branch that can lock every user out of the app is testable
// without a React harness. See SignInRequiredGate.tsx for why the wall exists at all.
export interface SignInGateState {
settingsLoaded: boolean;
userId: string | null;
onboardingActive: boolean;
}
export function shouldRequireSignIn({ settingsLoaded, userId, onboardingActive }: SignInGateState): boolean {
// Fails OPEN until settings are read: a backend that never answers must not brick a local-first
// app behind a wall the user's own saved account would have taken down.
if (!settingsLoaded) return false;
// Onboarding carries its own sign-in beat and its own curtain; stacking a second one hides both.
if (onboardingActive) return false;
return !userId;
}
+5 -3
View File
@@ -20,7 +20,7 @@
"eslint-knip": "Node tooling deferred to a later pass.",
"classes": "Placeholder check, not wired up. endpoints: orphaned-endpoint triage deferred.",
"max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them. The two manager/prompt/* entries are from the agent_manager decomposition: prompt_context.py aggregates the system-prompt context builders and attachments.py is one cohesive 230-line attachment resolver; both are single-responsibility and a few lines over, not splittable without an artificial seam.",
"max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte. AgentChat/parsing joined when the narration/deliverable classifier landed: it is the same flat one-module-per-parser collection as the rest of that subtree. 2026-08-03 browser merge: agents/browser is the flat one-module-per-concern browser tier (40 modules) that arrived whole from eric/browser-merged; .github/workflows crossed 7 when the packaged-smoke and intel-verify workflows landed; frontend/ is a package root, not a code folder.",
"max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte. AgentChat/parsing joined when the narration/deliverable classifier landed: it is the same flat one-module-per-parser collection as the rest of that subtree. 2026-08-03 browser merge: agents/browser is the flat one-module-per-concern browser tier (40 modules) that arrived whole from eric/browser-merged; .github/workflows crossed 7 when the packaged-smoke and intel-verify workflows landed; frontend/ is a package root, not a code folder. components/overlays is the flat one-component-per-overlay collection; it crossed 7 when the mandatory sign-in gate landed as component + pure predicate + its test.",
"import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way.",
"ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated — App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.",
"no-underscore-names + p-private": "Convention checks ported verbatim from Haik's linter (haik/feat/ingest): no-underscore-names bans leading-underscore names (a dead-code-tooling blind spot; use p_ for private), p-private enforces that p_-prefixed names are accessed only inside their owning file/class (cross-file/class use means the name should be public). Backend Python only. The exception lists grandfather pre-existing debt that landed with the workflows/analytics forward-ports (eric's 'don't mass-migrate untouched files' rule); the agent_manager refactor surface is clean. NOTE: Haik's full linter (his branch also adds pyright + ruff and runs a different enabled set) should eventually supersede this; these two were lifted to enforce the p_ conventions on eric/dev now. browser_cookies.py and its Windows round-trip test are excepted for `_fields_` only: a ctypes.Structure protocol name required by the ctypes metaclass, not our naming.",
@@ -163,7 +163,8 @@
"backend/apps/onboarding/usage/browser_cookies.py",
"backend/tests/test_browser_agent_loop.py",
"backend/tests/test_browser_skills.py",
"frontend/src/shared/state/settingsSlice.ts"
"frontend/src/shared/state/settingsSlice.ts",
"frontend/src/app/components/overlays/SignInDialog.tsx"
],
"max-folder-items": [
"backend",
@@ -208,7 +209,8 @@
"backend/apps/workflows/cloud",
"backend/apps/agents/browser",
".github/workflows",
"frontend"
"frontend",
"frontend/src/app/components/overlays"
],
"no-nested-imports": [],
"import-cycles": [],