Skip to content

Commit f449a30

Browse files
authored
fix(onboarding): make Connect your apps toggles start auth and reflect real connection state (tinyhumansai#1226)
1 parent d0dc31c commit f449a30

2 files changed

Lines changed: 276 additions & 10 deletions

File tree

app/src/components/OpenhumanLinkModal.tsx

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* Mounted once at AppShell root.
1212
*/
1313
import { useCallback, useEffect, useMemo, useState } from 'react';
14+
import { useNavigate } from 'react-router-dom';
1415

1516
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
1617
import {
@@ -20,9 +21,14 @@ import {
2021
showNativeNotification,
2122
} from '../lib/nativeNotifications/tauriBridge';
2223
import { isTauri, purgeWebviewAccount } from '../services/webviewAccountService';
23-
import { addAccount, removeAccount } from '../store/accountsSlice';
24+
import { addAccount, removeAccount, setActiveAccount } from '../store/accountsSlice';
2425
import { useAppDispatch, useAppSelector } from '../store/hooks';
25-
import { type Account, type AccountProvider, PROVIDERS } from '../types/accounts';
26+
import {
27+
type Account,
28+
type AccountProvider,
29+
type AccountStatus,
30+
PROVIDERS,
31+
} from '../types/accounts';
2632
import { BILLING_DASHBOARD_URL } from '../utils/links';
2733
import { openUrl } from '../utils/openUrl';
2834
import { ProviderIcon } from './accounts/providerIcons';
@@ -389,11 +395,34 @@ function makeAccountId(): string {
389395
return `acct-${Date.now().toString(36)}`;
390396
}
391397

398+
/** Status label + color for a given account lifecycle status. */
399+
function statusDisplay(status: AccountStatus): { label: string; dotClass: string } {
400+
switch (status) {
401+
case 'open':
402+
return { label: 'Connected', dotClass: 'bg-emerald-500' };
403+
case 'loading':
404+
return { label: 'Loading…', dotClass: 'bg-amber-400' };
405+
case 'pending':
406+
return { label: 'Needs sign-in', dotClass: 'bg-amber-400' };
407+
case 'timeout':
408+
return { label: 'Timed out', dotClass: 'bg-red-400' };
409+
case 'error':
410+
return { label: 'Error', dotClass: 'bg-red-400' };
411+
case 'closed':
412+
return { label: 'Closed', dotClass: 'bg-stone-300' };
413+
}
414+
}
415+
392416
const AccountsSetupBody = ({ close }: { close: () => void }) => {
393417
const dispatch = useAppDispatch();
418+
const navigate = useNavigate();
394419
const accountsById = useAppSelector(s => s.accounts.accounts);
395420
const order = useAppSelector(s => s.accounts.order);
396421

422+
// Track accounts added during this modal session so "Done" can navigate.
423+
// Uses state (not ref) so the CTA label re-renders when toggles change.
424+
const [newlyAdded, setNewlyAdded] = useState<Map<string, string>>(new Map());
425+
397426
// Map provider → first existing account (one provider, one row).
398427
const accountByProvider = useMemo(() => {
399428
const map = new Map<AccountProvider, Account>();
@@ -416,8 +445,12 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
416445
if (currentlyOn) {
417446
const existing = accountByProvider.get(providerId);
418447
if (!existing) return;
419-
// Best-effort tear down the webview if one was already spun up.
420448
void purgeWebviewAccount(existing.id).catch(() => {});
449+
setNewlyAdded(prev => {
450+
const next = new Map(prev);
451+
next.delete(existing.id);
452+
return next;
453+
});
421454
dispatch(removeAccount({ accountId: existing.id }));
422455
return;
423456
}
@@ -428,9 +461,25 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
428461
createdAt: new Date().toISOString(),
429462
status: 'pending',
430463
};
464+
setNewlyAdded(prev => new Map(prev).set(acct.id, label));
431465
dispatch(addAccount(acct));
432466
};
433467

468+
const handleDone = () => {
469+
close();
470+
// Navigate to /chat and activate the first newly-added account so its
471+
// WebviewHost mounts and the auth flow starts immediately.
472+
const firstNew = [...newlyAdded.keys()][0];
473+
if (firstNew) {
474+
dispatch(setActiveAccount(firstNew));
475+
navigate('/chat');
476+
}
477+
};
478+
479+
// Dynamic CTA based on what's been toggled on
480+
const firstNewLabel = [...newlyAdded.values()][0];
481+
const doneLabel = firstNewLabel ? `Continue with ${firstNewLabel} sign-in` : 'Done';
482+
434483
return (
435484
<div className="space-y-4 text-sm text-stone-700">
436485
<p>
@@ -440,15 +489,26 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
440489
</p>
441490
<div className="space-y-2">
442491
{providerDescriptors.map(p => {
443-
const on = accountByProvider.has(p.id);
492+
const acct = accountByProvider.get(p.id);
493+
const on = !!acct;
494+
const status = acct?.status;
444495
return (
445496
<div
446497
key={p.id}
447498
className="flex items-center gap-3 rounded-xl border border-stone-100 bg-white p-3">
448499
<ProviderIcon provider={p.id} className="h-5 w-5 flex-none" />
449500
<div className="min-w-0 flex-1">
450501
<div className="text-sm font-medium text-stone-900">{p.label}</div>
451-
<p className="line-clamp-1 text-xs text-stone-500">{p.description}</p>
502+
{on && status ? (
503+
<div className="flex items-center gap-1.5">
504+
<span
505+
className={`inline-block h-1.5 w-1.5 rounded-full ${statusDisplay(status).dotClass}`}
506+
/>
507+
<span className="text-xs text-stone-500">{statusDisplay(status).label}</span>
508+
</div>
509+
) : (
510+
<p className="line-clamp-1 text-xs text-stone-500">{p.description}</p>
511+
)}
452512
</div>
453513
<button
454514
type="button"
@@ -470,10 +530,10 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
470530
})}
471531
</div>
472532
<p className="text-xs text-stone-400">
473-
Toggling on creates a private webview here. You'll sign in the first time you open it from
474-
the sidebar — credentials stay on your device.
533+
Toggling on adds a private webview. You'll sign in the first time you open it — credentials
534+
stay on your device.
475535
</p>
476-
<DoneFooter close={close} />
536+
<DoneFooter close={close} onDone={handleDone} doneLabel={doneLabel} />
477537
</div>
478538
);
479539
};
@@ -482,9 +542,13 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
482542

483543
const DoneFooter = ({
484544
close,
545+
onDone,
546+
doneLabel = 'Done',
485547
skipLabel = 'Skip for now',
486548
}: {
487549
close: () => void;
550+
onDone?: () => void;
551+
doneLabel?: string;
488552
skipLabel?: string;
489553
}) => (
490554
<div className="flex items-center justify-between gap-3 pt-1">
@@ -496,9 +560,9 @@ const DoneFooter = ({
496560
</button>
497561
<button
498562
type="button"
499-
onClick={close}
563+
onClick={onDone ?? close}
500564
className="rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50">
501-
Done
565+
{doneLabel}
502566
</button>
503567
</div>
504568
);
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { combineReducers, configureStore } from '@reduxjs/toolkit';
2+
import { act, fireEvent, render, screen } from '@testing-library/react';
3+
import { Provider } from 'react-redux';
4+
import { MemoryRouter } from 'react-router-dom';
5+
import { beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
import accountsReducer from '../../store/accountsSlice';
8+
import OpenhumanLinkModal, { OPENHUMAN_LINK_EVENT } from '../OpenhumanLinkModal';
9+
10+
// Mock modules that require Tauri runtime
11+
vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn(() => false) }));
12+
vi.mock('../../lib/nativeNotifications/tauriBridge', () => ({
13+
ensureNotificationPermission: vi.fn(),
14+
getNotificationPermissionState: vi.fn().mockResolvedValue('prompt'),
15+
showNativeNotification: vi.fn(),
16+
}));
17+
vi.mock('../../services/webviewAccountService', () => ({
18+
isTauri: vi.fn(() => false),
19+
purgeWebviewAccount: vi.fn().mockResolvedValue(undefined),
20+
}));
21+
22+
const mockNavigate = vi.fn();
23+
vi.mock('react-router-dom', async () => {
24+
const actual = await vi.importActual('react-router-dom');
25+
return { ...actual, useNavigate: () => mockNavigate };
26+
});
27+
28+
function createStore() {
29+
return configureStore({
30+
reducer: combineReducers({
31+
accounts: accountsReducer,
32+
// Stubs for selectors that may be read elsewhere
33+
channelConnections: () => ({}),
34+
}),
35+
});
36+
}
37+
38+
function renderModal(store = createStore()) {
39+
return {
40+
store,
41+
...render(
42+
<Provider store={store}>
43+
<MemoryRouter>
44+
<OpenhumanLinkModal />
45+
</MemoryRouter>
46+
</Provider>
47+
),
48+
};
49+
}
50+
51+
function openAccountsModal() {
52+
act(() => {
53+
window.dispatchEvent(
54+
new CustomEvent(OPENHUMAN_LINK_EVENT, { detail: { path: 'accounts/setup' } })
55+
);
56+
});
57+
}
58+
59+
describe('OpenhumanLinkModal accounts setup', () => {
60+
beforeEach(() => {
61+
vi.clearAllMocks();
62+
});
63+
64+
it('renders provider toggles when accounts/setup path is opened', () => {
65+
renderModal();
66+
openAccountsModal();
67+
68+
expect(screen.getByLabelText('Connect WhatsApp Web')).toBeInTheDocument();
69+
expect(screen.getByLabelText('Connect Telegram Web')).toBeInTheDocument();
70+
expect(screen.getByLabelText('Connect Slack')).toBeInTheDocument();
71+
expect(screen.getByLabelText('Connect Discord')).toBeInTheDocument();
72+
expect(screen.getByLabelText('Connect LinkedIn')).toBeInTheDocument();
73+
});
74+
75+
it('toggle ON adds account to Redux store', () => {
76+
const { store } = renderModal();
77+
openAccountsModal();
78+
79+
fireEvent.click(screen.getByLabelText('Connect Telegram Web'));
80+
81+
const state = store.getState().accounts;
82+
const telegramAccount = Object.values(state.accounts).find(a => a.provider === 'telegram');
83+
expect(telegramAccount).toBeDefined();
84+
expect(telegramAccount!.status).toBe('pending');
85+
});
86+
87+
it('toggle OFF removes account from Redux store', () => {
88+
const { store } = renderModal();
89+
openAccountsModal();
90+
91+
// Toggle ON
92+
fireEvent.click(screen.getByLabelText('Connect Telegram Web'));
93+
expect(Object.values(store.getState().accounts.accounts)).toHaveLength(1);
94+
95+
// Toggle OFF
96+
fireEvent.click(screen.getByLabelText('Disconnect Telegram Web'));
97+
expect(Object.values(store.getState().accounts.accounts)).toHaveLength(0);
98+
});
99+
100+
it('Done button navigates to /chat and sets first new account as active', () => {
101+
const { store } = renderModal();
102+
openAccountsModal();
103+
104+
// Toggle two providers ON
105+
fireEvent.click(screen.getByLabelText('Connect Telegram Web'));
106+
fireEvent.click(screen.getByLabelText('Connect Slack'));
107+
108+
const accountIds = store.getState().accounts.order;
109+
expect(accountIds).toHaveLength(2);
110+
111+
// Click the CTA (dynamic label: "Continue with Telegram Web sign-in")
112+
fireEvent.click(screen.getByRole('button', { name: /Continue with Telegram Web sign-in/ }));
113+
114+
expect(store.getState().accounts.activeAccountId).toBe(accountIds[0]);
115+
expect(mockNavigate).toHaveBeenCalledWith('/chat');
116+
});
117+
118+
it('Skip button closes modal without navigating', () => {
119+
renderModal();
120+
openAccountsModal();
121+
122+
fireEvent.click(screen.getByLabelText('Connect Telegram Web'));
123+
fireEvent.click(screen.getByRole('button', { name: 'Skip for now' }));
124+
125+
expect(mockNavigate).not.toHaveBeenCalled();
126+
});
127+
128+
it('Done without any new toggles does not navigate', () => {
129+
renderModal();
130+
openAccountsModal();
131+
132+
fireEvent.click(screen.getByRole('button', { name: 'Done' }));
133+
expect(mockNavigate).not.toHaveBeenCalled();
134+
});
135+
136+
it('shows dynamic CTA label when a provider is toggled on', () => {
137+
renderModal();
138+
openAccountsModal();
139+
140+
// Before toggling, button says "Done"
141+
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
142+
143+
// Toggle Discord on
144+
fireEvent.click(screen.getByLabelText('Connect Discord'));
145+
146+
// CTA should now reference Discord
147+
expect(
148+
screen.getByRole('button', { name: /Continue with Discord sign-in/ })
149+
).toBeInTheDocument();
150+
});
151+
152+
it('shows status indicator for existing accounts with a status', () => {
153+
const store = createStore();
154+
// Pre-populate an account with 'open' status
155+
store.dispatch({
156+
type: 'accounts/addAccount',
157+
payload: {
158+
id: 'test-acct-1',
159+
provider: 'telegram',
160+
label: 'Telegram',
161+
createdAt: new Date().toISOString(),
162+
status: 'open',
163+
},
164+
});
165+
166+
render(
167+
<Provider store={store}>
168+
<MemoryRouter>
169+
<OpenhumanLinkModal />
170+
</MemoryRouter>
171+
</Provider>
172+
);
173+
openAccountsModal();
174+
175+
expect(screen.getByText('Connected')).toBeInTheDocument();
176+
});
177+
178+
it('shows "Needs sign-in" for accounts with pending status', () => {
179+
const store = createStore();
180+
store.dispatch({
181+
type: 'accounts/addAccount',
182+
payload: {
183+
id: 'test-acct-2',
184+
provider: 'slack',
185+
label: 'Slack',
186+
createdAt: new Date().toISOString(),
187+
status: 'pending',
188+
},
189+
});
190+
191+
render(
192+
<Provider store={store}>
193+
<MemoryRouter>
194+
<OpenhumanLinkModal />
195+
</MemoryRouter>
196+
</Provider>
197+
);
198+
openAccountsModal();
199+
200+
expect(screen.getByText('Needs sign-in')).toBeInTheDocument();
201+
});
202+
});

0 commit comments

Comments
 (0)