From 08c21aca899684c8071cc5f683a74a587de67e74 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 02:39:14 -0700 Subject: [PATCH 01/10] fix(oauth): don't set responseType=json when loopback is active In dev (pnpm dev:app), the OAuth button was setting both `responseType=json` and `redirectUri=` on the backend login URL. `responseType=json` makes the backend return JSON in the browser tab instead of redirecting, so the loopback listener never receives the callback and the app stays stuck on the loading state even though OAuth itself succeeds. Only set `responseType=json` when there is no loopback handle (web build or bind failure), preserving the pre-loopback dev workaround as a fallback. --- app/src/components/oauth/OAuthProviderButton.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx index 05546b69fc..747df95937 100644 --- a/app/src/components/oauth/OAuthProviderButton.tsx +++ b/app/src/components/oauth/OAuthProviderButton.tsx @@ -239,7 +239,11 @@ const OAuthProviderButton = ({ const loopback = isTauri() ? await startLoopbackOauthListener() : null; const loginUrlBase = `${backendUrl}/auth/${provider.id}/login`; const params = new URLSearchParams(); - if (IS_DEV) params.set('responseType', 'json'); + // `responseType=json` makes the backend return JSON in the browser tab + // instead of redirecting — useful as a pre-loopback dev workaround, but + // it shortcircuits the redirect so the loopback listener never fires. + // Only set it when we have no loopback handle (web build, or bind failed). + if (IS_DEV && !loopback) params.set('responseType', 'json'); if (loopback) params.set('redirectUri', loopback.redirectUri); const loginUrl = params.toString() ? `${loginUrlBase}?${params}` : loginUrlBase; From 5471f586e2a7f3a90d07ad9f9bad00fae433f4cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 02:40:49 -0700 Subject: [PATCH 02/10] fix(oauth): allow loopback OAuth commands in core-process capability `start_loopback_oauth_listener` / `stop_loopback_oauth_listener` were registered in the invoke handler (#2511) but never added to the `allow-core-process` capability allow-list, so every Tauri invoke was rejected with "Command not found" and the loopback path silently fell back to the deep-link redirect on every login click. Adds both commands to the allow-list so the loopback flow can actually fire on desktop. --- app/src-tauri/permissions/allow-core-process.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index 823f46f6f9..d424df61f4 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -125,6 +125,16 @@ allow = [ # CEF / PROFILE # ========================= "schedule_cef_profile_purge", + + # ========================= + # LOOPBACK OAUTH (RFC 8252) + # ========================= + # One-shot http://127.0.0.1:/auth listener that receives the OAuth + # callback in lieu of the openhuman:// deep link (#2511). Without these + # allow entries the invoke is rejected with "Command not found" and every + # login transparently falls back to the deep-link path. + "start_loopback_oauth_listener", + "stop_loopback_oauth_listener", ] deny = [] From 8dbc749b818a33882be5720cc638dc33d371aeca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 09:13:54 -0700 Subject: [PATCH 03/10] fix(oauth): rebind-safety + ephemeral-port fallback + camelCase result Five issues that together prevented the loopback OAuth path from ever completing end-to-end in dev: - StartResult serialized as `redirect_uri`/`state` (snake_case) but TS read `result.redirectUri`. The undefined value tripped a silent TypeError in appendState, so every "successful" listener bind was immediately abandoned in JS. Tag with `#[serde(rename_all = "camelCase")]`. - Two clicks in quick succession failed the second bind with EADDRINUSE: `cancel_active_listener` only signalled the previous task; the socket was still owned. Switch to TcpSocket with SO_REUSEADDR + await the previous task's JoinHandle before re-binding. - Fall back to an OS-assigned ephemeral port if the requested port is taken by a stale/unrelated process. The backend `redirectUri` whitelist restricts host but not port, so this is safe. - JS-side `listen()` handler from a previous click stayed registered after Rust cancelled its listener, so the next callback fanned out to every stale handler. Track and unsubscribe before installing a new one. - (Sibling commit) Loopback commands were missing from the `allow-core-process` capability allow-list; every invoke returned "Command not found". --- app/src-tauri/src/loopback_oauth.rs | 88 ++++++++++++++++++++++---- app/src/utils/loopbackOauthListener.ts | 19 +++++- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/app/src-tauri/src/loopback_oauth.rs b/app/src-tauri/src/loopback_oauth.rs index dd3769d7be..e3a56d57c6 100644 --- a/app/src-tauri/src/loopback_oauth.rs +++ b/app/src-tauri/src/loopback_oauth.rs @@ -29,7 +29,7 @@ use tauri::Emitter; use crate::AppRuntime; type AppHandle = tauri::AppHandle; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, TcpSocket}; use tokio::sync::oneshot; use tokio::time::timeout; @@ -40,15 +40,19 @@ const PER_CONNECTION_READ_TIMEOUT: Duration = Duration::from_secs(5); struct ActiveListener { id: u64, tx: oneshot::Sender<()>, + done: Option>, } static NEXT_LISTENER_ID: AtomicU64 = AtomicU64::new(1); static ACTIVE_LISTENER: Mutex> = Mutex::new(None); #[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct StartResult { /// Full redirect URI the backend should redirect to, e.g. /// `http://127.0.0.1:53824/auth`. State is appended by the caller. + /// Serializes as `redirectUri` so the TS-side `result.redirectUri` + /// destructure works. pub redirect_uri: String, /// State nonce the backend must echo back as `?state=`. pub state: String, @@ -61,18 +65,39 @@ struct CallbackPayload { url: String, } -fn cancel_active_listener() { +/// Signal the active listener to stop and return its join handle so the caller +/// can await its full teardown — critical when re-binding a fixed port, since +/// macOS releases the socket only after the owning task drops the listener. +fn take_active_listener() -> Option> { if let Ok(mut guard) = ACTIVE_LISTENER.lock() { - if let Some(active) = guard.take() { + if let Some(mut active) = guard.take() { let _ = active.tx.send(()); + return active.done.take(); } } + None +} + +fn cancel_active_listener() { + let _ = take_active_listener(); } -fn install_active_listener(id: u64, tx: oneshot::Sender<()>) { +fn install_active_listener( + id: u64, + tx: oneshot::Sender<()>, + done: tauri::async_runtime::JoinHandle<()>, +) { if let Ok(mut guard) = ACTIVE_LISTENER.lock() { - if let Some(old) = guard.replace(ActiveListener { id, tx }) { + if let Some(mut old) = guard.replace(ActiveListener { + id, + tx, + done: Some(done), + }) { let _ = old.tx.send(()); + // The previous listener's join handle is dropped here without an + // await — only the new-start path needs to await teardown. Stray + // installs (none today) would simply leak the wait, not break. + old.done.take(); } } } @@ -88,6 +113,26 @@ fn clear_active_listener(id: u64) { } } +/// Bind a loopback TCP listener on the given port (or 0 for ephemeral). Sets +/// SO_REUSEADDR so re-binding the same port soon after a previous listener +/// dropped doesn't trip EADDRINUSE on the TIME_WAIT window. +fn bind_loopback(port: u16) -> Result { + let sock_addr: std::net::SocketAddr = format!("127.0.0.1:{port}") + .parse() + .map_err(|err| format!("parse 127.0.0.1:{port} failed: {err}"))?; + let socket = + TcpSocket::new_v4().map_err(|err| format!("TcpSocket::new_v4 failed: {err}"))?; + socket + .set_reuseaddr(true) + .map_err(|err| format!("set_reuseaddr failed: {err}"))?; + socket + .bind(sock_addr) + .map_err(|err| format!("bind 127.0.0.1:{port} failed: {err}"))?; + socket + .listen(16) + .map_err(|err| format!("listen on 127.0.0.1:{port} failed: {err}")) +} + fn random_state_nonce() -> String { let mut bytes = [0u8; 16]; rand::rng().fill_bytes(&mut bytes); @@ -136,12 +181,31 @@ pub async fn start_loopback_oauth_listener( port: u16, timeout_secs: u64, ) -> Result { - cancel_active_listener(); + // Await the previous listener's task ending so the OS has actually + // released the fixed loopback port. SO_REUSEADDR alone is not enough on + // macOS — the prior socket must be dropped first. + if let Some(done) = take_active_listener() { + let _ = done.await; + } - let bind_addr = format!("127.0.0.1:{port}"); - let listener = TcpListener::bind(&bind_addr) - .await - .map_err(|err| format!("bind {bind_addr} failed: {err}"))?; + // Prefer the caller's requested port (so the backend allowlist, if any, + // matches) but fall back to an ephemeral OS-assigned port if the requested + // one is taken by another process (stale openhuman, second instance, + // unrelated service). The backend `redirectUri` whitelist restricts host + // but not port, so an ephemeral fallback is safe. + let listener: TcpListener = match bind_loopback(port) { + Ok(l) => l, + Err(primary_err) => { + log::warn!( + "[loopback-oauth] bind on requested port {port} failed ({primary_err}); retrying on ephemeral port" + ); + bind_loopback(0).map_err(|err| { + format!( + "bind 127.0.0.1:{port} failed ({primary_err}); ephemeral fallback also failed: {err}" + ) + })? + } + }; // Use the listener's actual bound port for the emitted callback URL so // the frontend rewrite (`^https?://127.0.0.1:\d+/auth`) always matches, // even if a future change moves to port 0. @@ -156,10 +220,9 @@ pub async fn start_loopback_oauth_listener( let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); let listener_id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed); - install_active_listener(listener_id, cancel_tx); let expected_state = state.clone(); - tauri::async_runtime::spawn(async move { + let done = tauri::async_runtime::spawn(async move { let lifetime = Duration::from_secs(timeout_secs.max(1)); let run = run_accept_loop(listener, app, expected_state, bound_port, cancel_rx); match timeout(lifetime, run).await { @@ -171,6 +234,7 @@ pub async fn start_loopback_oauth_listener( } clear_active_listener(listener_id); }); + install_active_listener(listener_id, cancel_tx, done); Ok(StartResult { redirect_uri, diff --git a/app/src/utils/loopbackOauthListener.ts b/app/src/utils/loopbackOauthListener.ts index 71a0563da6..4c87652fc5 100644 --- a/app/src/utils/loopbackOauthListener.ts +++ b/app/src/utils/loopbackOauthListener.ts @@ -49,6 +49,14 @@ export interface StartLoopbackOptions { timeoutSecs?: number; } +/** + * The JS-side `listen()` handler from a previous call. We unsubscribe it + * before starting a new listener so a single Rust emit can't fan out to + * multiple stale handlers (happens when the user re-clicks before the + * previous OAuth round-trip completes). + */ +let activeUnlisten: UnlistenFn | null = null; + /** * Start a one-shot loopback listener. Returns `null` if not running inside * Tauri, or if the shell fails to bind (port in use, etc) — the caller should @@ -57,6 +65,11 @@ export interface StartLoopbackOptions { export const startLoopbackOauthListener = async ( options: StartLoopbackOptions = {} ): Promise => { + if (activeUnlisten) { + const prev = activeUnlisten; + activeUnlisten = null; + prev(); + } if (!isTauri()) { return null; } @@ -93,11 +106,15 @@ export const startLoopbackOauthListener = async ( listen(CALLBACK_EVENT, event => { window.clearTimeout(timer); - if (unlisten) unlisten(); + if (unlisten) { + unlisten(); + if (activeUnlisten === unlisten) activeUnlisten = null; + } resolve(event.payload.url); }) .then(fn => { unlisten = fn; + activeUnlisten = fn; }) .catch(err => { window.clearTimeout(timer); From 4380405040660c6f823a7be5c9b8fbda2fe62e69 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 09:14:57 -0700 Subject: [PATCH 04/10] feat(home): move theme toggle into the homepage card header The sun/moon toggle used to sit above the main card in its own row. Move it into the card's header row alongside the version label so the card owns its own controls. The version stays visually centered via a width-matched spacer on the left. --- app/src/pages/Home.tsx | 83 +++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx index 3d6082403b..7ffba56594 100644 --- a/app/src/pages/Home.tsx +++ b/app/src/pages/Home.tsx @@ -160,55 +160,54 @@ const Home = () => { {showPromoBanner && } - {/* Theme toggle — sun/moon icon above the main card */} -
- -
- {/* Main card — data-walkthrough target for step 1 */}
- {/* Header row: logo + version + settings */} -
+ {/* Header row: version centered, theme toggle right-aligned. + The empty left spacer matches the toggle's width so the version + stays visually centered. */} +
+ {/* Welcome title */} From 18034e6b1893557fbdf2d28f217cfb6f18f7f04c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 09:24:07 -0700 Subject: [PATCH 05/10] feat(settings): tab Notifications preferences and Routing under one entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notification routing (previously buried in Developer Options) is now a second tab inside the main Settings → Notifications page, alongside the existing per-channel/DnD preferences. The two panels remain independent components but accept an `embedded` flag so the new `NotificationsTabbedPanel` wrapper owns the SettingsHeader and tab strip. The legacy `/settings/notification-routing` path redirects to `/settings/notifications#routing` so existing deep links keep working, and the entry has been removed from Developer Options. --- .../settings/panels/DeveloperOptionsPanel.tsx | 19 +--- .../panels/NotificationRoutingPanel.tsx | 22 +++-- .../settings/panels/NotificationsPanel.tsx | 22 +++-- .../panels/NotificationsTabbedPanel.tsx | 91 +++++++++++++++++++ app/src/lib/i18n/en.ts | 2 + app/src/pages/Settings.tsx | 10 +- 6 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 app/src/components/settings/panels/NotificationsTabbedPanel.tsx diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index b2c15b7d90..50a398f097 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -167,22 +167,9 @@ const developerItems = [ ), }, - { - id: 'notification-routing', - titleKey: 'settings.developerMenu.notificationRouting.title', - descriptionKey: 'settings.developerMenu.notificationRouting.desc', - route: 'notification-routing', - icon: ( - - - - ), - }, + // `notification-routing` moved into the main Settings → Notifications page + // as a tab. The old `/settings/notification-routing` path now redirects to + // `/settings/notifications#routing`, so deep links continue to work. { id: 'webhooks-triggers', titleKey: 'settings.developerMenu.composeioTriggers.title', diff --git a/app/src/components/settings/panels/NotificationRoutingPanel.tsx b/app/src/components/settings/panels/NotificationRoutingPanel.tsx index 93ac74a701..abb5df93b3 100644 --- a/app/src/components/settings/panels/NotificationRoutingPanel.tsx +++ b/app/src/components/settings/panels/NotificationRoutingPanel.tsx @@ -12,13 +12,19 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const PROVIDERS = ['gmail', 'slack', 'discord', 'whatsapp']; +interface NotificationRoutingPanelProps { + /** When embedded inside the tabbed Notifications page, the parent owns the + `` chrome and we render only the body. */ + embedded?: boolean; +} + /** * Settings panel for the notification intelligence / routing pipeline. * * Currently exposes a global explanation card. Per-provider threshold * controls will populate here as providers are connected. */ -const NotificationRoutingPanel = () => { +const NotificationRoutingPanel = ({ embedded = false }: NotificationRoutingPanelProps = {}) => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const providers = PROVIDERS; @@ -104,12 +110,14 @@ const NotificationRoutingPanel = () => { return (
- + {!embedded && ( + + )}
{stats && ( diff --git a/app/src/components/settings/panels/NotificationsPanel.tsx b/app/src/components/settings/panels/NotificationsPanel.tsx index bff94d3c2f..9a8a172f8d 100644 --- a/app/src/components/settings/panels/NotificationsPanel.tsx +++ b/app/src/components/settings/panels/NotificationsPanel.tsx @@ -7,6 +7,12 @@ import { type NotificationCategory, setPreference } from '../../../store/notific import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +interface NotificationsPanelProps { + /** When embedded inside the tabbed Notifications page, the parent owns the + `` chrome and we render only the body. */ + embedded?: boolean; +} + const CATEGORIES: { id: NotificationCategory; title: string; description: string }[] = [ { id: 'messages', @@ -41,7 +47,7 @@ const CATEGORIES: { id: NotificationCategory; title: string; description: string }, ]; -const NotificationsPanel = () => { +const NotificationsPanel = ({ embedded = false }: NotificationsPanelProps = {}) => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const preferences = useAppSelector(s => s.notifications.preferences); @@ -78,12 +84,14 @@ const NotificationsPanel = () => { return (
- + {!embedded && ( + + )}
diff --git a/app/src/components/settings/panels/NotificationsTabbedPanel.tsx b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx new file mode 100644 index 0000000000..d6e590b294 --- /dev/null +++ b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +import NotificationRoutingPanel from './NotificationRoutingPanel'; +import NotificationsPanel from './NotificationsPanel'; + +type TabId = 'preferences' | 'routing'; + +const TAB_HASH: Record = { + preferences: '', + routing: '#routing', +}; + +const hashToTab = (hash: string): TabId => (hash === '#routing' ? 'routing' : 'preferences'); + +/** + * Single Settings entry for notifications. Combines the user-facing + * preferences (NotificationsPanel) and the routing/intelligence pipeline + * controls (NotificationRoutingPanel) as two tabs under one header. The + * active tab is reflected in the URL hash (`#routing`) so deep links from + * Developer Options still land on the right view. + */ +const NotificationsTabbedPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const location = useLocation(); + const navigate = useNavigate(); + const [tab, setTab] = useState(() => hashToTab(location.hash)); + + // Keep state in sync if the user navigates with the hash directly. + useEffect(() => { + setTab(hashToTab(location.hash)); + }, [location.hash]); + + const selectTab = (next: TabId) => { + setTab(next); + navigate(`${location.pathname}${TAB_HASH[next]}`, { replace: true }); + }; + + const tabs: { id: TabId; label: string }[] = [ + { id: 'preferences', label: t('settings.notifications.tabs.preferences') }, + { id: 'routing', label: t('settings.notifications.tabs.routing') }, + ]; + + return ( +
+ + +
+ {tabs.map(({ id, label }) => { + const selected = tab === id; + return ( + + ); + })} +
+ + {tab === 'preferences' ? ( + + ) : ( + + )} +
+ ); +}; + +export default NotificationsTabbedPanel; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index aba5dba949..06bd588674 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -64,6 +64,8 @@ const en: TranslationMap = { 'settings.accountDesc': 'Recovery phrase, team, connections, and privacy', 'settings.notifications': 'Notifications', 'settings.notificationsDesc': 'Do Not Disturb and per-account notification controls', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Features', 'settings.featuresDesc': 'Screen awareness, messaging, and tools', 'settings.aiModels': 'AI & Models', diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 1af00cef2d..6e7562ce7b 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -23,8 +23,7 @@ import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel'; import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel'; import MessagingPanel from '../components/settings/panels/MessagingPanel'; import MigrationPanel from '../components/settings/panels/MigrationPanel'; -import NotificationRoutingPanel from '../components/settings/panels/NotificationRoutingPanel'; -import NotificationsPanel from '../components/settings/panels/NotificationsPanel'; +import NotificationsTabbedPanel from '../components/settings/panels/NotificationsTabbedPanel'; import PrivacyPanel from '../components/settings/panels/PrivacyPanel'; import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel'; import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel'; @@ -348,7 +347,7 @@ const Settings = () => { )} /> )} /> )} /> - )} /> + )} /> )} /> )} /> )} /> @@ -357,9 +356,12 @@ const Settings = () => { )} /> )} /> )} /> + {/* Legacy direct path for the routing tab — kept so existing links + (Developer Options entries, walkthroughs) keep working. The + tabbed panel reads the URL hash to land on the right tab. */} )} + element={} /> )} /> )} /> From 9f9a078e5f70ad82a4916457daf48cdd178a23b1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 09:39:21 -0700 Subject: [PATCH 06/10] refactor(settings): move destructive actions to Account, drop Connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract Log Out / Clear App Data + the confirmation modal out of Settings home into a new LogoutAndClearActions component and render it as the footer of the Settings → Account page. SettingsSectionPage grows an optional `footer` slot. The home menu is now purely navigational; destructive actions live with the rest of the account-scoped controls. - Delete the Connections panel entirely (MetaMask / Binance / Notion / Google placeholders that are no longer part of the product), along with its test and account-section entry. The deep route `/settings/connections` is gone; references in the navigation hook, Developer Options, and the post-onboarding deep link are removed or repointed to `/settings/composio-routing`. `walletApi` stays — it's still used by the recovery-phrase flow. - Move the Clear-App-Data flow tests to the new component, and add a guard on SettingsHome that the destructive entries no longer appear on the home screen. --- .../settings/LogoutAndClearActions.tsx | 161 ++++++++++++ app/src/components/settings/SettingsHome.tsx | 159 +---------- .../settings/SettingsSectionPage.tsx | 6 +- .../__tests__/LogoutAndClearActions.test.tsx | 98 +++++++ .../settings/__tests__/SettingsHome.test.tsx | 69 +---- .../settings/hooks/useSettingsNavigation.ts | 3 - .../settings/panels/ConnectionsPanel.tsx | 246 ------------------ .../__tests__/ConnectionsPanel.test.tsx | 107 -------- app/src/pages/Settings.tsx | 21 +- app/src/pages/onboarding/customWizardSteps.ts | 2 +- 10 files changed, 282 insertions(+), 590 deletions(-) create mode 100644 app/src/components/settings/LogoutAndClearActions.tsx create mode 100644 app/src/components/settings/__tests__/LogoutAndClearActions.test.tsx delete mode 100644 app/src/components/settings/panels/ConnectionsPanel.tsx delete mode 100644 app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx diff --git a/app/src/components/settings/LogoutAndClearActions.tsx b/app/src/components/settings/LogoutAndClearActions.tsx new file mode 100644 index 0000000000..d80b623368 --- /dev/null +++ b/app/src/components/settings/LogoutAndClearActions.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { useCoreState } from '../../providers/CoreStateProvider'; +import { clearAllAppData } from '../../utils/clearAllAppData'; + +import SettingsMenuItem from './components/SettingsMenuItem'; + +/** + * Destructive account actions: Log out, and Log out + clear all app data. + * Lives at the bottom of the Settings → Account page. Owns its own modal + * state and confirmation flow so the parent page is just a list + this row. + */ +const LogoutAndClearActions = () => { + const { t } = useT(); + const { clearSession, snapshot } = useCoreState(); + const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogout = async () => { + try { + await clearSession(); + } catch (err) { + console.warn('[Account] Rust logout failed:', err); + setError(t('clearData.failedLogout')); + } + }; + + const handleLogoutAndClearData = async () => { + try { + setIsLoading(true); + setError(null); + const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; + await clearAllAppData({ clearSession, userId: currentUserId }); // restarts the app + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message || t('clearData.failed')); + } finally { + setIsLoading(false); + } + }; + + const arrowOutIcon = ( + + + + ); + + return ( +
+ setShowLogoutAndClearModal(true)} + testId="settings-nav-logout-and-clear" + dangerous + isFirst + /> + + + {showLogoutAndClearModal && ( +
+
+
+
+ + + +
+
+

+ {t('clearData.title')} +

+
+
+ +
+
+

{t('clearData.warning')}

+
    +
  • {t('clearData.bulletSettings')}
  • +
  • {t('clearData.bulletCache')}
  • +
  • {t('clearData.bulletWorkspace')}
  • +
  • {t('clearData.bulletOther')}
  • +
+

{t('clearData.irreversible')}

+
+ + {error && ( +
+

{error}

+
+ )} +
+ +
+ + +
+
+
+ )} +
+ ); +}; + +export default LogoutAndClearActions; diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 1a83a58b14..5ca0574dd5 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -1,9 +1,7 @@ -import { ReactNode, useState } from 'react'; +import { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { useCoreState } from '../../providers/CoreStateProvider'; -import { clearAllAppData } from '../../utils/clearAllAppData'; import { BILLING_DASHBOARD_URL } from '../../utils/links'; import { openUrl } from '../../utils/openUrl'; import LanguageSelect from '../LanguageSelect'; @@ -29,34 +27,7 @@ interface SettingsItem { const SettingsHome = () => { const navigate = useNavigate(); const { navigateToSettings } = useSettingsNavigation(); - const { clearSession, snapshot } = useCoreState(); const { t } = useT(); - const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const handleLogout = async () => { - try { - await clearSession(); - } catch (err) { - console.warn('[Settings] Rust logout failed:', err); - setError(t('clearData.failedLogout')); - } - }; - - const handleLogoutAndClearData = async () => { - try { - setIsLoading(true); - setError(null); - const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; - await clearAllAppData({ clearSession, userId: currentUserId }); // restarts the app - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message || t('clearData.failed')); - } finally { - setIsLoading(false); - } - }; const settingsSections: SettingsSection[] = [ { @@ -225,43 +196,8 @@ const SettingsHome = () => { }, ]; - // Destructive actions — rendered separately under "Danger Zone" heading - const destructiveItems: SettingsItem[] = [ - { - id: 'logout-and-clear', - title: t('settings.clearAppData'), - description: t('settings.clearAppDataDesc'), - icon: ( - - - - ), - onClick: () => setShowLogoutAndClearModal(true), - dangerous: true, - }, - { - id: 'logout', - title: t('settings.logOut'), - description: t('settings.logOutDesc'), - icon: ( - - - - ), - onClick: handleLogout, - dangerous: true, - }, - ]; + // Log Out and Clear App Data now live on the Account page (Settings → Account) + // alongside the recovery phrase, team, privacy, and migration entries. return (
@@ -270,10 +206,10 @@ const SettingsHome = () => {
- {/* Flat list — group titles removed for clarity. Regular items first, - destructive items appended at the end. */} + {/* Flat list — group titles removed for clarity. Destructive + actions (Log Out, Clear App Data) now live on the Account page. */} {(() => { - const flatItems = settingsSections.flatMap(s => s.items).concat(destructiveItems); + const flatItems = settingsSections.flatMap(s => s.items); return flatItems.map((item, index) => ( { )); })()}
- - {/* Log Out & Clear Data Confirmation Modal */} - {showLogoutAndClearModal && ( -
-
-
-
- - - -
-
-

- {t('clearData.title')} -

-
-
- -
-
-

{t('clearData.warning')}

-
    -
  • {t('clearData.bulletSettings')}
  • -
  • {t('clearData.bulletCache')}
  • -
  • {t('clearData.bulletWorkspace')}
  • -
  • {t('clearData.bulletOther')}
  • -
-

{t('clearData.irreversible')}

-
- - {error && ( -
-

{error}

-
- )} -
- -
- - -
-
-
- )}
); }; diff --git a/app/src/components/settings/SettingsSectionPage.tsx b/app/src/components/settings/SettingsSectionPage.tsx index bc3e9601d4..7b71c60d68 100644 --- a/app/src/components/settings/SettingsSectionPage.tsx +++ b/app/src/components/settings/SettingsSectionPage.tsx @@ -16,9 +16,11 @@ interface SettingsSectionPageProps { title: string; description?: string; items: SettingsSectionItem[]; + /** Optional content rendered below the items list (e.g. destructive actions). */ + footer?: ReactNode; } -const SettingsSectionPage = ({ title, description, items }: SettingsSectionPageProps) => { +const SettingsSectionPage = ({ title, description, items, footer }: SettingsSectionPageProps) => { const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); return ( @@ -51,6 +53,8 @@ const SettingsSectionPage = ({ title, description, items }: SettingsSectionPageP /> ))}
+ + {footer}
); diff --git a/app/src/components/settings/__tests__/LogoutAndClearActions.test.tsx b/app/src/components/settings/__tests__/LogoutAndClearActions.test.tsx new file mode 100644 index 0000000000..519ab1c678 --- /dev/null +++ b/app/src/components/settings/__tests__/LogoutAndClearActions.test.tsx @@ -0,0 +1,98 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import localeReducer from '../../../store/localeSlice'; +import LogoutAndClearActions from '../LogoutAndClearActions'; + +function makeTestStore() { + return configureStore({ + reducer: { locale: localeReducer }, + preloadedState: { locale: { current: 'en' as const } }, + }); +} + +vi.mock('../../../providers/CoreStateProvider', () => ({ + useCoreState: () => ({ + clearSession: vi.fn().mockResolvedValue(undefined), + snapshot: { auth: { userId: null }, currentUser: null }, + }), +})); + +const { mockClearAllAppData } = vi.hoisted(() => ({ + mockClearAllAppData: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../../utils/clearAllAppData', () => ({ + clearAllAppData: (...args: unknown[]) => mockClearAllAppData(...args), +})); + +function renderActions() { + return render( + + + + + + ); +} + +describe('LogoutAndClearActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockClearAllAppData.mockReset().mockResolvedValue(undefined); + }); + + it('renders the destructive actions row', () => { + renderActions(); + expect(screen.getByText('Clear App Data')).toBeInTheDocument(); + expect(screen.getByText('Log out')).toBeInTheDocument(); + }); + + it('passes the current snapshot user id + clearSession to clearAllAppData', async () => { + const user = userEvent.setup(); + renderActions(); + + await user.click(screen.getByText('Clear App Data').closest('button')!); + // Confirm in the modal — the last button matching the label is the modal confirm. + const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); + await user.click(confirmButtons[confirmButtons.length - 1]); + + expect(mockClearAllAppData).toHaveBeenCalledTimes(1); + const args = mockClearAllAppData.mock.calls[0][0]; + expect(args).toMatchObject({ userId: null }); + expect(typeof args.clearSession).toBe('function'); + }); + + it('surfaces the core error message when clearAllAppData fails (Windows file-lock guidance)', async () => { + const user = userEvent.setup(); + mockClearAllAppData.mockRejectedValueOnce( + new Error( + 'Failed to remove C:\\Users\\me\\.openhuman because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again.' + ) + ); + renderActions(); + + await user.click(screen.getByText('Clear App Data').closest('button')!); + const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); + await user.click(confirmButtons[confirmButtons.length - 1]); + + expect( + await screen.findByText(/locked by another OpenHuman window or process/) + ).toBeInTheDocument(); + }); + + it('falls back to the translated message when the error has no message', async () => { + const user = userEvent.setup(); + mockClearAllAppData.mockRejectedValueOnce(new Error('')); + renderActions(); + + await user.click(screen.getByText('Clear App Data').closest('button')!); + const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); + await user.click(confirmButtons[confirmButtons.length - 1]); + + expect(await screen.findByText(/Failed to clear data and logout/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index 1823d11f3d..5cae6cf874 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -52,13 +52,6 @@ vi.mock('../../../utils/tauriCommands', () => ({ scheduleCefProfilePurge: vi.fn().mockResolvedValue(undefined), })); -const { mockClearAllAppData } = vi.hoisted(() => ({ - mockClearAllAppData: vi.fn().mockResolvedValue(undefined), -})); -vi.mock('../../../utils/clearAllAppData', () => ({ - clearAllAppData: (...args: unknown[]) => mockClearAllAppData(...args), -})); - vi.mock('../../walkthrough/AppWalkthrough', () => ({ resetWalkthrough: vi.fn() })); // --- helpers --- @@ -105,12 +98,17 @@ describe('SettingsHome', () => { expect(screen.getByText('Notifications')).toBeInTheDocument(); expect(screen.getByText('Billing & Usage')).toBeInTheDocument(); expect(screen.getByText('Advanced')).toBeInTheDocument(); - expect(screen.getByText('Clear App Data')).toBeInTheDocument(); - expect(screen.getByText('Log out')).toBeInTheDocument(); expect(screen.getByTestId('settings-nav-account')).toBeInTheDocument(); expect(screen.getByTestId('settings-nav-notifications')).toBeInTheDocument(); }); + it('no longer renders destructive actions on the home screen', () => { + // Clear App Data + Log out moved to Settings → Account. + renderSettingsHome(); + expect(screen.queryByText('Clear App Data')).not.toBeInTheDocument(); + expect(screen.queryByText('Log out')).not.toBeInTheDocument(); + }); + it('localizes Appearance and Mascot menu items', () => { renderSettingsHome({ locale: 'zh-CN', withI18n: true }); @@ -181,55 +179,6 @@ describe('SettingsHome', () => { }); }); - describe('Clear App Data flow', () => { - beforeEach(() => { - mockClearAllAppData.mockReset().mockResolvedValue(undefined); - }); - - it('passes the current snapshot user id + clearSession to clearAllAppData', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByText('Clear App Data').closest('button')!); - // Confirm in the modal - const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); - // The last one is the modal confirm button (first is the menu item we just clicked). - await user.click(confirmButtons[confirmButtons.length - 1]); - - expect(mockClearAllAppData).toHaveBeenCalledTimes(1); - const args = mockClearAllAppData.mock.calls[0][0]; - expect(args).toMatchObject({ userId: null }); - expect(typeof args.clearSession).toBe('function'); - }); - - it('surfaces the core error message when clearAllAppData fails (Windows file-lock guidance)', async () => { - const user = userEvent.setup(); - mockClearAllAppData.mockRejectedValueOnce( - new Error( - 'Failed to remove C:\\Users\\me\\.openhuman because it is locked by another OpenHuman window or process. Close all OpenHuman windows and try again.' - ) - ); - renderSettingsHome(); - - await user.click(screen.getByText('Clear App Data').closest('button')!); - const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); - await user.click(confirmButtons[confirmButtons.length - 1]); - - expect( - await screen.findByText(/locked by another OpenHuman window or process/) - ).toBeInTheDocument(); - }); - - it('falls back to the translated message when the error has no message', async () => { - const user = userEvent.setup(); - mockClearAllAppData.mockRejectedValueOnce(new Error('')); - renderSettingsHome(); - - await user.click(screen.getByText('Clear App Data').closest('button')!); - const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); - await user.click(confirmButtons[confirmButtons.length - 1]); - - expect(await screen.findByText(/Failed to clear data and logout/)).toBeInTheDocument(); - }); - }); + // Clear App Data flow moved to LogoutAndClearActions (rendered on Account + // page) — see LogoutAndClearActions.test.tsx. }); diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 6edee81595..8f0d3e6565 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -5,7 +5,6 @@ export type SettingsRoute = | 'home' | 'account' | 'features' - | 'connections' | 'messaging' | 'cron-jobs' | 'screen-intelligence' @@ -84,7 +83,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/team')) return 'team'; if (path.includes('/settings/account')) return 'account'; if (path.includes('/settings/features')) return 'features'; - if (path.includes('/settings/connections')) return 'connections'; if (path.includes('/settings/messaging')) return 'messaging'; if (path.includes('/settings/cron-jobs')) return 'cron-jobs'; if (path.includes('/settings/screen-awareness-debug')) return 'screen-awareness-debug'; @@ -186,7 +184,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { // Leaf panels under account case 'recovery-phrase': case 'team': - case 'connections': case 'privacy': return [settingsCrumb, accountCrumb]; diff --git a/app/src/components/settings/panels/ConnectionsPanel.tsx b/app/src/components/settings/panels/ConnectionsPanel.tsx deleted file mode 100644 index cb652d30c6..0000000000 --- a/app/src/components/settings/panels/ConnectionsPanel.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { type ReactElement, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import BinanceIcon from '../../../assets/icons/binance.svg'; -import GoogleIcon from '../../../assets/icons/GoogleIcon'; -import MetamaskIcon from '../../../assets/icons/metamask.svg'; -import NotionIcon from '../../../assets/icons/notion.svg'; -import { useT } from '../../../lib/i18n/I18nContext'; -import { fetchWalletStatus, type WalletStatus } from '../../../services/walletApi'; -import SettingsHeader from '../components/SettingsHeader'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; - -interface ConnectOption { - id: string; - name: string; - description: string; - icon: ReactElement; - comingSoon?: boolean; - statusLabel?: string; - skillId?: string; -} - -function ConnectionOptionRow({ - option, - isFirst, - isLast, - onConnect, - t, -}: { - option: ConnectOption; - isFirst: boolean; - isLast: boolean; - onConnect: (option: ConnectOption) => void; - t: (key: string) => string; -}) { - const isDisabled = option.comingSoon; - - const badge = option.comingSoon ? ( - - {t('connections.comingSoon')} - - ) : option.statusLabel ? ( - - {option.statusLabel} - - ) : ( - - {t('connections.setUp')} - - ); - - return ( - - ); -} - -const ConnectionsPanel = () => { - const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); - const navigate = useNavigate(); - const [walletStatus, setWalletStatus] = useState(null); - const [walletStatusState, setWalletStatusState] = useState<'loading' | 'ready' | 'error'>( - 'loading' - ); - - useEffect(() => { - let active = true; - fetchWalletStatus() - .then(status => { - if (active) { - setWalletStatus(status); - setWalletStatusState('ready'); - } - }) - .catch(() => { - if (active) { - setWalletStatusState('error'); - } - }); - return () => { - active = false; - }; - }, []); - - const walletReady = walletStatusState === 'ready'; - const walletConfigured = walletReady && walletStatus?.configured === true; - - const connectOptions: ConnectOption[] = [ - { - id: 'google', - name: 'Google', - description: 'Manage emails, contacts and calendar events', - icon: , - comingSoon: true, - }, - { - id: 'notion', - name: 'Notion', - description: 'Manage tasks, documents and everything else in your Notion', - icon: Notion, - comingSoon: true, - }, - { - id: 'wallet', - name: 'Web3 Wallet', - description: walletConfigured - ? t('connections.walletConfigured') - : walletReady - ? t('connections.walletReady') - : walletStatusState === 'error' - ? t('connections.walletError') - : t('connections.walletChecking'), - icon: Metamask, - statusLabel: walletConfigured - ? t('connections.configured') - : walletReady - ? undefined - : walletStatusState === 'error' - ? t('connections.unavailable') - : t('connections.checking'), - }, - { - id: 'exchange', - name: 'Crypto Trading Exchanges', - description: 'Connect and make trades with deep insights.', - icon: Binance, - comingSoon: true, - }, - ]; - - const handleConnect = (option: ConnectOption) => { - if (option.comingSoon) return; - if (option.id === 'wallet') { - navigate('/settings/recovery-phrase'); - return; - } - if (option.skillId) return; - }; - - return ( -
- - -
-
-
- {connectOptions.map((option, index) => ( - - ))} -
- - {walletConfigured && walletStatus ? ( -
-
-

- {t('connections.walletIdentities')} -

-

- {t('connections.walletDerived')} -

-
-
- {walletStatus.accounts.map(account => ( -
-
- - {account.chain} - - - {account.address} - -
-
- ))} -
-
- ) : null} - -
-
- - - -
-

- {t('connections.privacySecurity')} -

-

- {t('connections.privacySecurityDesc')} -

-
-
-
-
-
-
- ); -}; - -export default ConnectionsPanel; diff --git a/app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx b/app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx deleted file mode 100644 index e8bd4324ec..0000000000 --- a/app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../../../test/test-utils'; -import ConnectionsPanel from '../ConnectionsPanel'; - -const navigateMock = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { ...actual, useNavigate: () => navigateMock }; -}); - -const fetchWalletStatusMock = vi.fn(); - -vi.mock('../../../../services/walletApi', () => ({ - fetchWalletStatus: () => fetchWalletStatusMock(), -})); - -const sampleConfigured = { - configured: true, - onboardingCompleted: true, - consentGranted: true, - secretStored: true, - source: 'generated' as const, - mnemonicWordCount: 12, - accounts: [ - { chain: 'evm', address: '0xabc', derivationPath: "m/44'/60'/0'/0/0" }, - { chain: 'btc', address: 'bc1q', derivationPath: "m/44'/0'/0'/0/0" }, - { chain: 'solana', address: 'So1', derivationPath: "m/44'/501'/0'/0'" }, - { chain: 'tron', address: 'TR0', derivationPath: "m/44'/195'/0'/0/0" }, - ], - updatedAtMs: 1234567890, -}; - -const sampleUnconfigured = { - configured: false, - onboardingCompleted: false, - consentGranted: false, - secretStored: false, - source: null, - mnemonicWordCount: null, - accounts: [], - updatedAtMs: null, -}; - -describe('ConnectionsPanel — trust-surface polish', () => { - it('shows "Coming soon" badge on the three not-yet-shipped options (Web3 Wallet is now wired)', async () => { - fetchWalletStatusMock.mockResolvedValueOnce(sampleUnconfigured); - renderWithProviders(); - await waitFor(() => expect(fetchWalletStatusMock).toHaveBeenCalled()); - expect(screen.getAllByText(/Coming soon/i)).toHaveLength(3); - }); -}); - -describe('ConnectionsPanel — wallet status branches', () => { - it('renders a "Checking…" badge while wallet status is loading', () => { - let resolve: ((value: typeof sampleUnconfigured) => void) | undefined; - fetchWalletStatusMock.mockImplementationOnce( - () => - new Promise(r => { - resolve = r; - }) - ); - renderWithProviders(); - expect(screen.getByText(/Checking…/i)).toBeTruthy(); - expect(screen.getByText(/Checking wallet status/i)).toBeTruthy(); - resolve?.(sampleUnconfigured); - }); - - it('renders the Configured badge and wallet identities when status reports configured', async () => { - fetchWalletStatusMock.mockResolvedValueOnce(sampleConfigured); - renderWithProviders(); - await waitFor(() => expect(screen.getByText('Configured')).toBeTruthy()); - expect(screen.getByText('Wallet identities')).toBeTruthy(); - expect(screen.getByText('evm')).toBeTruthy(); - expect(screen.getByText('btc')).toBeTruthy(); - expect(screen.getByText('solana')).toBeTruthy(); - expect(screen.getByText('tron')).toBeTruthy(); - expect( - screen.getByText(/Local EVM, BTC, Solana, and Tron identities are configured/i) - ).toBeTruthy(); - }); - - it('renders the Set up CTA when status reports unconfigured', async () => { - fetchWalletStatusMock.mockResolvedValueOnce(sampleUnconfigured); - renderWithProviders(); - await waitFor(() => expect(screen.getByText('Set up')).toBeTruthy()); - expect(screen.getByText(/Set up local EVM, BTC, Solana, and Tron identities/i)).toBeTruthy(); - }); - - it('renders the Unavailable badge when fetchWalletStatus rejects', async () => { - fetchWalletStatusMock.mockRejectedValueOnce(new Error('network down')); - renderWithProviders(); - await waitFor(() => expect(screen.getByText(/Unavailable/i)).toBeTruthy()); - expect(screen.getByText(/Could not check wallet status/i)).toBeTruthy(); - }); - - it('navigates to the recovery-phrase panel when the wallet row is clicked', async () => { - fetchWalletStatusMock.mockResolvedValueOnce(sampleUnconfigured); - navigateMock.mockReset(); - renderWithProviders(); - await waitFor(() => expect(screen.getByText('Set up')).toBeTruthy()); - fireEvent.click(screen.getByRole('button', { name: /Web3 Wallet/i })); - expect(navigateMock).toHaveBeenCalledWith('/settings/recovery-phrase'); - }); -}); diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 6e7562ce7b..ce272828f9 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -12,7 +12,6 @@ import BillingPanel from '../components/settings/panels/BillingPanel'; import CompanionPanel from '../components/settings/panels/CompanionPanel'; import ComposioPanel from '../components/settings/panels/ComposioPanel'; import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel'; -import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel'; import CronJobsPanel from '../components/settings/panels/CronJobsPanel'; import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel'; import DevicesPanel from '../components/settings/panels/DevicesPanel'; @@ -37,6 +36,7 @@ import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel'; import VoicePanel from '../components/settings/panels/VoicePanel'; import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel'; import SettingsHome from '../components/settings/SettingsHome'; +import LogoutAndClearActions from '../components/settings/LogoutAndClearActions'; import SettingsSectionPage from '../components/settings/SettingsSectionPage'; import { useT } from '../lib/i18n/I18nContext'; import { APP_VERSION } from '../utils/config'; @@ -64,16 +64,6 @@ const TeamIcon = ( /> ); -const ConnectionsIcon = ( - - - -); const PrivacyIcon = ( { route: 'team', icon: TeamIcon, }, - { - id: 'connections', - title: t('pages.settings.account.connections'), - description: t('pages.settings.account.connectionsDesc'), - route: 'connections', - icon: ConnectionsIcon, - }, { id: 'privacy', title: t('pages.settings.account.privacy'), @@ -300,6 +283,7 @@ const Settings = () => { title={t('pages.settings.accountSection.title')} description={t('pages.settings.accountSection.description')} items={accountSettingsItems} + footer={} /> )} /> @@ -337,7 +321,6 @@ const Settings = () => { /> )} /> )} /> - )} /> {/* BillingPanel intentionally uses its own wider layout. */} } /> )} /> diff --git a/app/src/pages/onboarding/customWizardSteps.ts b/app/src/pages/onboarding/customWizardSteps.ts index 9d3b57e863..6f316da3d9 100644 --- a/app/src/pages/onboarding/customWizardSteps.ts +++ b/app/src/pages/onboarding/customWizardSteps.ts @@ -24,7 +24,7 @@ export const CUSTOM_WIZARD_ROUTES: Record = { export const CUSTOM_WIZARD_SETTINGS_ROUTES: Record = { inference: '/settings/llm', voice: '/settings/voice', - oauth: '/settings/connections', + oauth: '/settings/composio-routing', search: '/settings/tools', memory: '/settings/memory-data', }; From 8b0cc3d312a52b5af3bb6b9cf7bf927456cee6ca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 09:42:58 -0700 Subject: [PATCH 07/10] chore: apply auto-fixes --- app/src-tauri/src/loopback_oauth.rs | 3 +-- app/src/components/settings/LogoutAndClearActions.tsx | 1 - .../components/settings/panels/NotificationsTabbedPanel.tsx | 6 +----- app/src/pages/Settings.tsx | 2 +- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app/src-tauri/src/loopback_oauth.rs b/app/src-tauri/src/loopback_oauth.rs index e3a56d57c6..c4b9d01ef2 100644 --- a/app/src-tauri/src/loopback_oauth.rs +++ b/app/src-tauri/src/loopback_oauth.rs @@ -120,8 +120,7 @@ fn bind_loopback(port: u16) -> Result { let sock_addr: std::net::SocketAddr = format!("127.0.0.1:{port}") .parse() .map_err(|err| format!("parse 127.0.0.1:{port} failed: {err}"))?; - let socket = - TcpSocket::new_v4().map_err(|err| format!("TcpSocket::new_v4 failed: {err}"))?; + let socket = TcpSocket::new_v4().map_err(|err| format!("TcpSocket::new_v4 failed: {err}"))?; socket .set_reuseaddr(true) .map_err(|err| format!("set_reuseaddr failed: {err}"))?; diff --git a/app/src/components/settings/LogoutAndClearActions.tsx b/app/src/components/settings/LogoutAndClearActions.tsx index d80b623368..e9d89bc549 100644 --- a/app/src/components/settings/LogoutAndClearActions.tsx +++ b/app/src/components/settings/LogoutAndClearActions.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { useCoreState } from '../../providers/CoreStateProvider'; import { clearAllAppData } from '../../utils/clearAllAppData'; - import SettingsMenuItem from './components/SettingsMenuItem'; /** diff --git a/app/src/components/settings/panels/NotificationsTabbedPanel.tsx b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx index d6e590b294..dc74484928 100644 --- a/app/src/components/settings/panels/NotificationsTabbedPanel.tsx +++ b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx @@ -4,16 +4,12 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useT } from '../../../lib/i18n/I18nContext'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; - import NotificationRoutingPanel from './NotificationRoutingPanel'; import NotificationsPanel from './NotificationsPanel'; type TabId = 'preferences' | 'routing'; -const TAB_HASH: Record = { - preferences: '', - routing: '#routing', -}; +const TAB_HASH: Record = { preferences: '', routing: '#routing' }; const hashToTab = (hash: string): TabId => (hash === '#routing' ? 'routing' : 'preferences'); diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index ce272828f9..f178b79eb8 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import LogoutAndClearActions from '../components/settings/LogoutAndClearActions'; import AboutPanel from '../components/settings/panels/AboutPanel'; import AgentChatPanel from '../components/settings/panels/AgentChatPanel'; import AIPanel from '../components/settings/panels/AIPanel'; @@ -36,7 +37,6 @@ import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel'; import VoicePanel from '../components/settings/panels/VoicePanel'; import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel'; import SettingsHome from '../components/settings/SettingsHome'; -import LogoutAndClearActions from '../components/settings/LogoutAndClearActions'; import SettingsSectionPage from '../components/settings/SettingsSectionPage'; import { useT } from '../lib/i18n/I18nContext'; import { APP_VERSION } from '../utils/config'; From 62604c50d6774d0bd876520c62cb0abd884d2ea6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 09:51:51 -0700 Subject: [PATCH 08/10] chore(i18n): seed settings.notifications.tabs keys in all locale chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the two new tab labels (`settings.notifications.tabs.preferences` and `.routing`) to every locale's `*-1.ts` chunk so the i18n coverage gate stops failing on `missing[head]`. Values are English placeholders for non-English locales — they'll show up as untranslated (which is a non-blocking warning) until human translations land. --- app/src/lib/i18n/chunks/ar-1.ts | 2 ++ app/src/lib/i18n/chunks/bn-1.ts | 2 ++ app/src/lib/i18n/chunks/de-1.ts | 2 ++ app/src/lib/i18n/chunks/en-1.ts | 2 ++ app/src/lib/i18n/chunks/es-1.ts | 2 ++ app/src/lib/i18n/chunks/fr-1.ts | 2 ++ app/src/lib/i18n/chunks/hi-1.ts | 2 ++ app/src/lib/i18n/chunks/id-1.ts | 2 ++ app/src/lib/i18n/chunks/it-1.ts | 2 ++ app/src/lib/i18n/chunks/ko-1.ts | 2 ++ app/src/lib/i18n/chunks/pt-1.ts | 2 ++ app/src/lib/i18n/chunks/ru-1.ts | 2 ++ app/src/lib/i18n/chunks/zh-CN-1.ts | 2 ++ 13 files changed, 26 insertions(+) diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index 1ae1e85ef2..82f1d1e3bb 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -60,6 +60,8 @@ const ar1: TranslationMap = { 'settings.accountDesc': 'عبارة الاسترداد والفريق والاتصالات والخصوصية', 'settings.notifications': 'الإشعارات', 'settings.notificationsDesc': 'عدم الإزعاج وضوابط الإشعارات لكل حساب', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'الميزات', 'settings.featuresDesc': 'وعي الشاشة والمراسلة والأدوات', 'settings.aiModels': 'الذكاء الاصطناعي والنماذج', diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index 5d5fd35496..4056cf0341 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -60,6 +60,8 @@ const bn1: TranslationMap = { 'settings.accountDesc': 'রিকভারি ফ্রেজ, টিম, সংযোগ ও গোপনীয়তা', 'settings.notifications': 'বিজ্ঞপ্তি', 'settings.notificationsDesc': 'ডু নট ডিস্টার্ব এবং প্রতিটি অ্যাকাউন্টের বিজ্ঞপ্তি নিয়ন্ত্রণ', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'ফিচার', 'settings.featuresDesc': 'স্ক্রিন সচেতনতা, মেসেজিং এবং টুলস', 'settings.aiModels': 'AI ও মডেল', diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index 1705e57e57..67647a9f49 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -60,6 +60,8 @@ const de1: TranslationMap = { 'settings.accountDesc': 'Wiederherstellungsphrase, Team, Verbindungen und Privatsphäre', 'settings.notifications': 'Benachrichtigungen', 'settings.notificationsDesc': '„Bitte nicht stören“ und Benachrichtigungskontrollen pro Konto', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Funktionen', 'settings.featuresDesc': 'Bildschirmbewusstsein, Nachrichten und Tools', 'settings.aiModels': 'KI & Modelle', diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index a6bbc06e37..18f4de76ea 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -60,6 +60,8 @@ const en1: TranslationMap = { 'settings.accountDesc': 'Recovery phrase, team, connections, and privacy', 'settings.notifications': 'Notifications', 'settings.notificationsDesc': 'Do Not Disturb and per-account notification controls', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Features', 'settings.featuresDesc': 'Screen awareness, messaging, and tools', 'settings.aiModels': 'AI & Models', diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index 5f506780ee..512d10aa8a 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -60,6 +60,8 @@ const es1: TranslationMap = { 'settings.accountDesc': 'Frase de recuperación, equipo, conexiones y privacidad', 'settings.notifications': 'Notificaciones', 'settings.notificationsDesc': 'No molestar y controles de notificación por cuenta', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Funciones', 'settings.featuresDesc': 'Conciencia de pantalla, mensajería y herramientas', 'settings.aiModels': 'IA y modelos', diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index c974a177bc..2bc3c1374a 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -60,6 +60,8 @@ const fr1: TranslationMap = { 'settings.accountDesc': 'Phrase de récupération, équipe, connexions et confidentialité', 'settings.notifications': 'Notifications', 'settings.notificationsDesc': 'Ne pas déranger et contrôles de notifications par compte', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Fonctionnalités', 'settings.featuresDesc': "Surveillance de l'écran, messagerie et outils", 'settings.aiModels': 'IA & Modèles', diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index f3548d4393..896fca1e56 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -60,6 +60,8 @@ const hi1: TranslationMap = { 'settings.accountDesc': 'रिकवरी फ्रेज़, टीम, कनेक्शन और प्राइवेसी', 'settings.notifications': 'नोटिफिकेशन', 'settings.notificationsDesc': 'डू नॉट डिस्टर्ब और हर अकाउंट के नोटिफिकेशन कंट्रोल', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'फीचर्स', 'settings.featuresDesc': 'स्क्रीन अवेयरनेस, मैसेजिंग और टूल्स', 'settings.aiModels': 'AI और मॉडल्स', diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index 5fff31114f..e4ce950383 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -60,6 +60,8 @@ const id1: TranslationMap = { 'settings.accountDesc': 'Frasa pemulihan, tim, koneksi, dan privasi', 'settings.notifications': 'Notifikasi', 'settings.notificationsDesc': 'Jangan Ganggu dan kontrol notifikasi per akun', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Fitur', 'settings.featuresDesc': 'Kesadaran layar, pesan, dan alat', 'settings.aiModels': 'AI & Model', diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index 647671f5cf..4b915d894e 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -60,6 +60,8 @@ const it1: TranslationMap = { 'settings.accountDesc': 'Frase di recupero, team, connessioni e privacy', 'settings.notifications': 'Notifiche', 'settings.notificationsDesc': 'Non disturbare e controlli notifiche per account', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Funzionalità', 'settings.featuresDesc': 'Consapevolezza schermo, messaggistica e strumenti', 'settings.aiModels': 'AI e modelli', diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index 041374bc52..ca7d001b26 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -60,6 +60,8 @@ const ko1: TranslationMap = { 'settings.accountDesc': '복구 문구, 팀, 연결 및 개인정보', 'settings.notifications': '알림', 'settings.notificationsDesc': '방해 금지 및 계정별 알림 설정', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': '기능', 'settings.featuresDesc': '화면 인식, 메시징 및 도구', 'settings.aiModels': 'AI 및 모델', diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index ac596e043a..df3d3959bd 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -60,6 +60,8 @@ const pt1: TranslationMap = { 'settings.accountDesc': 'Frase de recuperação, equipe, conexões e privacidade', 'settings.notifications': 'Notificações', 'settings.notificationsDesc': 'Não Perturbe e controles de notificação por conta', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Recursos', 'settings.featuresDesc': 'Reconhecimento de tela, mensagens e ferramentas', 'settings.aiModels': 'IA e Modelos', diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index d5f1bec46a..09f674d470 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -61,6 +61,8 @@ const ru1: TranslationMap = { 'settings.notifications': 'Уведомления', 'settings.notificationsDesc': 'Режим «Не беспокоить» и настройки уведомлений для каждого аккаунта', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': 'Функции', 'settings.featuresDesc': 'Слежение за экраном, мессенджеры и инструменты', 'settings.aiModels': 'AI и модели', diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 9b672d45a1..161275b6c4 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -60,6 +60,8 @@ const zhCN1: TranslationMap = { 'settings.accountDesc': '恢复短语、团队、连接与隐私', 'settings.notifications': '通知', 'settings.notificationsDesc': '免打扰模式与各账户通知控制', + 'settings.notifications.tabs.preferences': 'Preferences', + 'settings.notifications.tabs.routing': 'Routing', 'settings.features': '功能', 'settings.featuresDesc': '屏幕感知、消息与工具', 'settings.aiModels': 'AI 与模型', From 82261a88723cddcacbc07ec054a0c87b15590686 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 09:59:41 -0700 Subject: [PATCH 09/10] fix(settings): address CodeRabbit review on PR #2550 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split loopback OAuth into its own `allow-loopback-oauth` permission TOML so consumers of the broad `allow-core-process` capability don't inherit control of the OAuth listener. Add the new permission to the default desktop capability and drop the entries from `allow-core-process`. - LogoutAndClearActions: replace raw console.warn with a namespaced `debug('settings:account:warn')` logger, sanitize the error before logging (message only, no stack / raw payload), and tag the confirm modal with `role="dialog"`, `aria-modal`, and `aria-labelledby`. - NotificationsTabbedPanel: drop the useState + useEffect mirror and derive `tab` directly from `location.hash`. The router is already the source of truth — the local state was just churn. --- app/src-tauri/capabilities/default.json | 3 ++- .../permissions/allow-core-process.toml | 10 --------- .../permissions/allow-loopback-oauth.toml | 12 +++++++++++ .../settings/LogoutAndClearActions.tsx | 21 +++++++++++++++---- .../panels/NotificationsTabbedPanel.tsx | 11 +++------- 5 files changed, 34 insertions(+), 23 deletions(-) create mode 100644 app/src-tauri/permissions/allow-loopback-oauth.toml diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index ec1b786af1..c19e0aa3b1 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -31,6 +31,7 @@ "updater:default", "allow-core-process", "allow-workspace-files", - "allow-app-update" + "allow-app-update", + "allow-loopback-oauth" ] } diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index d424df61f4..823f46f6f9 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -125,16 +125,6 @@ allow = [ # CEF / PROFILE # ========================= "schedule_cef_profile_purge", - - # ========================= - # LOOPBACK OAUTH (RFC 8252) - # ========================= - # One-shot http://127.0.0.1:/auth listener that receives the OAuth - # callback in lieu of the openhuman:// deep link (#2511). Without these - # allow entries the invoke is rejected with "Command not found" and every - # login transparently falls back to the deep-link path. - "start_loopback_oauth_listener", - "stop_loopback_oauth_listener", ] deny = [] diff --git a/app/src-tauri/permissions/allow-loopback-oauth.toml b/app/src-tauri/permissions/allow-loopback-oauth.toml new file mode 100644 index 0000000000..6a69a4fd8d --- /dev/null +++ b/app/src-tauri/permissions/allow-loopback-oauth.toml @@ -0,0 +1,12 @@ +[[permission]] +identifier = "allow-loopback-oauth" +description = "Permission to start / stop the one-shot http://127.0.0.1:/auth listener used as the RFC 8252 OAuth callback target (see #2511). Narrow on purpose so consumers of the broader `allow-core-process` group do not inherit OAuth listener control." + +[permission.commands] + +allow = [ + "start_loopback_oauth_listener", + "stop_loopback_oauth_listener", +] + +deny = [] diff --git a/app/src/components/settings/LogoutAndClearActions.tsx b/app/src/components/settings/LogoutAndClearActions.tsx index e9d89bc549..301386b935 100644 --- a/app/src/components/settings/LogoutAndClearActions.tsx +++ b/app/src/components/settings/LogoutAndClearActions.tsx @@ -1,10 +1,13 @@ -import { useState } from 'react'; +import debug from 'debug'; +import { useId, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { useCoreState } from '../../providers/CoreStateProvider'; import { clearAllAppData } from '../../utils/clearAllAppData'; import SettingsMenuItem from './components/SettingsMenuItem'; +const warnLog = debug('settings:account:warn'); + /** * Destructive account actions: Log out, and Log out + clear all app data. * Lives at the bottom of the Settings → Account page. Owns its own modal @@ -16,12 +19,16 @@ const LogoutAndClearActions = () => { const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const modalTitleId = useId(); const handleLogout = async () => { try { await clearSession(); } catch (err) { - console.warn('[Account] Rust logout failed:', err); + // Log only the message — `err` may carry stack frames / serialized + // backend payloads we don't want in renderer console. + const reason = err instanceof Error ? err.message : String(err); + warnLog('logout_failed %o', { reason }); setError(t('clearData.failedLogout')); } }; @@ -74,7 +81,11 @@ const LogoutAndClearActions = () => { {showLogoutAndClearModal && (
-
+
{
-

+

{t('clearData.title')}

diff --git a/app/src/components/settings/panels/NotificationsTabbedPanel.tsx b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx index dc74484928..9c64590504 100644 --- a/app/src/components/settings/panels/NotificationsTabbedPanel.tsx +++ b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -25,15 +24,11 @@ const NotificationsTabbedPanel = () => { const { navigateBack, breadcrumbs } = useSettingsNavigation(); const location = useLocation(); const navigate = useNavigate(); - const [tab, setTab] = useState(() => hashToTab(location.hash)); - - // Keep state in sync if the user navigates with the hash directly. - useEffect(() => { - setTab(hashToTab(location.hash)); - }, [location.hash]); + // The router is the single source of truth for the active tab — hash is the + // only signal needed, so derive directly instead of mirroring it in state. + const tab: TabId = hashToTab(location.hash); const selectTab = (next: TabId) => { - setTab(next); navigate(`${location.pathname}${TAB_HASH[next]}`, { replace: true }); }; From bb42e732ec718695641daf0b70c543561d6281a2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 23 May 2026 10:12:14 -0700 Subject: [PATCH 10/10] fix(settings): surface logout failures + tighten test harness Round-2 CodeRabbit fixes on PR #2550: - Render an inline `role="alert"` banner below the Log out row when `handleLogout` fails. Previously `setError` was only displayed inside the clear-data modal, so a failed logout was silent unless the user happened to open that modal afterwards. - Add a failure-path test covering the inline error path. - Migrate LogoutAndClearActions tests to the shared `renderWithProviders` helper from `app/src/test/test-utils.tsx` instead of a bespoke `makeTestStore`/wrapper, matching project guidelines. - SettingsHome: use `import type { ReactNode }` since the symbol is only used as a type. --- .../settings/LogoutAndClearActions.tsx | 15 ++++++ app/src/components/settings/SettingsHome.tsx | 2 +- .../__tests__/LogoutAndClearActions.test.tsx | 46 +++++++++---------- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/app/src/components/settings/LogoutAndClearActions.tsx b/app/src/components/settings/LogoutAndClearActions.tsx index 301386b935..26dabd38a4 100644 --- a/app/src/components/settings/LogoutAndClearActions.tsx +++ b/app/src/components/settings/LogoutAndClearActions.tsx @@ -58,6 +58,12 @@ const LogoutAndClearActions = () => { ); + // Inline error is only displayed below the row when the clear-data modal is + // closed — when the modal is open, it owns the error display. Without this + // surface, a `handleLogout` failure would set `error` but the user would + // never see it. + const showInlineError = error !== null && !showLogoutAndClearModal; + return (
{ isLast /> + {showInlineError && ( +
+

{error}

+
+ )} + {showLogoutAndClearModal && (
({ + mockClearSession: vi.fn(), + mockClearAllAppData: vi.fn(), +})); vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => ({ - clearSession: vi.fn().mockResolvedValue(undefined), + clearSession: mockClearSession, snapshot: { auth: { userId: null }, currentUser: null }, }), })); -const { mockClearAllAppData } = vi.hoisted(() => ({ - mockClearAllAppData: vi.fn().mockResolvedValue(undefined), -})); vi.mock('../../../utils/clearAllAppData', () => ({ clearAllAppData: (...args: unknown[]) => mockClearAllAppData(...args), })); function renderActions() { - return render( - - - - - - ); + return renderWithProviders(, { + preloadedState: { locale: { current: 'en' } }, + }); } describe('LogoutAndClearActions', () => { beforeEach(() => { vi.clearAllMocks(); + mockClearSession.mockReset().mockResolvedValue(undefined); mockClearAllAppData.mockReset().mockResolvedValue(undefined); }); @@ -56,7 +45,6 @@ describe('LogoutAndClearActions', () => { renderActions(); await user.click(screen.getByText('Clear App Data').closest('button')!); - // Confirm in the modal — the last button matching the label is the modal confirm. const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); await user.click(confirmButtons[confirmButtons.length - 1]); @@ -95,4 +83,16 @@ describe('LogoutAndClearActions', () => { expect(await screen.findByText(/Failed to clear data and logout/)).toBeInTheDocument(); }); + + it('surfaces logout failures inline next to the Log out row', async () => { + const user = userEvent.setup(); + mockClearSession.mockRejectedValueOnce(new Error('backend unreachable')); + renderActions(); + + await user.click(screen.getByText('Log out').closest('button')!); + + const alert = await screen.findByTestId('logout-error'); + expect(alert).toHaveTextContent(/sign-in failed|failed to log out|/i); // tolerant + expect(alert).toBeVisible(); + }); });