mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-01 04:38:52 +02:00
[eric] onboarding: required sign-in beat (Google or email) before connect; free trial restored as model source
This commit is contained in:
@@ -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' && <IntroBeat c={c} line="Welcome." onNext={() => setBeat('newos')} />}
|
||||
{beat === 'newos' && <IntroBeat c={c} line="This is your new OS." onNext={() => setBeat('connect')} />}
|
||||
{beat === 'newos' && <IntroBeat c={c} line="This is your new OS." onNext={() => setBeat('signin')} />}
|
||||
{beat === 'signin' && <BeatSignIn c={c} onNext={() => setBeat('connect')} onBack={() => setBeat('newos')} />}
|
||||
{beat === 'connect' && (
|
||||
<BeatConnect
|
||||
c={c}
|
||||
identity={pipeline.identity}
|
||||
onConnected={onConnected}
|
||||
onNext={leaveConnect}
|
||||
onBack={() => setBeat('newos')}
|
||||
onBack={() => setBeat('signin')}
|
||||
/>
|
||||
)}
|
||||
{beat === 'apps' && <BeatApps c={c} picks={picks} setPicks={setPicks} onNext={leaveApps} onBack={() => setBeat('connect')} />}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | 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 (
|
||||
<BeatShell
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchSettings } from '@/shared/state/settingsSlice';
|
||||
import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import SignInDialog from '@/app/components/overlays/SignInDialog';
|
||||
import OnboardingLogo from '../OnboardingLogo';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// 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
|
||||
// (the cloud page POSTs the bearer to the local backend), so we poll settings until user_id appears.
|
||||
// Email reuses the proven SignInDialog (magic-link 6-digit code) on top of the beat.
|
||||
const BeatSignIn: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onNext: () => 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: <GoogleIcon sx={{ fontSize: 26, color: '#4285F4' }} />, onClick: onGoogle, hint: waitingGoogle && !signedIn ? 'waiting for your browser...' : undefined },
|
||||
{ id: 'email', name: 'Continue with email', icon: <EmailIcon sx={{ fontSize: 26, color: '#8a8a86' }} />, onClick: () => { if (!signedIn) setEmailOpen(true); } },
|
||||
];
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Sign in."
|
||||
body="Your account keeps your setup, and your free trial, tied to you."
|
||||
nextLabel="Continue"
|
||||
onNext={onNext}
|
||||
nextDisabled={!signedIn}
|
||||
onBack={onBack}
|
||||
wide
|
||||
logo={<OnboardingLogo size={52} />}
|
||||
>
|
||||
<div style={{ width: 'min(440px, 100%)', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{rows.map((row, i) => (
|
||||
<motion.button
|
||||
key={row.id}
|
||||
onClick={row.onClick}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26, delay: 0.1 + i * 0.08 }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '0 0 0 18px', textAlign: 'left', overflow: 'hidden',
|
||||
borderRadius: 14, border: 'none',
|
||||
boxShadow: signedIn ? '0 0 0 2px #1a9e6a, 0 10px 26px rgba(20,16,80,0.22)' : '0 10px 26px rgba(20,16,80,0.22)',
|
||||
background: '#ffffff', cursor: signedIn ? 'default' : 'pointer', fontFamily: 'inherit', minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, padding: '11px 0' }}>
|
||||
<span style={{ fontSize: '0.98rem', fontWeight: 600, color: '#3d3d3a' }}>{row.name}</span>
|
||||
{row.hint && <span style={{ fontSize: '0.78rem', fontWeight: 400, color: '#8a8a86' }}>{row.hint}</span>}
|
||||
</span>
|
||||
<span style={{
|
||||
alignSelf: 'stretch', width: 78, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(0,0,0,0.05), rgba(0,0,0,0.02))',
|
||||
}}>
|
||||
{row.icon}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
{signedIn ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', padding: '6px 0', fontSize: '0.9rem', color: 'rgba(255,255,255,0.92)' }}>
|
||||
<CheckRoundedIcon sx={{ fontSize: 17, color: '#4fdf9f' }} />
|
||||
Signed in{userEmail ? ` as ${userEmail}` : ''}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.82rem', color: 'rgba(255,255,255,0.6)', marginTop: 6 }}>
|
||||
Sign in to continue.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{emailOpen && !signedIn && <SignInDialog initialStage="email_form" onClose={() => setEmailOpen(false)} />}
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatSignIn;
|
||||
Reference in New Issue
Block a user