fix(onboarding): make Connect your apps toggles start auth and reflect real connection state (#1226)

This commit is contained in:
Cyrus Gray
2026-05-06 13:18:06 -07:00
committed by GitHub
parent d0dc31c53f
commit f449a30520
2 changed files with 276 additions and 10 deletions
+74 -10
View File
@@ -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<Map<string, string>>(new Map());
// Map provider → first existing account (one provider, one row).
const accountByProvider = useMemo(() => {
const map = new Map<AccountProvider, Account>();
@@ -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 (
<div className="space-y-4 text-sm text-stone-700">
<p>
@@ -440,7 +489,9 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
</p>
<div className="space-y-2">
{providerDescriptors.map(p => {
const on = accountByProvider.has(p.id);
const acct = accountByProvider.get(p.id);
const on = !!acct;
const status = acct?.status;
return (
<div
key={p.id}
@@ -448,7 +499,16 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
<ProviderIcon provider={p.id} className="h-5 w-5 flex-none" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-stone-900">{p.label}</div>
<p className="line-clamp-1 text-xs text-stone-500">{p.description}</p>
{on && status ? (
<div className="flex items-center gap-1.5">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${statusDisplay(status).dotClass}`}
/>
<span className="text-xs text-stone-500">{statusDisplay(status).label}</span>
</div>
) : (
<p className="line-clamp-1 text-xs text-stone-500">{p.description}</p>
)}
</div>
<button
type="button"
@@ -470,10 +530,10 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
})}
</div>
<p className="text-xs text-stone-400">
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.
</p>
<DoneFooter close={close} />
<DoneFooter close={close} onDone={handleDone} doneLabel={doneLabel} />
</div>
);
};
@@ -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;
}) => (
<div className="flex items-center justify-between gap-3 pt-1">
@@ -496,9 +560,9 @@ const DoneFooter = ({
</button>
<button
type="button"
onClick={close}
onClick={onDone ?? close}
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">
Done
{doneLabel}
</button>
</div>
);
@@ -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(
<Provider store={store}>
<MemoryRouter>
<OpenhumanLinkModal />
</MemoryRouter>
</Provider>
),
};
}
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(
<Provider store={store}>
<MemoryRouter>
<OpenhumanLinkModal />
</MemoryRouter>
</Provider>
);
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(
<Provider store={store}>
<MemoryRouter>
<OpenhumanLinkModal />
</MemoryRouter>
</Provider>
);
openAccountsModal();
expect(screen.getByText('Needs sign-in')).toBeInTheDocument();
});
});