mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] email: Settings gains a real cloud-run email toggle behind a degrade-honest proxy, ghost label survives old prod
This commit is contained in:
@@ -235,3 +235,62 @@ async def signout():
|
||||
p_sync_identity_to_service(settings_obj)
|
||||
await p_sync_pro_routing(settings_obj)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- /api/auth/email-prefs ---------------------------------------------------------------------------
|
||||
|
||||
class EmailPrefsUpdate(BaseModel):
|
||||
run_emails: bool
|
||||
|
||||
|
||||
def p_email_prefs_unavailable() -> dict:
|
||||
return {"available": False, "run_emails": None}
|
||||
|
||||
|
||||
@auth.router.get("/email-prefs")
|
||||
async def get_email_prefs():
|
||||
"""Proxy the cloud's signed-in run-email preference for the Settings toggle.
|
||||
|
||||
Degrades to available:false (the Settings row falls back to its honest
|
||||
ghost label) when signed out, when the cloud is unreachable, or when prod
|
||||
predates the prefs endpoint. Never 500s the Settings page.
|
||||
"""
|
||||
from backend.apps.settings.credentials import account_auth
|
||||
|
||||
token, base = account_auth(load_settings())
|
||||
if not token:
|
||||
return p_email_prefs_unavailable()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||
r = await client.get(
|
||||
f"{base}/api/email-prefs/mine",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return p_email_prefs_unavailable()
|
||||
if r.status_code != 200:
|
||||
return p_email_prefs_unavailable()
|
||||
data = r.json()
|
||||
return {"available": True, "run_emails": bool(data.get("run_emails"))}
|
||||
|
||||
|
||||
@auth.router.put("/email-prefs")
|
||||
async def put_email_prefs(body: EmailPrefsUpdate):
|
||||
from backend.apps.settings.credentials import account_auth
|
||||
|
||||
token, base = account_auth(load_settings())
|
||||
if not token:
|
||||
return p_email_prefs_unavailable()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||
r = await client.put(
|
||||
f"{base}/api/email-prefs/mine",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"run_emails": body.run_emails},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return p_email_prefs_unavailable()
|
||||
if r.status_code != 200:
|
||||
return p_email_prefs_unavailable()
|
||||
data = r.json()
|
||||
return {"available": True, "run_emails": bool(data.get("run_emails"))}
|
||||
|
||||
@@ -208,3 +208,68 @@ def test_dev_token_is_dev_only():
|
||||
assert noauth.get("/api/dev/token").status_code == 404
|
||||
finally:
|
||||
os.environ.pop("OPENSWARM_PACKAGED", None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- /api/auth/email-prefs ---------------------------------------------------------------------------
|
||||
|
||||
def p_set_bearer(value):
|
||||
from backend.apps.settings.settings import load_settings, save_settings
|
||||
s = load_settings()
|
||||
s.openswarm_bearer_token = value
|
||||
save_settings(s)
|
||||
|
||||
|
||||
def test_email_prefs_signed_out_reads_unavailable(client, reset_settings):
|
||||
p_set_bearer(None)
|
||||
r = client.get("/api/auth/email-prefs")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"available": False, "run_emails": None}
|
||||
|
||||
|
||||
def test_email_prefs_passthrough_when_cloud_answers(client, reset_settings):
|
||||
p_set_bearer("bearer-abc")
|
||||
fake_response = AsyncMock()
|
||||
fake_response.status_code = 200
|
||||
fake_response.json = lambda: {"run_emails": True}
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.get = AsyncMock(return_value=fake_response)
|
||||
r = client.get("/api/auth/email-prefs")
|
||||
assert r.json() == {"available": True, "run_emails": True}
|
||||
sent_headers = instance.get.call_args.kwargs["headers"]
|
||||
assert sent_headers["Authorization"] == "Bearer bearer-abc"
|
||||
|
||||
|
||||
def test_email_prefs_old_prod_404_degrades_to_unavailable(client, reset_settings):
|
||||
p_set_bearer("bearer-abc")
|
||||
fake_response = AsyncMock()
|
||||
fake_response.status_code = 404
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.get = AsyncMock(return_value=fake_response)
|
||||
r = client.get("/api/auth/email-prefs")
|
||||
assert r.json() == {"available": False, "run_emails": None}
|
||||
|
||||
|
||||
def test_email_prefs_network_failure_never_500s(client, reset_settings):
|
||||
import httpx as p_httpx
|
||||
p_set_bearer("bearer-abc")
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.get = AsyncMock(side_effect=p_httpx.ConnectError("down"))
|
||||
r = client.get("/api/auth/email-prefs")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"available": False, "run_emails": None}
|
||||
|
||||
|
||||
def test_email_prefs_put_flips_through_the_cloud(client, reset_settings):
|
||||
p_set_bearer("bearer-abc")
|
||||
fake_response = AsyncMock()
|
||||
fake_response.status_code = 200
|
||||
fake_response.json = lambda: {"run_emails": False}
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.put = AsyncMock(return_value=fake_response)
|
||||
r = client.put("/api/auth/email-prefs", json={"run_emails": False})
|
||||
assert r.json() == {"available": True, "run_emails": False}
|
||||
assert instance.put.call_args.kwargs["json"] == {"run_emails": False}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { AppSettings } from '@/shared/state/settingsSlice';
|
||||
|
||||
interface Props {
|
||||
@@ -10,9 +12,43 @@ interface Props {
|
||||
setForm: (next: AppSettings) => void;
|
||||
}
|
||||
|
||||
interface EmailPrefs {
|
||||
available: boolean;
|
||||
run_emails: boolean | null;
|
||||
}
|
||||
|
||||
// Both toggles gate REAL notification paths (notifications.ts checks them before firing); nothing here is decorative.
|
||||
const NotificationsSection: React.FC<Props> = ({ form, setForm }) => {
|
||||
const c = useClaudeTokens();
|
||||
const signedIn = useAppSelector((s) => Boolean(s.settings.data.openswarm_bearer_token));
|
||||
// The email pref lives in the CLOUD (the sender must read it with the laptop shut), so this row round-trips instead of writing local settings.
|
||||
const [emailPrefs, setEmailPrefs] = useState<EmailPrefs | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch(`${API_BASE}/auth/email-prefs`, { cache: 'no-store' })
|
||||
.then((r) => r.json())
|
||||
.then((d: EmailPrefs) => { if (alive) setEmailPrefs(d); })
|
||||
.catch(() => { if (alive) setEmailPrefs({ available: false, run_emails: null }); });
|
||||
return () => { alive = false; };
|
||||
}, [signedIn]);
|
||||
|
||||
const flipEmail = async (next: boolean): Promise<void> => {
|
||||
setSaving(true);
|
||||
setEmailPrefs({ available: true, run_emails: next });
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/auth/email-prefs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ run_emails: next }),
|
||||
});
|
||||
setEmailPrefs((await r.json()) as EmailPrefs);
|
||||
} catch {
|
||||
setEmailPrefs({ available: false, run_emails: null });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const row = (title: string, body: string, key: 'notify_agent_completion' | 'notify_workflow_runs'): React.ReactElement => (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2,
|
||||
@@ -33,9 +69,31 @@ const NotificationsSection: React.FC<Props> = ({ form, setForm }) => {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{row('Agent completion', 'Native notification when an agent finishes or errors while the window is in the background.', 'notify_agent_completion')}
|
||||
{row('Workflow runs', 'Notification Center alert when a scheduled workflow run finishes, with quick actions.', 'notify_workflow_runs')}
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.ghost, px: 0.5, pt: 2 }}>
|
||||
Email alerts are not available yet.
|
||||
</Typography>
|
||||
{emailPrefs?.available ? (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2,
|
||||
px: 0.5, py: 2, borderBottom: `1px solid ${c.border.subtle}`, '&:last-of-type': { borderBottom: 'none' },
|
||||
}}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.875rem', fontWeight: 600, color: c.text.primary }}>Email on cloud runs</Typography>
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.tertiary, mt: 0.25 }}>
|
||||
Emails you the result when a cloud-hosted workflow finishes, even with this computer off. Every email carries its own off switch.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
size="small"
|
||||
disabled={saving}
|
||||
checked={emailPrefs.run_emails === true}
|
||||
onChange={(e) => { void flipEmail(e.target.checked); }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.ghost, px: 0.5, pt: 2 }}>
|
||||
{signedIn
|
||||
? 'Email alerts are not available right now.'
|
||||
: 'Sign in to get an email when a cloud workflow run finishes.'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user