mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(onboarding): make Connect your apps toggles start auth and reflect real connection state (#1226)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user