[eric] settings: sign-in becomes an optional dialog opened from the account card

This commit is contained in:
ciregenz
2026-06-04 04:17:38 -07:00
parent f59ab9d4dd
commit e5ff670070
4 changed files with 42 additions and 25 deletions
+1 -1
View File
@@ -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
@@ -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<string | null>(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 (
<Modal
open
disableEscapeKeyDown
onClose={onClose}
hideBackdrop={false}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
slotProps={{ backdrop: { sx: { backgroundColor: 'rgba(0,0,0,0.55)' } } }}
>
<Box
sx={{
position: 'relative',
width: '100%',
maxWidth: 440,
mx: 2,
@@ -171,6 +175,14 @@ export default function SignInGate(): JSX.Element {
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>
{stage === 'code_form' ? (
<>
<Typography
@@ -7,6 +7,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
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';
/** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */
const AccountCard: React.FC = () => {
@@ -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 (
<Box sx={{ p: 2, mb: 2, borderRadius: `${c.radius.lg}px`, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
@@ -64,7 +67,7 @@ const AccountCard: React.FC = () => {
<Button
variant="outlined"
size="small"
onClick={onSignIn}
onClick={() => setSignInOpen(true)}
sx={{
textTransform: 'none',
fontSize: '0.8rem',
@@ -75,6 +78,8 @@ const AccountCard: React.FC = () => {
>
Sign in to OpenSwarm
</Button>
{/* Dialog unmounts on its own once sign-in lands and this branch flips to signed-in. */}
{signInOpen && <SignInDialog onClose={() => setSignInOpen(false)} />}
</Box>
);
}
+5 -5
View File
@@ -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) {