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-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-tauri/src/loopback_oauth.rs b/app/src-tauri/src/loopback_oauth.rs index dd3769d7be..c4b9d01ef2 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,25 @@ 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 +180,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 +219,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 +233,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/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; diff --git a/app/src/components/settings/LogoutAndClearActions.tsx b/app/src/components/settings/LogoutAndClearActions.tsx new file mode 100644 index 0000000000..26dabd38a4 --- /dev/null +++ b/app/src/components/settings/LogoutAndClearActions.tsx @@ -0,0 +1,188 @@ +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 + * 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 modalTitleId = useId(); + + const handleLogout = async () => { + try { + await clearSession(); + } catch (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')); + } + }; + + 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 = ( + + + + ); + + // 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 ( +
+ setShowLogoutAndClearModal(true)} + testId="settings-nav-logout-and-clear" + dangerous + isFirst + /> + + + {showInlineError && ( +
+

{error}

+
+ )} + + {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..afcbd0b25f 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 type { 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..1cebeddbed --- /dev/null +++ b/app/src/components/settings/__tests__/LogoutAndClearActions.test.tsx @@ -0,0 +1,98 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import LogoutAndClearActions from '../LogoutAndClearActions'; + +const { mockClearSession, mockClearAllAppData } = vi.hoisted(() => ({ + mockClearSession: vi.fn(), + mockClearAllAppData: vi.fn(), +})); + +vi.mock('../../../providers/CoreStateProvider', () => ({ + useCoreState: () => ({ + clearSession: mockClearSession, + snapshot: { auth: { userId: null }, currentUser: null }, + }), +})); + +vi.mock('../../../utils/clearAllAppData', () => ({ + clearAllAppData: (...args: unknown[]) => mockClearAllAppData(...args), +})); + +function renderActions() { + return renderWithProviders(, { + preloadedState: { locale: { current: 'en' } }, + }); +} + +describe('LogoutAndClearActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockClearSession.mockReset().mockResolvedValue(undefined); + 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')!); + 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(); + }); + + 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(); + }); +}); 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/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..9c64590504 --- /dev/null +++ b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx @@ -0,0 +1,82 @@ +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(); + // 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) => { + 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/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/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 与模型', 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/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 */} diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 1af00cef2d..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'; @@ -12,7 +13,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'; @@ -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'; @@ -65,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'), @@ -301,6 +283,7 @@ const Settings = () => { title={t('pages.settings.accountSection.title')} description={t('pages.settings.accountSection.description')} items={accountSettingsItems} + footer={} /> )} /> @@ -338,7 +321,6 @@ const Settings = () => { /> )} /> )} /> - )} /> {/* BillingPanel intentionally uses its own wider layout. */} } /> )} /> @@ -348,7 +330,7 @@ const Settings = () => { )} /> )} /> )} /> - )} /> + )} /> )} /> )} /> )} /> @@ -357,9 +339,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={} /> )} /> )} /> 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', }; 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);