diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 634d57f8..fd8456ec 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -17,7 +17,7 @@ Full precedences live in root [CLAUDE.md](../.claude/CLAUDE.md). Always: **under - **Spatial dashboard.** Agents are draggable nodes on a canvas; layout + selection state lives in Redux. - **Settings draft persistence.** `AppSettings.dismissed_mcp_suggestions` is a map of MCP id → ISO timestamp; preserve this shape when modifying settings serialization. - **Onboarding wizard** (`src/app/components/Onboarding/`). 8-step agentic cursor walkthrough. Cursor offsets, fit-to-view, AC popup timing, and group-meta dedup were each delicate to land; verify visually after touching this code. Note: steps 3/5/6 launch real agent sessions that hit the cloud's analytics ingest, so don't treat them as visual-only. -- **SignInGate** (`src/app/components/SignInGate.tsx`, mounted in `Main.tsx`). First-launch gate that captures `user_id` + email via Google OAuth or email magic link, hitting the cloud's `/api/auth/{google,email}/*`. Auto-dismisses for users with a valid bearer. +- **SignInDialog** (`src/app/components/overlays/SignInDialog.tsx`, opened from the Settings account card). Optional sign-in that captures `user_id` + email via Google OAuth or email magic link, hitting the cloud's `/api/auth/{google,email}/*`. Sign-in is never required to use the app. - **Custom providers.** `AppSettings.custom_providers: CustomProvider[]` supports any OpenAI-compatible endpoint (e.g. LM Studio). ## Conventions diff --git a/frontend/src/app/components/overlays/SignInGate.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx similarity index 90% rename from frontend/src/app/components/overlays/SignInGate.tsx rename to frontend/src/app/components/overlays/SignInDialog.tsx index 1a15e4f8..d2cc88b1 100644 --- a/frontend/src/app/components/overlays/SignInGate.tsx +++ b/frontend/src/app/components/overlays/SignInDialog.tsx @@ -1,6 +1,6 @@ -// Mandatory sign-in gate; Google OAuth handoff or email magic-link (6-digit code per sign-in). +// Optional sign-in dialog opened from Settings; Google OAuth handoff or email magic-link (6-digit code). -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Typography, @@ -8,21 +8,25 @@ import { Button, TextField, CircularProgress, + IconButton, Link, } from '@mui/material'; import GoogleIcon from '@mui/icons-material/Google'; import EmailIcon from '@mui/icons-material/Email'; -import { useAppSelector } from '@/shared/hooks'; +import CloseIcon from '@mui/icons-material/Close'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { activateSignin, fetchSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { OPENSWARM_DEFAULT_PROXY_URL, API_BASE } from '@/shared/config'; +import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { report } from '@/shared/serviceClient'; type Stage = 'choose' | 'email_form' | 'code_form'; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; -export default function SignInGate(): JSX.Element { +export default function SignInDialog({ onClose }: { onClose: () => void }): JSX.Element { const tokens = useClaudeTokens(); + const dispatch = useAppDispatch(); const proxyUrl = useAppSelector( (s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL, ); @@ -34,6 +38,12 @@ export default function SignInGate(): JSX.Element { const [busy, setBusy] = useState(false); const [errMsg, setErrMsg] = useState(null); + // Google's handoff page POSTs the bearer to the local backend out-of-band; poll so the dialog notices. + useEffect(() => { + const id = setInterval(() => { dispatch(fetchSettings()); }, 2000); + return () => clearInterval(id); + }, [dispatch]); + const cloudBase = proxyUrl.replace(/\/$/, ''); const onGoogle = () => { @@ -121,21 +131,14 @@ export default function SignInGate(): JSX.Element { } const data = (await res.json()) as { bearer?: string; user_id?: string; user_email?: string }; if (!data.bearer) throw new Error('Server did not return a bearer.'); - // Hand bearer to local backend like Google's handoff page so the app converges identically. - const activate = await fetch(`${API_BASE}/auth/signin-activate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + // Hand bearer to local backend like Google's handoff page; the settings refetch flips the account card, which unmounts us. + await dispatch( + activateSignin({ token: data.bearer, email: data.user_email, signin_method: 'email', }), - }); - if (!activate.ok) { - const text = await activate.text().catch(() => ''); - throw new Error(text || `Local activate failed (${activate.status})`); - } - // SignInGateLoader's 2s poll picks up new user_id and unmounts the gate. + ).unwrap(); } catch (err) { setErrMsg((err as Error).message || 'Verification failed.'); } finally { @@ -152,13 +155,14 @@ export default function SignInGate(): JSX.Element { return ( + + + {stage === 'code_form' ? ( <> { @@ -20,10 +21,12 @@ const AccountCard: React.FC = () => { const installId = useAppSelector((s) => s.settings.data.installation_id ?? ''); const proxyUrl = useAppSelector((s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL); const [signingOut, setSigningOut] = useState(false); + const [signInOpen, setSignInOpen] = useState(false); const methodLabel = (() => { switch (signinMethod) { case 'google': return 'Signed in with Google'; + case 'email': return 'Signed in with email'; case 'stripe': return 'Signed in via Stripe checkout'; default: return null; } @@ -53,7 +56,7 @@ const AccountCard: React.FC = () => { else window.open(startUrl, '_blank'); }; - // Not signed in at all (no bearer, no user_id); inline CTA. + // Not signed in at all (no bearer, no user_id); optional, sign-in just adds sync + backup. if (!userId && !hasBearer) { return ( @@ -64,7 +67,7 @@ const AccountCard: React.FC = () => { + {/* Dialog unmounts on its own once sign-in lands and this branch flips to signed-in. */} + {signInOpen && setSignInOpen(false)} />} ); } diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index d639f6a3..be194141 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -66,7 +66,7 @@ export interface AppSettings { /** Identity populated by /api/auth/signin-activate; Stripe checkout also fills these. */ user_id?: string | null; user_email?: string | null; - signin_method?: 'google' | 'stripe' | null; + signin_method?: 'google' | 'email' | 'stripe' | null; /** Anonymous device id (first-run generated); stitches anon to authed PostHog Persons. */ installation_id?: string | null; } @@ -79,7 +79,7 @@ export interface ActivateSubscriptionPayload { export interface ActivateSigninPayload { token: string; - signin_method: 'google'; + signin_method: 'google' | 'email'; email?: string | null; } @@ -197,12 +197,12 @@ export const activateSignin = createAsyncThunk( user_id: string; email: string; plan: string; - signin_method: 'google'; + signin_method: 'google' | 'email'; }; }, ); -/** POST /api/auth/signout; revokes cloud bearer, clears local identity, returns to sign-in gate. */ +/** POST /api/auth/signout; revokes cloud bearer, clears local identity. */ export const signOut = createAsyncThunk( 'settings/signOut', async (_: void, { dispatch }) => { @@ -254,7 +254,7 @@ const settingsSlice = createSlice({ .addCase(fetchSettings.fulfilled, (state, action) => { state.loading = false; state.loaded = true; - // Skip ref-assignment when byte-identical; prevents SignInGate 2s poll from re-firing every effect. + // Skip ref-assignment when byte-identical; keeps background refetch polls from re-firing every effect. const next = JSON.stringify(action.payload); const prev = JSON.stringify(state.data); if (next !== prev) {