diff --git a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx index 80da455d..47069ade 100644 --- a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx +++ b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx @@ -10,12 +10,13 @@ import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline'; import { GRAIN_URL } from '@/shared/styles/grainTexture'; import { ARC_BLUE_BG, ONBOARDING_SANS } from './beats/BeatShell'; +import BeatSignIn from './beats/BeatSignIn'; import BeatConnect from './beats/BeatConnect'; import BeatApps from './beats/BeatApps'; import BeatTheme from './beats/BeatTheme'; import BeatCard from './beats/BeatCard'; -type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme' | 'card'; +type Beat = 'welcome' | 'newos' | 'signin' | 'connect' | 'apps' | 'theme' | 'card'; const V2_STORAGE_KEY = 'openswarm.onboarding.v2'; const WINDOWED_BEATS: Beat[] = ['welcome', 'newos']; @@ -194,14 +195,15 @@ const OnboardingV3Root: React.FC = () => { style={{ position: 'absolute', inset: 0 }} > {beat === 'welcome' && setBeat('newos')} />} - {beat === 'newos' && setBeat('connect')} />} + {beat === 'newos' && setBeat('signin')} />} + {beat === 'signin' && setBeat('connect')} onBack={() => setBeat('newos')} />} {beat === 'connect' && ( setBeat('newos')} + onBack={() => setBeat('signin')} /> )} {beat === 'apps' && setBeat('connect')} />} diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx index 1d80aae4..13d494b5 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx @@ -4,7 +4,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { API_BASE } from '@/shared/config'; import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchSubscriptionStatus, markSubscriptionConnected, selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice'; -import { hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; +import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; import { SUBSCRIPTION_PROVIDERS } from '@/app/pages/Settings/sections/subscription/subscriptionProviders'; import { runConnectFlow } from '@/app/pages/Settings/sections/subscription/subscriptionConnect'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; @@ -23,6 +23,7 @@ const BeatConnect: React.FC<{ }> = ({ c, identity, onConnected, onNext, onBack }) => { const dispatch = useAppDispatch(); const connected = useAppSelector((s) => hasModelConnected(s)); + const freeTrial = useAppSelector((s) => hasFreeTrialActive(s)); const [connecting, setConnecting] = useState(null); const [userCode, setUserCode] = useState(''); const pollTimerRef = useRef | null>(null); @@ -66,10 +67,9 @@ const BeatConnect: React.FC<{ // Which provider rows are live, so the tab itself shows "Connected", not a floating label below. const connections = useAppSelector(selectSubscriptionConnections); const connectedIds = new Set(connections.filter((cx) => cx.isActive !== false).map((cx) => cx.provider)); - // Sign-in gate: the whole flow leans on a real connection (identity, chat-history read, personalized - // reveal), so Continue stays locked until the user actually connects their own AI (OAuth subscription - // or an API key). The silently-armed free trial no longer satisfies the gate, we require a sign-in. - const canContinue = connected; + // The account gate lives in the prior sign-in beat, so here the free trial is a legitimate model + // source again: Continue unlocks on a provider connection OR the armed trial (both mean "can run"). + const canContinue = connected || freeTrial; return ( void; + onBack: () => void; +}> = ({ c, onNext, onBack }) => { + const dispatch = useAppDispatch(); + const userId = useAppSelector((s) => s.settings.data.user_id ?? null); + const userEmail = useAppSelector((s) => s.settings.data.user_email ?? null); + 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 [emailOpen, setEmailOpen] = useState(false); + const signedIn = !!userId; + + useEffect(() => { + if (signedIn || !waitingGoogle) return undefined; + const id = window.setInterval(() => { void dispatch(fetchSettings()); }, 2000); + return () => window.clearInterval(id); + }, [signedIn, waitingGoogle, dispatch]); + + const onGoogle = (): void => { + 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 api = (window as unknown as { openswarm?: { openExternal?: (u: string) => void } }).openswarm; + if (api?.openExternal) api.openExternal(startUrl); + else window.open(startUrl, '_blank'); + setWaitingGoogle(true); + }; + + 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: 'email', name: 'Continue with email', icon: , onClick: () => { if (!signedIn) setEmailOpen(true); } }, + ]; + + return ( + } + > +
+ {rows.map((row, i) => ( + + + {row.name} + {row.hint && {row.hint}} + + + {row.icon} + + + ))} + {signedIn ? ( +
+ + Signed in{userEmail ? ` as ${userEmail}` : ''} +
+ ) : ( +
+ Sign in to continue. +
+ )} +
+ {emailOpen && !signedIn && setEmailOpen(false)} />} +
+ ); +}; + +export default BeatSignIn;