mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] settings: sign-in becomes an optional dialog opened from the account card
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
+29
-17
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user