diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx index 7be6f51b..0bbce3b5 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx @@ -11,6 +11,7 @@ import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import SignInDialog from '@/app/components/overlays/SignInDialog'; import OnboardingLogo from '../OnboardingLogo'; import BeatShell from './BeatShell'; +import { googleStartUrl } from '@/shared/googleStartUrl'; // The account gate: users sign in (Google or email) before anything else, so the free trial and their // setup are tied to a real account. Google hands off through the external browser and lands out-of-band @@ -27,6 +28,7 @@ const BeatSignIn: React.FC<{ const proxyUrl = useAppSelector((s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL); const installId = useAppSelector((s) => s.settings.data.installation_id ?? ''); const [waitingGoogle, setWaitingGoogle] = useState(false); + const [notReady, setNotReady] = useState(false); const [emailOpen, setEmailOpen] = useState(false); const signedIn = !!userId; @@ -40,8 +42,11 @@ const BeatSignIn: React.FC<{ if (signedIn) return; report('signin', 'google_clicked'); const localPort = (window as unknown as { __OPENSWARM_PORT__?: number }).__OPENSWARM_PORT__ || 8324; - const params = new URLSearchParams({ install_id: installId, local_port: String(localPort) }); - const startUrl = `${proxyUrl.replace(/\/$/, '')}/api/auth/google/start?${params.toString()}`; + const startUrl = googleStartUrl(proxyUrl, installId, localPort); + // First run is exactly when settings may not have landed yet, so this beat is the likeliest + // place to catch a not-ready install. Say so on the row instead of opening a broken page. + if (!startUrl) { setNotReady(true); return; } + setNotReady(false); const api = (window as unknown as { openswarm?: { openExternal?: (u: string) => void } }).openswarm; if (api?.openExternal) api.openExternal(startUrl); else window.open(startUrl, '_blank'); @@ -49,7 +54,7 @@ const BeatSignIn: React.FC<{ }; const rows: Array<{ id: string; name: string; icon: React.ReactNode; onClick: () => void; hint?: string }> = [ - { id: 'google', name: 'Continue with Google', icon: , onClick: onGoogle, hint: waitingGoogle && !signedIn ? 'Waiting for your browser...' : undefined }, + { id: 'google', name: 'Continue with Google', icon: , onClick: onGoogle, hint: notReady ? 'Still starting up, try again in a second' : (waitingGoogle && !signedIn ? 'Waiting for your browser...' : undefined) }, { id: 'email', name: 'Continue with email', icon: , onClick: () => { if (!signedIn) setEmailOpen(true); } }, ]; diff --git a/frontend/src/app/components/overlays/SignInDialog.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx index bfdf5eb4..096a20dc 100644 --- a/frontend/src/app/components/overlays/SignInDialog.tsx +++ b/frontend/src/app/components/overlays/SignInDialog.tsx @@ -19,6 +19,7 @@ import { activateSignin, fetchSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { report } from '@/shared/serviceClient'; +import { googleStartUrl } from '@/shared/googleStartUrl'; type Stage = 'choose' | 'email_form' | 'code_form'; @@ -50,11 +51,11 @@ export default function SignInDialog({ onClose, initialStage = 'choose', mandato const onGoogle = () => { report('signin', 'google_clicked'); const localPort = (window as any).__OPENSWARM_PORT__ || 8324; - const params = new URLSearchParams({ - install_id: installId, - local_port: String(localPort), - }); - const startUrl = `${cloudBase}/api/auth/google/start?${params.toString()}`; + const startUrl = googleStartUrl(cloudBase, installId, localPort); + if (!startUrl) { + setErrMsg('Still finishing startup. Give it a second and try again.'); + return; + } const api = (window as any).openswarm; if (api?.openExternal) { api.openExternal(startUrl); diff --git a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx index 57403f1a..31b45ea1 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx @@ -8,6 +8,7 @@ import { signOut } from '@/shared/state/settingsSlice'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import SignInDialog from '@/app/components/overlays/SignInDialog'; +import { googleStartUrl } from '@/shared/googleStartUrl'; /** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */ const AccountCard: React.FC = () => { @@ -46,11 +47,10 @@ const AccountCard: React.FC = () => { const onSignIn = () => { // Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424). const localPort = (window as any).__OPENSWARM_PORT__ || 8324; - const params = new URLSearchParams({ - install_id: installId, - local_port: String(localPort), - }); - const startUrl = proxyUrl.replace(/\/$/, '') + '/api/auth/google/start?' + params.toString(); + const startUrl = googleStartUrl(proxyUrl, installId, localPort); + // No error surface on this card, so the honest move is to do nothing visible rather than send + // them to a black cloud error page. Settings land within a second and the click then works. + if (!startUrl) return; const api = (window as any).openswarm; if (api?.openExternal) api.openExternal(startUrl); else window.open(startUrl, '_blank'); diff --git a/frontend/src/shared/googleStartUrl.test.ts b/frontend/src/shared/googleStartUrl.test.ts new file mode 100644 index 00000000..0818e995 --- /dev/null +++ b/frontend/src/shared/googleStartUrl.test.ts @@ -0,0 +1,48 @@ +// Run: node --test (via frontend/scripts/run-tests.mjs) +// +// The install id arrives with settings, so every sign-in button can be clicked before it exists. +// When that happened the app sent the user to the cloud with `install_id=` and they landed on a bare +// black page reading "install_id must be 8-128 chars" (seen live). A URL that cannot work should +// never be built in the first place. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { googleStartUrl, isUsableInstallId } from './googleStartUrl.ts'; + +const PROXY = 'https://api.openswarm.com'; +const REAL_ID = 'faec918d-6bda-42d5-9de9-f274eb49a8bc'; + +test('a real install id builds the URL the cloud expects', () => { + const u = googleStartUrl(PROXY, REAL_ID, 8324); + assert.equal(u, `https://api.openswarm.com/api/auth/google/start?install_id=${REAL_ID}&local_port=8324`); +}); + +test('settings not loaded yet refuses instead of building a doomed URL', () => { + assert.equal(googleStartUrl(PROXY, '', 8324), null); +}); + +test('an id shorter than the cloud accepts is refused on this side of the network', () => { + assert.equal(googleStartUrl(PROXY, '1234567', 8324), null, '7 chars is below the cloud minimum of 8'); + assert.notEqual(googleStartUrl(PROXY, '12345678', 8324), null, '8 chars is exactly the minimum and must pass'); +}); + +test('an absurdly long id is refused too, matching the cloud bound', () => { + assert.notEqual(googleStartUrl(PROXY, 'x'.repeat(128), 8324), null); + assert.equal(googleStartUrl(PROXY, 'x'.repeat(129), 8324), null); +}); + +test('a trailing slash on the proxy does not produce a double slash', () => { + const u = googleStartUrl('https://api.openswarm.com/', REAL_ID, 8324); + assert.ok(u && !u.includes('.com//'), `double slash in ${u}`); +}); + +test('the port actually travels, since the bearer handoff POSTs back to it', () => { + const u = googleStartUrl(PROXY, REAL_ID, 8411); + assert.ok(u && u.includes('local_port=8411')); +}); + +test('the predicate agrees with the builder in both directions', () => { + assert.equal(isUsableInstallId(undefined), false); + assert.equal(isUsableInstallId(null), false); + assert.equal(isUsableInstallId(''), false); + assert.equal(isUsableInstallId(REAL_ID), true); +}); diff --git a/frontend/src/shared/googleStartUrl.ts b/frontend/src/shared/googleStartUrl.ts new file mode 100644 index 00000000..f47b877c --- /dev/null +++ b/frontend/src/shared/googleStartUrl.ts @@ -0,0 +1,19 @@ +// The install id lives in settings, so it is empty until `GET /api/settings` has answered. Clicking +// "Continue with Google" before that sent the user to the cloud with `install_id=`, and the cloud +// answers with a bare black page reading "install_id must be 8-128 chars". Three call sites built +// this URL by hand and all three could do it. Refusing to build an unusable URL is the whole fix. + +// Mirrors the cloud's own validation, so we fail on this side of the network instead of over there. +const INSTALL_ID_MIN = 8; +const INSTALL_ID_MAX = 128; + +export function isUsableInstallId(id: string | undefined | null): boolean { + return !!id && id.length >= INSTALL_ID_MIN && id.length <= INSTALL_ID_MAX; +} + +/** The Google sign-in start URL, or null when this install cannot form a valid one yet. */ +export function googleStartUrl(proxyUrl: string, installId: string, localPort: number): string | null { + if (!isUsableInstallId(installId)) return null; + const params = new URLSearchParams({ install_id: installId, local_port: String(localPort) }); + return `${proxyUrl.replace(/\/$/, '')}/api/auth/google/start?${params.toString()}`; +}