diff --git a/app/src/components/OpenhumanLinkModal.tsx b/app/src/components/OpenhumanLinkModal.tsx index b3255339c..d7318b90a 100644 --- a/app/src/components/OpenhumanLinkModal.tsx +++ b/app/src/components/OpenhumanLinkModal.tsx @@ -11,6 +11,7 @@ * Mounted once at AppShell root. */ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useChannelDefinitions } from '../hooks/useChannelDefinitions'; import { @@ -20,9 +21,14 @@ import { showNativeNotification, } from '../lib/nativeNotifications/tauriBridge'; import { isTauri, purgeWebviewAccount } from '../services/webviewAccountService'; -import { addAccount, removeAccount } from '../store/accountsSlice'; +import { addAccount, removeAccount, setActiveAccount } from '../store/accountsSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; -import { type Account, type AccountProvider, PROVIDERS } from '../types/accounts'; +import { + type Account, + type AccountProvider, + type AccountStatus, + PROVIDERS, +} from '../types/accounts'; import { BILLING_DASHBOARD_URL } from '../utils/links'; import { openUrl } from '../utils/openUrl'; import { ProviderIcon } from './accounts/providerIcons'; @@ -389,11 +395,34 @@ function makeAccountId(): string { return `acct-${Date.now().toString(36)}`; } +/** Status label + color for a given account lifecycle status. */ +function statusDisplay(status: AccountStatus): { label: string; dotClass: string } { + switch (status) { + case 'open': + return { label: 'Connected', dotClass: 'bg-emerald-500' }; + case 'loading': + return { label: 'Loading…', dotClass: 'bg-amber-400' }; + case 'pending': + return { label: 'Needs sign-in', dotClass: 'bg-amber-400' }; + case 'timeout': + return { label: 'Timed out', dotClass: 'bg-red-400' }; + case 'error': + return { label: 'Error', dotClass: 'bg-red-400' }; + case 'closed': + return { label: 'Closed', dotClass: 'bg-stone-300' }; + } +} + const AccountsSetupBody = ({ close }: { close: () => void }) => { const dispatch = useAppDispatch(); + const navigate = useNavigate(); const accountsById = useAppSelector(s => s.accounts.accounts); const order = useAppSelector(s => s.accounts.order); + // Track accounts added during this modal session so "Done" can navigate. + // Uses state (not ref) so the CTA label re-renders when toggles change. + const [newlyAdded, setNewlyAdded] = useState>(new Map()); + // Map provider → first existing account (one provider, one row). const accountByProvider = useMemo(() => { const map = new Map(); @@ -416,8 +445,12 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => { if (currentlyOn) { const existing = accountByProvider.get(providerId); if (!existing) return; - // Best-effort tear down the webview if one was already spun up. void purgeWebviewAccount(existing.id).catch(() => {}); + setNewlyAdded(prev => { + const next = new Map(prev); + next.delete(existing.id); + return next; + }); dispatch(removeAccount({ accountId: existing.id })); return; } @@ -428,9 +461,25 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => { createdAt: new Date().toISOString(), status: 'pending', }; + setNewlyAdded(prev => new Map(prev).set(acct.id, label)); dispatch(addAccount(acct)); }; + const handleDone = () => { + close(); + // Navigate to /chat and activate the first newly-added account so its + // WebviewHost mounts and the auth flow starts immediately. + const firstNew = [...newlyAdded.keys()][0]; + if (firstNew) { + dispatch(setActiveAccount(firstNew)); + navigate('/chat'); + } + }; + + // Dynamic CTA based on what's been toggled on + const firstNewLabel = [...newlyAdded.values()][0]; + const doneLabel = firstNewLabel ? `Continue with ${firstNewLabel} sign-in` : 'Done'; + return (

@@ -440,7 +489,9 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {

{providerDescriptors.map(p => { - const on = accountByProvider.has(p.id); + const acct = accountByProvider.get(p.id); + const on = !!acct; + const status = acct?.status; return (
void }) => {
{p.label}
-

{p.description}

+ {on && status ? ( +
+ + {statusDisplay(status).label} +
+ ) : ( +

{p.description}

+ )}

- Toggling on creates a private webview here. You'll sign in the first time you open it from - the sidebar — credentials stay on your device. + Toggling on adds a private webview. You'll sign in the first time you open it — credentials + stay on your device.

- +
); }; @@ -482,9 +542,13 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => { const DoneFooter = ({ close, + onDone, + doneLabel = 'Done', skipLabel = 'Skip for now', }: { close: () => void; + onDone?: () => void; + doneLabel?: string; skipLabel?: string; }) => (
@@ -496,9 +560,9 @@ const DoneFooter = ({
); diff --git a/app/src/components/__tests__/OpenhumanLinkModal.accounts.test.tsx b/app/src/components/__tests__/OpenhumanLinkModal.accounts.test.tsx new file mode 100644 index 000000000..5d224cc0f --- /dev/null +++ b/app/src/components/__tests__/OpenhumanLinkModal.accounts.test.tsx @@ -0,0 +1,202 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import accountsReducer from '../../store/accountsSlice'; +import OpenhumanLinkModal, { OPENHUMAN_LINK_EVENT } from '../OpenhumanLinkModal'; + +// Mock modules that require Tauri runtime +vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn(() => false) })); +vi.mock('../../lib/nativeNotifications/tauriBridge', () => ({ + ensureNotificationPermission: vi.fn(), + getNotificationPermissionState: vi.fn().mockResolvedValue('prompt'), + showNativeNotification: vi.fn(), +})); +vi.mock('../../services/webviewAccountService', () => ({ + isTauri: vi.fn(() => false), + purgeWebviewAccount: vi.fn().mockResolvedValue(undefined), +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +function createStore() { + return configureStore({ + reducer: combineReducers({ + accounts: accountsReducer, + // Stubs for selectors that may be read elsewhere + channelConnections: () => ({}), + }), + }); +} + +function renderModal(store = createStore()) { + return { + store, + ...render( + + + + + + ), + }; +} + +function openAccountsModal() { + act(() => { + window.dispatchEvent( + new CustomEvent(OPENHUMAN_LINK_EVENT, { detail: { path: 'accounts/setup' } }) + ); + }); +} + +describe('OpenhumanLinkModal accounts setup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders provider toggles when accounts/setup path is opened', () => { + renderModal(); + openAccountsModal(); + + expect(screen.getByLabelText('Connect WhatsApp Web')).toBeInTheDocument(); + expect(screen.getByLabelText('Connect Telegram Web')).toBeInTheDocument(); + expect(screen.getByLabelText('Connect Slack')).toBeInTheDocument(); + expect(screen.getByLabelText('Connect Discord')).toBeInTheDocument(); + expect(screen.getByLabelText('Connect LinkedIn')).toBeInTheDocument(); + }); + + it('toggle ON adds account to Redux store', () => { + const { store } = renderModal(); + openAccountsModal(); + + fireEvent.click(screen.getByLabelText('Connect Telegram Web')); + + const state = store.getState().accounts; + const telegramAccount = Object.values(state.accounts).find(a => a.provider === 'telegram'); + expect(telegramAccount).toBeDefined(); + expect(telegramAccount!.status).toBe('pending'); + }); + + it('toggle OFF removes account from Redux store', () => { + const { store } = renderModal(); + openAccountsModal(); + + // Toggle ON + fireEvent.click(screen.getByLabelText('Connect Telegram Web')); + expect(Object.values(store.getState().accounts.accounts)).toHaveLength(1); + + // Toggle OFF + fireEvent.click(screen.getByLabelText('Disconnect Telegram Web')); + expect(Object.values(store.getState().accounts.accounts)).toHaveLength(0); + }); + + it('Done button navigates to /chat and sets first new account as active', () => { + const { store } = renderModal(); + openAccountsModal(); + + // Toggle two providers ON + fireEvent.click(screen.getByLabelText('Connect Telegram Web')); + fireEvent.click(screen.getByLabelText('Connect Slack')); + + const accountIds = store.getState().accounts.order; + expect(accountIds).toHaveLength(2); + + // Click the CTA (dynamic label: "Continue with Telegram Web sign-in") + fireEvent.click(screen.getByRole('button', { name: /Continue with Telegram Web sign-in/ })); + + expect(store.getState().accounts.activeAccountId).toBe(accountIds[0]); + expect(mockNavigate).toHaveBeenCalledWith('/chat'); + }); + + it('Skip button closes modal without navigating', () => { + renderModal(); + openAccountsModal(); + + fireEvent.click(screen.getByLabelText('Connect Telegram Web')); + fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('Done without any new toggles does not navigate', () => { + renderModal(); + openAccountsModal(); + + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('shows dynamic CTA label when a provider is toggled on', () => { + renderModal(); + openAccountsModal(); + + // Before toggling, button says "Done" + expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); + + // Toggle Discord on + fireEvent.click(screen.getByLabelText('Connect Discord')); + + // CTA should now reference Discord + expect( + screen.getByRole('button', { name: /Continue with Discord sign-in/ }) + ).toBeInTheDocument(); + }); + + it('shows status indicator for existing accounts with a status', () => { + const store = createStore(); + // Pre-populate an account with 'open' status + store.dispatch({ + type: 'accounts/addAccount', + payload: { + id: 'test-acct-1', + provider: 'telegram', + label: 'Telegram', + createdAt: new Date().toISOString(), + status: 'open', + }, + }); + + render( + + + + + + ); + openAccountsModal(); + + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('shows "Needs sign-in" for accounts with pending status', () => { + const store = createStore(); + store.dispatch({ + type: 'accounts/addAccount', + payload: { + id: 'test-acct-2', + provider: 'slack', + label: 'Slack', + createdAt: new Date().toISOString(), + status: 'pending', + }, + }); + + render( + + + + + + ); + openAccountsModal(); + + expect(screen.getByText('Needs sign-in')).toBeInTheDocument(); + }); +});