mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(auth): add local offline login (#2208)
Co-authored-by: shadowdoggie <shadowdoggie@users.noreply.github.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
shadowdoggie
Steven Enamakel
parent
76ce014e20
commit
fa62b7be2a
@@ -127,7 +127,6 @@ const makeTabs = (t: (key: string) => string) => [
|
||||
|
||||
const BottomTabBar = () => {
|
||||
const { t } = useT();
|
||||
const tabs = useMemo(() => makeTabs(t), [t]);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot } = useCoreState();
|
||||
@@ -142,6 +141,7 @@ const BottomTabBar = () => {
|
||||
// so an absent theme branch can't crash the bar.
|
||||
const tabBarLabels = useAppSelector(state => state.theme?.tabBarLabels ?? 'hover');
|
||||
const labelsAlwaysVisible = tabBarLabels === 'always';
|
||||
const tabs = useMemo(() => makeTabs(t), [t]);
|
||||
|
||||
const hiddenPaths = ['/', '/login'];
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EmptyStateCard = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
footer,
|
||||
className = '',
|
||||
}: EmptyStateCardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-2xl border border-dashed border-stone-300 dark:border-neutral-700 bg-stone-50/80 dark:bg-neutral-900/80 px-6 py-16 text-center ${className}`.trim()}>
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-500/10">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">{title}</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
{actionLabel && onAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 border border-primary-100 dark:border-primary-800/50 transition-colors hover:bg-primary-100 dark:hover:bg-primary-500/20">
|
||||
<span>{actionLabel}</span>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyStateCard;
|
||||
@@ -54,16 +54,18 @@ function buildStore(opts: BuildStoreOpts = {}) {
|
||||
interface RenderOpts {
|
||||
hasToken?: boolean;
|
||||
companionSessionActive?: boolean;
|
||||
tokenValue?: string;
|
||||
}
|
||||
|
||||
async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean = {}) {
|
||||
// Back-compat: previous callsites passed `hasToken` as the 2nd positional arg.
|
||||
const resolved: RenderOpts = typeof opts === 'boolean' ? { hasToken: opts } : opts;
|
||||
const hasToken = resolved.hasToken ?? true;
|
||||
const tokenValue = resolved.tokenValue ?? 'tok-test';
|
||||
const { useCoreState } = await import('../../providers/CoreStateProvider');
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot: {
|
||||
sessionToken: hasToken ? 'tok-test' : null,
|
||||
sessionToken: hasToken ? tokenValue : null,
|
||||
auth: { isAuthenticated: true, userId: 'u1', user: null, profileId: null },
|
||||
currentUser: null,
|
||||
onboardingCompleted: true,
|
||||
@@ -123,6 +125,11 @@ describe('BottomTabBar', () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('still shows the Rewards tab for local sessions', async () => {
|
||||
await renderBottomTabBar('/home', { tokenValue: 'header.payload.local' });
|
||||
expect(screen.getByRole('button', { name: 'Rewards' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the pulsing companion dot on the Settings tab when a session is active', async () => {
|
||||
const { container } = await renderBottomTabBar('/home', { companionSessionActive: true });
|
||||
const settingsBtn = screen.getByRole('button', { name: 'Settings' });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { channelConnectionsApi } from '../../services/api/channelConnectionsApi';
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
ChannelConnectionStatus,
|
||||
ChannelDefinition,
|
||||
} from '../../types/channels';
|
||||
import { isLocalSessionToken } from '../../utils/localSession';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import { restartCoreProcess } from '../../utils/tauriCommands/core';
|
||||
import ChannelFieldInput from './ChannelFieldInput';
|
||||
@@ -36,6 +38,11 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const channelConnections = useAppSelector(state => state.channelConnections);
|
||||
const { snapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
const visibleAuthModes = definition.auth_modes.filter(
|
||||
spec => !isLocalSession || (spec.mode !== 'managed_dm' && spec.mode !== 'oauth')
|
||||
);
|
||||
|
||||
const [busyKeys, setBusyKeys] = useState<Record<string, boolean>>({});
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
|
||||
@@ -279,7 +286,13 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{definition.auth_modes.map(spec => {
|
||||
{isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && (
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-4 py-3 text-sm text-stone-700 dark:text-neutral-200">
|
||||
{t('channels.localManagedUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleAuthModes.map(spec => {
|
||||
const compositeKey = `discord:${spec.mode}`;
|
||||
const connection = channelConnections.connections.discord?.[spec.mode];
|
||||
const status: ChannelConnectionStatus = connection?.status ?? 'disconnected';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { channelConnectionsApi } from '../../services/api/channelConnectionsApi';
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
ChannelConnectionStatus,
|
||||
ChannelDefinition,
|
||||
} from '../../types/channels';
|
||||
import { isLocalSessionToken } from '../../utils/localSession';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import { restartCoreProcess } from '../../utils/tauriCommands/core';
|
||||
import ChannelFieldInput from './ChannelFieldInput';
|
||||
@@ -33,6 +35,11 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const channelConnections = useAppSelector(state => state.channelConnections);
|
||||
const { snapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
const visibleAuthModes = definition.auth_modes.filter(
|
||||
spec => !isLocalSession || (spec.mode !== 'managed_dm' && spec.mode !== 'oauth')
|
||||
);
|
||||
|
||||
const MANAGED_DM_CONNECTING_MESSAGE = t('channels.telegram.managedDmConnecting');
|
||||
const MANAGED_DM_TIMEOUT_MESSAGE = t('channels.telegram.managedDmTimeout');
|
||||
@@ -340,7 +347,13 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{definition.auth_modes.map(spec => {
|
||||
{isLocalSession && visibleAuthModes.length !== definition.auth_modes.length && (
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-4 py-3 text-sm text-stone-700 dark:text-neutral-200">
|
||||
{t('channels.localManagedUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleAuthModes.map(spec => {
|
||||
const compositeKey = `telegram:${spec.mode}`;
|
||||
const connection = channelConnections.connections.telegram?.[spec.mode];
|
||||
const status: ChannelConnectionStatus = connection?.status ?? 'disconnected';
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FALLBACK_DEFINITIONS } from '../../../lib/channels/definitions';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import DiscordConfig from '../DiscordConfig';
|
||||
|
||||
const coreStateMock = vi.hoisted(() => vi.fn(() => ({ snapshot: { sessionToken: 'jwt-abc' } })));
|
||||
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => coreStateMock() }));
|
||||
|
||||
const discordDef = FALLBACK_DEFINITIONS.find(d => d.id === 'discord')!;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'jwt-abc' } });
|
||||
});
|
||||
|
||||
describe('DiscordConfig', () => {
|
||||
it('renders auth mode labels', () => {
|
||||
renderWithProviders(<DiscordConfig definition={discordDef} />);
|
||||
@@ -30,4 +39,17 @@ describe('DiscordConfig', () => {
|
||||
const connectButtons = screen.getAllByText('Connect');
|
||||
expect(connectButtons.length).toBe(3);
|
||||
});
|
||||
|
||||
it('hides managed channel auth modes for local users', () => {
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } });
|
||||
|
||||
renderWithProviders(<DiscordConfig definition={discordDef} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Managed channels are not available for local users.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('OAuth Sign-in')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Login with OpenHuman')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Bot Token').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { openUrl } from '../../../utils/openUrl';
|
||||
import TelegramConfig from '../TelegramConfig';
|
||||
|
||||
const telegramDef = FALLBACK_DEFINITIONS.find(d => d.id === 'telegram')!;
|
||||
const coreStateMock = vi.hoisted(() => vi.fn(() => ({ snapshot: { sessionToken: 'jwt-abc' } })));
|
||||
|
||||
vi.mock('../../../services/api/channelConnectionsApi', () => ({
|
||||
channelConnectionsApi: {
|
||||
@@ -21,9 +22,11 @@ vi.mock('../../../services/api/channelConnectionsApi', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => coreStateMock() }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'jwt-abc' } });
|
||||
});
|
||||
|
||||
describe('TelegramConfig', () => {
|
||||
@@ -98,4 +101,16 @@ describe('TelegramConfig', () => {
|
||||
});
|
||||
expect(await screen.findByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides managed channel auth modes for local users', () => {
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } });
|
||||
|
||||
renderWithProviders(<TelegramConfig definition={telegramDef} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Managed channels are not available for local users.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Login with OpenHuman')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Bot Token/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { BILLING_DASHBOARD_URL } from '../../utils/links';
|
||||
import { isLocalSessionToken } from '../../utils/localSession';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import LanguageSelect from '../LanguageSelect';
|
||||
import SettingsHeader from './components/SettingsHeader';
|
||||
@@ -28,6 +30,8 @@ const SettingsHome = () => {
|
||||
const navigate = useNavigate();
|
||||
const { navigateToSettings } = useSettingsNavigation();
|
||||
const { t } = useT();
|
||||
const { snapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
const settingsSections: SettingsSection[] = [
|
||||
{
|
||||
@@ -150,29 +154,36 @@ const SettingsHome = () => {
|
||||
// Features tile (Screen Awareness / Messaging Channels / Notifications /
|
||||
// Tools) used to live here. Everything under it moved into Advanced
|
||||
// (DeveloperOptionsPanel), so the section is gone from the home menu.
|
||||
{
|
||||
label: t('settings.billingAndRewards'),
|
||||
items: [
|
||||
{
|
||||
id: 'billing',
|
||||
title: t('settings.billingUsage'),
|
||||
description: t('settings.billingUsageDesc'),
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
openUrl(BILLING_DASHBOARD_URL).catch(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Billing & Rewards requires a backend-authenticated session.
|
||||
// Hidden in local/offline mode — no auth headers are sent and the
|
||||
// billing dashboard would not recognise the session.
|
||||
...(!isLocalSession
|
||||
? [
|
||||
{
|
||||
label: t('settings.billingAndRewards'),
|
||||
items: [
|
||||
{
|
||||
id: 'billing',
|
||||
title: t('settings.billingUsage'),
|
||||
description: t('settings.billingUsageDesc'),
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
openUrl(BILLING_DASHBOARD_URL).catch(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies SettingsSection,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('settings.advanced'),
|
||||
items: [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { I18nProvider } from '../../../lib/i18n/I18nContext';
|
||||
import type { Locale } from '../../../lib/i18n/types';
|
||||
@@ -33,10 +33,13 @@ vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateToSettings: mockNavigateToSettings }),
|
||||
}));
|
||||
|
||||
const mockClearSession = vi.fn().mockResolvedValue(undefined);
|
||||
let mockSessionToken: string | null = null;
|
||||
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({
|
||||
useCoreState: () => ({
|
||||
clearSession: vi.fn().mockResolvedValue(undefined),
|
||||
snapshot: { auth: { userId: null }, currentUser: null },
|
||||
clearSession: mockClearSession,
|
||||
snapshot: { auth: { userId: null }, currentUser: null, sessionToken: mockSessionToken },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -179,6 +182,27 @@ describe('SettingsHome', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('local session gating', () => {
|
||||
beforeEach(() => {
|
||||
// Use a valid local-session token (three parts, last part = 'local')
|
||||
mockSessionToken = 'header.payload.local';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockSessionToken = null;
|
||||
});
|
||||
|
||||
it('hides the Billing & Usage item in local mode', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.queryByText('Billing & Usage')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Billing & Usage" when not in local mode', () => {
|
||||
mockSessionToken = null;
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('Billing & Usage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
// Clear App Data flow moved to LogoutAndClearActions (rendered on Account
|
||||
// page) — see LogoutAndClearActions.test.tsx.
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import {
|
||||
type ComposioModeStatus,
|
||||
openhumanComposioClearApiKey,
|
||||
@@ -30,11 +32,18 @@ interface ComposioPanelProps {
|
||||
/** When true, render without the SettingsHeader chrome (used when embedded
|
||||
* inside the onboarding custom wizard). */
|
||||
embedded?: boolean;
|
||||
/** Whether OpenHuman-managed auth should be offered. Defaults to true for
|
||||
* cloud-authenticated sessions and false otherwise. */
|
||||
managedAuthEnabled?: boolean;
|
||||
}
|
||||
|
||||
const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
|
||||
const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelProps = {}) => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { snapshot } = useCoreState();
|
||||
const allowManagedAuth =
|
||||
managedAuthEnabled ??
|
||||
(Boolean(snapshot.sessionToken) && !isLocalSessionToken(snapshot.sessionToken));
|
||||
|
||||
const [mode, setMode] = useState<Mode>('backend');
|
||||
// Tracks the mode that's actually persisted on disk — distinct from
|
||||
@@ -66,7 +75,8 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
|
||||
if (!isMounted) return;
|
||||
const status: ComposioModeStatus | undefined = res.result;
|
||||
if (!status) return;
|
||||
const normalizedMode: Mode = status.mode === 'direct' ? 'direct' : 'backend';
|
||||
const normalizedMode: Mode =
|
||||
!allowManagedAuth || status.mode === 'direct' ? 'direct' : 'backend';
|
||||
setMode(normalizedMode);
|
||||
setPersistedMode(normalizedMode);
|
||||
setApiKeyStored(Boolean(status.api_key_set));
|
||||
@@ -87,7 +97,7 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
|
||||
clearTimeout(saveStatusTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [allowManagedAuth]);
|
||||
|
||||
const flashSaved = (status: 'saved' | 'cleared') => {
|
||||
setSaveStatus(status);
|
||||
@@ -228,58 +238,71 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
|
||||
{t('settings.composio.intro')}
|
||||
</p>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-3">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-stone-900 dark:text-neutral-100 mb-2">
|
||||
{t('settings.composio.routingMode')}
|
||||
</legend>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => setMode('backend')}>
|
||||
<input
|
||||
type="radio"
|
||||
name="composio-mode"
|
||||
value="backend"
|
||||
checked={mode === 'backend'}
|
||||
onChange={() => setMode('backend')}
|
||||
aria-label={t('settings.composio.modeManaged')}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.composio.modeManaged')}
|
||||
</span>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
|
||||
{t('settings.composio.modeManagedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => setMode('direct')}>
|
||||
<input
|
||||
type="radio"
|
||||
name="composio-mode"
|
||||
value="direct"
|
||||
checked={mode === 'direct'}
|
||||
onChange={() => setMode('direct')}
|
||||
aria-label={t('settings.composio.modeDirect')}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.composio.modeDirect')}
|
||||
</span>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
|
||||
{t('settings.composio.modeDirectDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{allowManagedAuth ? (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-3">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-stone-900 dark:text-neutral-100 mb-2">
|
||||
{t('settings.composio.routingMode')}
|
||||
</legend>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => setMode('backend')}>
|
||||
<input
|
||||
type="radio"
|
||||
name="composio-mode"
|
||||
value="backend"
|
||||
checked={mode === 'backend'}
|
||||
onChange={() => setMode('backend')}
|
||||
aria-label={t('settings.composio.modeManaged')}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.composio.modeManaged')}
|
||||
</span>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
|
||||
{t('settings.composio.modeManagedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => setMode('direct')}>
|
||||
<input
|
||||
type="radio"
|
||||
name="composio-mode"
|
||||
value="direct"
|
||||
checked={mode === 'direct'}
|
||||
onChange={() => setMode('direct')}
|
||||
aria-label={t('settings.composio.modeDirect')}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.composio.modeDirect')}
|
||||
</span>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
|
||||
{t('settings.composio.modeDirectDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.composio.modeDirect')}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'settings.composio.directOnlyDesc',
|
||||
'Managed Composio auth is unavailable here. Enter your own Composio API key or skip this for now.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API key field — only when Direct is selected */}
|
||||
{mode === 'direct' && (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import EmptyStateCard from '../../EmptyStateCard';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const DevicesComingSoonPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<SettingsHeader
|
||||
title={t('settings.devices')}
|
||||
showBackButton={breadcrumbs.length > 0}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5">
|
||||
<EmptyStateCard
|
||||
className="shadow-soft"
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
title="Devices"
|
||||
description="Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices."
|
||||
footer={
|
||||
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
Coming Soon
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevicesComingSoonPanel;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import {
|
||||
openhumanGetSearchSettings,
|
||||
openhumanUpdateSearchSettings,
|
||||
@@ -24,9 +26,11 @@ interface EngineOption {
|
||||
requiresKey: boolean;
|
||||
}
|
||||
|
||||
const SearchPanel = () => {
|
||||
const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { snapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
const [settings, setSettings] = useState<SearchSettings | null>(null);
|
||||
const [status, setStatus] = useState<Status>({ kind: 'loading' });
|
||||
@@ -55,6 +59,9 @@ const SearchPanel = () => {
|
||||
requiresKey: true,
|
||||
},
|
||||
];
|
||||
const visibleEngines = isLocalSession
|
||||
? ENGINES.filter(engine => engine.id !== 'managed')
|
||||
: ENGINES;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -118,18 +125,26 @@ const SearchPanel = () => {
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.search.title')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
{!embedded && (
|
||||
<SettingsHeader
|
||||
title={t('settings.search.title')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
|
||||
{t('settings.search.description')}
|
||||
</p>
|
||||
|
||||
{isLocalSession && (
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-4 py-3 text-sm text-stone-700 dark:text-neutral-200">
|
||||
{t('settings.search.localManagedUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.kind === 'loading' && (
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('common.loading')}
|
||||
@@ -142,7 +157,7 @@ const SearchPanel = () => {
|
||||
className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden"
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.search.engineAria')}>
|
||||
{ENGINES.map((opt, idx) => {
|
||||
{visibleEngines.map((opt, idx) => {
|
||||
const selected = opt.id === selectedEngine;
|
||||
const configured = isConfigured(opt.id);
|
||||
const blocked = opt.requiresKey && !configured && selected;
|
||||
|
||||
@@ -16,6 +16,10 @@ vi.mock('../../../../utils/tauriCommands', () => ({
|
||||
openhumanComposioClearApiKey: hoisted.clearApiKey,
|
||||
}));
|
||||
|
||||
const coreStateMock = vi.fn(() => ({ snapshot: { sessionToken: 'cloud.jwt.remote' } }));
|
||||
|
||||
vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: () => coreStateMock() }));
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack: vi.fn(),
|
||||
@@ -41,6 +45,7 @@ const directModeWithKey = { result: { mode: 'direct' as const, api_key_set: true
|
||||
describe('ComposioPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'cloud.jwt.remote' } });
|
||||
hoisted.getMode.mockResolvedValue(backendMode);
|
||||
hoisted.setApiKey.mockResolvedValue({ result: { stored: true, mode: 'direct' }, logs: [] });
|
||||
hoisted.clearApiKey.mockResolvedValue({ result: { cleared: true, mode: 'backend' }, logs: [] });
|
||||
@@ -104,6 +109,20 @@ describe('ComposioPanel', () => {
|
||||
expect(screen.getByLabelText('Composio API key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('defaults to direct-only API-key mode when managed auth is unavailable', async () => {
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } });
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel embedded />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull());
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText('Managed (OpenHuman handles it for you)')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Direct (bring your own API key)')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Composio API key')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Managed Composio auth is unavailable here/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('saving Direct mode with a key calls setApiKey and masks the field', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useBackendReachable } from '../useBackendReachable';
|
||||
|
||||
vi.mock('../../services/backendUrl', () => ({
|
||||
getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'),
|
||||
}));
|
||||
|
||||
describe('useBackendReachable', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports reachable when fetch resolves with any HTTP response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
const { result } = renderHook(() => useBackendReachable());
|
||||
expect(result.current).toBe('probing');
|
||||
await waitFor(() => expect(result.current).toBe('reachable'));
|
||||
});
|
||||
|
||||
it('reports reachable on 4xx (host answered)', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 404 }));
|
||||
|
||||
const { result } = renderHook(() => useBackendReachable());
|
||||
await waitFor(() => expect(result.current).toBe('reachable'));
|
||||
});
|
||||
|
||||
it('reports unreachable when fetch rejects (network error)', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
const { result } = renderHook(() => useBackendReachable());
|
||||
await waitFor(() => expect(result.current).toBe('unreachable'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import createDebug from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getBackendUrl } from '../services/backendUrl';
|
||||
|
||||
const log = createDebug('app:backend-probe');
|
||||
|
||||
export type BackendProbeStatus = 'probing' | 'reachable' | 'unreachable';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 2500;
|
||||
|
||||
/**
|
||||
* Probes the configured backend URL once on mount and reports whether it is
|
||||
* reachable. The "Continue locally" CTA on the Welcome screen is gated on
|
||||
* `unreachable` so users only see it when the backend OAuth flow can't be
|
||||
* completed (per issue #2037 AC).
|
||||
*
|
||||
* We treat any successful HTTP response (incl. 4xx) as reachable — the goal is
|
||||
* to confirm the host is online, not that a specific route exists.
|
||||
*/
|
||||
export function useBackendReachable(): BackendProbeStatus {
|
||||
const [status, setStatus] = useState<BackendProbeStatus>('probing');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const base = await getBackendUrl();
|
||||
log('[probe] fetching %s/health', base);
|
||||
const response = await fetch(`${base}/health`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (cancelled) return;
|
||||
// Any HTTP response (even 404) means the host answered — it's online.
|
||||
log('[probe] response status=%d → reachable', response.status);
|
||||
setStatus('reachable');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
log('[probe] failed → unreachable: %o', err);
|
||||
setStatus('unreachable');
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const mockListToolkits = vi.fn();
|
||||
const mockListConnections = vi.fn();
|
||||
const mockListAgentReadyToolkits = vi.fn();
|
||||
const mockOpenhumanComposioGetMode = vi.fn();
|
||||
let sessionToken = 'jwt-abc';
|
||||
|
||||
vi.mock('./composioApi', () => ({
|
||||
listToolkits: () => mockListToolkits(),
|
||||
@@ -11,10 +13,27 @@ vi.mock('./composioApi', () => ({
|
||||
listAgentReadyToolkits: () => mockListAgentReadyToolkits(),
|
||||
}));
|
||||
|
||||
vi.mock('../coreState/store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../coreState/store')>('../coreState/store');
|
||||
return { ...actual, getCoreStateSnapshot: () => ({ snapshot: { sessionToken } }) };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/tauriCommands', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../utils/tauriCommands')>(
|
||||
'../../utils/tauriCommands'
|
||||
);
|
||||
return { ...actual, openhumanComposioGetMode: () => mockOpenhumanComposioGetMode() };
|
||||
});
|
||||
|
||||
describe('useComposioIntegrations', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
sessionToken = 'jwt-abc';
|
||||
mockOpenhumanComposioGetMode.mockResolvedValue({
|
||||
result: { mode: 'backend', api_key_set: true },
|
||||
logs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps toolkit cards visible when connections fetch fails', async () => {
|
||||
@@ -50,6 +69,27 @@ describe('useComposioIntegrations', () => {
|
||||
expect(result.current.connectionByToolkit.size).toBe(0);
|
||||
expect(result.current.error).toBe('backend unreachable');
|
||||
});
|
||||
|
||||
it('skips toolkit fetch and polling for local sessions without a composio api key', async () => {
|
||||
sessionToken = 'header.payload.local';
|
||||
mockOpenhumanComposioGetMode.mockResolvedValue({
|
||||
result: { mode: 'direct', api_key_set: false },
|
||||
logs: [],
|
||||
});
|
||||
|
||||
const { useComposioIntegrations } = await import('./hooks');
|
||||
const { result } = renderHook(() => useComposioIntegrations(10));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.toolkits).toEqual([]);
|
||||
expect(result.current.connectionByToolkit.size).toBe(0);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockListToolkits).not.toHaveBeenCalled();
|
||||
expect(mockListConnections).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgentReadyComposioToolkits', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { isLocalSessionToken } from '../../utils/localSession';
|
||||
import { openhumanComposioGetMode } from '../../utils/tauriCommands';
|
||||
import { getCoreStateSnapshot } from '../coreState/store';
|
||||
import { listAgentReadyToolkits, listConnections, listToolkits } from './composioApi';
|
||||
import { canonicalizeComposioToolkitSlug } from './toolkitSlug';
|
||||
import type { ComposioConnection } from './types';
|
||||
@@ -33,10 +36,14 @@ export interface UseComposioIntegrationsResult {
|
||||
* explicit `refresh()` because the allowlist is stable.
|
||||
*/
|
||||
export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioIntegrationsResult {
|
||||
const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken);
|
||||
const [toolkits, setToolkits] = useState<string[]>([]);
|
||||
const [connections, setConnections] = useState<ComposioConnection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fetchEnabled, setFetchEnabled] = useState<boolean | null>(() =>
|
||||
isLocalSession ? null : true
|
||||
);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,7 +53,38 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveFetchEnabled = useCallback(async (): Promise<boolean> => {
|
||||
if (!isLocalSession) {
|
||||
if (mountedRef.current) setFetchEnabled(true);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const res = await openhumanComposioGetMode();
|
||||
const enabled = Boolean(res.result?.api_key_set);
|
||||
if (mountedRef.current) setFetchEnabled(enabled);
|
||||
return enabled;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[composio] failed to resolve direct-mode api key status:',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
if (mountedRef.current) setFetchEnabled(false);
|
||||
return false;
|
||||
}
|
||||
}, [isLocalSession]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const enabled = fetchEnabled ?? (await resolveFetchEnabled());
|
||||
if (!enabled) {
|
||||
if (mountedRef.current) {
|
||||
setToolkits([]);
|
||||
setConnections([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let nextError: string | null = null;
|
||||
try {
|
||||
const [toolkitsResult, connectionsResult] = await Promise.allSettled([
|
||||
@@ -81,12 +119,12 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [fetchEnabled, resolveFetchEnabled]);
|
||||
|
||||
// Initial fetch + polling.
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if (pollIntervalMs <= 0) return;
|
||||
if (pollIntervalMs <= 0 || fetchEnabled !== true) return;
|
||||
const id = window.setInterval(() => {
|
||||
void listConnections()
|
||||
.then(resp => {
|
||||
@@ -101,7 +139,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
|
||||
});
|
||||
}, pollIntervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh, pollIntervalMs]);
|
||||
}, [refresh, pollIntervalMs, fetchEnabled]);
|
||||
|
||||
// [composio-cache] Listen for a window-level "config changed" event
|
||||
// emitted by ComposioPanel when the user flips backend ↔ direct or
|
||||
@@ -115,11 +153,26 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
|
||||
useEffect(() => {
|
||||
const onConfigChanged = () => {
|
||||
console.debug('[composio-cache] window:composio:config-changed → refresh()');
|
||||
if (isLocalSession) {
|
||||
void resolveFetchEnabled().then(enabled => {
|
||||
if (enabled) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setToolkits([]);
|
||||
setConnections([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
};
|
||||
window.addEventListener('composio:config-changed', onConfigChanged);
|
||||
return () => window.removeEventListener('composio:config-changed', onConfigChanged);
|
||||
}, [refresh]);
|
||||
}, [isLocalSession, refresh, resolveFetchEnabled]);
|
||||
|
||||
const connectionByToolkit = useMemo(() => {
|
||||
const map = new Map<string, ComposioConnection>();
|
||||
|
||||
+20
-1
@@ -7,6 +7,25 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Arabic (العربية) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const ar: TranslationMap = { ...ar1, ...ar2, ...ar3, ...ar4, ...ar5 };
|
||||
const ar: TranslationMap = {
|
||||
...ar1,
|
||||
...ar2,
|
||||
...ar3,
|
||||
...ar4,
|
||||
...ar5,
|
||||
'skills.composio.noApiKeyTitle': 'لم يتم إعداد مفتاح Composio API',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'يستخدم الوضع المحلي مفتاح Composio API الخاص بك. افتح الإعدادات ← الخيارات المتقدمة ← Composio لإضافته قبل توصيل التكاملات هنا.',
|
||||
'skills.composio.noApiKeyCta': 'افتح في الإعدادات',
|
||||
'channels.localManagedUnavailable': 'القنوات المُدارة غير متاحة للمستخدمين المحليين.',
|
||||
'rewards.localUnavailable':
|
||||
'تسجيل الدخول المحلي لا يمنح مكافآت أو قسائم أو رصيد إحالة. لكسب المكافآت، سجّل الخروج ثم تابِع بتسجيل الدخول باستخدام حساب OpenHuman.',
|
||||
'rewards.localUnavailableCta': 'افتح إعدادات الحساب',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'بحث OpenHuman المُدار غير متاح للمستخدمين المحليين. أضف مفتاح Parallel أو Brave الخاص بك لتفعيل البحث على الويب.',
|
||||
'devices.comingSoonDescription':
|
||||
'إقران الأجهزة قريبًا. ستكون هذه الصفحة مخصصة لإقران أجهزة iPhone وإدارة الأجهزة المتصلة.',
|
||||
'welcome.continueLocallyExperimental': 'المتابعة محليًا (تجريبي)',
|
||||
};
|
||||
|
||||
export default ar;
|
||||
|
||||
+20
-1
@@ -7,6 +7,25 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Bengali (বাংলা) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const bn: TranslationMap = { ...bn1, ...bn2, ...bn3, ...bn4, ...bn5 };
|
||||
const bn: TranslationMap = {
|
||||
...bn1,
|
||||
...bn2,
|
||||
...bn3,
|
||||
...bn4,
|
||||
...bn5,
|
||||
'skills.composio.noApiKeyTitle': 'কোনো Composio API Key কনফিগার করা নেই',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'লোকাল মোডে আপনার নিজের Composio API key ব্যবহার হয়। এখানে ইন্টিগ্রেশন যুক্ত করার আগে Settings → Advanced → Composio খুলে একটি key যোগ করুন।',
|
||||
'skills.composio.noApiKeyCta': 'সেটিংসে খুলুন',
|
||||
'channels.localManagedUnavailable': 'লোকাল ব্যবহারকারীদের জন্য ম্যানেজড চ্যানেল উপলভ্য নয়।',
|
||||
'rewards.localUnavailable':
|
||||
'লোকাল লগইনে কোনো রিওয়ার্ড, কুপন বা রেফারেল ক্রেডিট মেলে না। রিওয়ার্ড পেতে লগ আউট করে একটি OpenHuman অ্যাকাউন্ট দিয়ে সাইন ইন করুন।',
|
||||
'rewards.localUnavailableCta': 'অ্যাকাউন্ট সেটিংস খুলুন',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'লোকাল ব্যবহারকারীদের জন্য OpenHuman Managed সার্চ উপলভ্য নয়। ওয়েব সার্চ চালু করতে আপনার নিজের Parallel বা Brave API key যোগ করুন।',
|
||||
'devices.comingSoonDescription':
|
||||
'ডিভাইস পেয়ারিং শীঘ্রই আসছে। এই পেজে iPhone পেয়ারিং এবং সংযুক্ত ডিভাইস ম্যানেজ করা যাবে।',
|
||||
'welcome.continueLocallyExperimental': 'লোকালি চালিয়ে যান (প্রায়োগিক)',
|
||||
};
|
||||
|
||||
export default bn;
|
||||
|
||||
@@ -84,6 +84,8 @@ const ar1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'تسجيل الخروج وحذف جميع البيانات المحلية للتطبيق نهائيًا',
|
||||
'settings.logOut': 'تسجيل الخروج',
|
||||
'settings.logOutDesc': 'تسجيل الخروج من حسابك',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'اللغة',
|
||||
'settings.languageDesc': 'لغة عرض واجهة التطبيق',
|
||||
'settings.alerts': 'التنبيهات',
|
||||
@@ -511,6 +513,18 @@ const ar1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'مسار التنقل',
|
||||
'settings.betaBuild': 'إصدار تجريبي - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -211,6 +211,9 @@ const ar3: TranslationMap = {
|
||||
'about.update.status.default': 'التحقق من التحديثات',
|
||||
'welcome.connectionFailed': 'فشل الاتصال: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'فشل الاتصال: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'فتح جلسة محادثة مباشرة مع الوكيل.',
|
||||
'channels.activeRouteValue': '{channel} عبر {authMode}',
|
||||
'privacy.dataKind.messages': 'الرسائل',
|
||||
@@ -398,6 +401,7 @@ const ar3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default ar3;
|
||||
|
||||
@@ -84,6 +84,8 @@ const bn1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'সাইন আউট করুন এবং সব লোকাল ডেটা স্থায়ীভাবে মুছুন',
|
||||
'settings.logOut': 'লগ আউট',
|
||||
'settings.logOutDesc': 'আপনার অ্যাকাউন্ট থেকে সাইন আউট করুন',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'ভাষা',
|
||||
'settings.languageDesc': 'অ্যাপ ইন্টারফেসের প্রদর্শন ভাষা',
|
||||
'settings.alerts': 'সতর্কতা',
|
||||
@@ -520,6 +522,18 @@ const bn1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'ব্রেডক্রাম্ব নেভিগেশন',
|
||||
'settings.betaBuild': 'বেটা বিল্ড - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -215,6 +215,9 @@ const bn3: TranslationMap = {
|
||||
'about.update.status.default': 'আপডেট পরীক্ষা করুন',
|
||||
'welcome.connectionFailed': 'সংযোগ ব্যর্থ: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'সংযোগ ব্যর্থ: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'এজেন্টের সাথে সরাসরি চ্যাট সেশন খুলুন।',
|
||||
'channels.activeRouteValue': '{authMode} এর মাধ্যমে {channel}',
|
||||
'privacy.dataKind.messages': 'বার্তা',
|
||||
@@ -401,6 +404,7 @@ const bn3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default bn3;
|
||||
|
||||
@@ -532,6 +532,20 @@ const de1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Navigationspfad',
|
||||
'settings.betaBuild': 'Beta-Build - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -413,6 +413,10 @@ const de3: TranslationMap = {
|
||||
'channels.web.description': 'Chatte über die integrierte Web-Oberfläche.',
|
||||
'channels.web.authMode.managed_dm.description':
|
||||
'Nutze den eingebetteten Web-Chat — keine Einrichtung erforderlich.',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
};
|
||||
|
||||
export default de3;
|
||||
|
||||
@@ -285,6 +285,8 @@ const en1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'Sign out and permanently clear all local app data',
|
||||
'settings.logOut': 'Log out',
|
||||
'settings.logOutDesc': 'Sign out of your account',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Language',
|
||||
'settings.languageDesc': 'Display language for the app interface',
|
||||
'settings.alerts': 'Alerts',
|
||||
@@ -444,6 +446,10 @@ const en1: TranslationMap = {
|
||||
'skills.integrations': 'Composio Integrations',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
@@ -467,6 +473,9 @@ const en1: TranslationMap = {
|
||||
'rewards.title': 'Rewards',
|
||||
'rewards.referrals': 'Referrals',
|
||||
'rewards.coupons': 'Redeem',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'rewards.credits': 'Credits',
|
||||
'rewards.referralCode': 'Your referral code',
|
||||
'rewards.copyCode': 'Copy code',
|
||||
@@ -596,6 +605,7 @@ const en1: TranslationMap = {
|
||||
'channels.configure': 'Configure Channel',
|
||||
'channels.setup': 'Setup',
|
||||
'channels.noChannels': 'No channels configured',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'channels.addChannel': 'Add Channel',
|
||||
'channels.status.connected': 'Connected',
|
||||
'channels.status.disconnected': 'Disconnected',
|
||||
@@ -937,6 +947,8 @@ const en1: TranslationMap = {
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
@@ -980,6 +992,8 @@ const en1: TranslationMap = {
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'devices.title': 'Devices',
|
||||
'devices.pairIphone': 'Pair iPhone',
|
||||
'devices.noPaired': 'No paired devices',
|
||||
|
||||
@@ -214,6 +214,10 @@ const en3: TranslationMap = {
|
||||
'about.update.status.default': 'Check for updates',
|
||||
'welcome.connectionFailed': 'Connection failed: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Connection failed: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'Open a direct chat session with the agent.',
|
||||
'channels.activeRouteValue': '{channel} via {authMode}',
|
||||
'privacy.dataKind.messages': 'Messages',
|
||||
|
||||
@@ -86,6 +86,8 @@ const es1: TranslationMap = {
|
||||
'Cerrar sesión y eliminar permanentemente todos los datos locales de la app',
|
||||
'settings.logOut': 'Cerrar sesión',
|
||||
'settings.logOutDesc': 'Salir de tu cuenta',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Idioma',
|
||||
'settings.languageDesc': 'Idioma de visualización de la interfaz de la app',
|
||||
'settings.alerts': 'Alertas',
|
||||
@@ -532,6 +534,18 @@ const es1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Ruta de navegación',
|
||||
'settings.betaBuild': 'Compilación beta - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -218,6 +218,9 @@ const es3: TranslationMap = {
|
||||
'about.update.status.default': 'Buscar actualizaciones',
|
||||
'welcome.connectionFailed': 'Conexión fallida: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Conexión fallida: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'Abre una sesión de chat directo con el agente.',
|
||||
'channels.activeRouteValue': '{channel} vía {authMode}',
|
||||
'privacy.dataKind.messages': 'Mensajes',
|
||||
@@ -406,6 +409,7 @@ const es3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default es3;
|
||||
|
||||
@@ -86,6 +86,8 @@ const fr1: TranslationMap = {
|
||||
'Se déconnecter et supprimer définitivement toutes les données locales',
|
||||
'settings.logOut': 'Se déconnecter',
|
||||
'settings.logOutDesc': 'Se déconnecter de ton compte',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Langue',
|
||||
'settings.languageDesc': "Langue d'affichage de l'interface",
|
||||
'settings.alerts': 'Alertes',
|
||||
@@ -534,6 +536,18 @@ const fr1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Fil d’Ariane',
|
||||
'settings.betaBuild': 'Build bêta - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -219,6 +219,9 @@ const fr3: TranslationMap = {
|
||||
'about.update.status.default': 'Rechercher des mises à jour',
|
||||
'welcome.connectionFailed': 'Connexion échouée : {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Connexion échouée : {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': "Ouvrir une session de chat direct avec l'agent.",
|
||||
'channels.activeRouteValue': '{channel} via {authMode}',
|
||||
'privacy.dataKind.messages': 'Messages',
|
||||
@@ -407,6 +410,7 @@ const fr3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default fr3;
|
||||
|
||||
@@ -83,6 +83,8 @@ const hi1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'साइन आउट करें और सारा लोकल ऐप डेटा हमेशा के लिए मिटाएं',
|
||||
'settings.logOut': 'लॉग आउट',
|
||||
'settings.logOutDesc': 'अपने अकाउंट से साइन आउट करें',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'भाषा',
|
||||
'settings.languageDesc': 'ऐप इंटरफेस की डिस्प्ले भाषा',
|
||||
'settings.alerts': 'अलर्ट',
|
||||
@@ -517,6 +519,18 @@ const hi1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'ब्रेडक्रंब नेविगेशन',
|
||||
'settings.betaBuild': 'बीटा बिल्ड - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -214,6 +214,9 @@ const hi3: TranslationMap = {
|
||||
'about.update.status.default': 'अपडेट चेक करें',
|
||||
'welcome.connectionFailed': 'कनेक्शन विफल: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'कनेक्शन विफल: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'एजेंट के साथ डायरेक्ट चैट सेशन खोलें।',
|
||||
'channels.activeRouteValue': '{channel} द्वारा {authMode}',
|
||||
'privacy.dataKind.messages': 'मैसेज',
|
||||
@@ -403,6 +406,7 @@ const hi3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default hi3;
|
||||
|
||||
@@ -83,6 +83,8 @@ const id1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'Keluar dan hapus permanen semua data aplikasi lokal',
|
||||
'settings.logOut': 'Keluar',
|
||||
'settings.logOutDesc': 'Keluar dari akun Anda',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Bahasa',
|
||||
'settings.languageDesc': 'Bahasa tampilan untuk antarmuka aplikasi',
|
||||
'settings.alerts': 'Peringatan',
|
||||
@@ -523,6 +525,18 @@ const id1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Jejak navigasi',
|
||||
'settings.betaBuild': 'Build beta - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -215,6 +215,9 @@ const id3: TranslationMap = {
|
||||
'about.update.status.default': 'Periksa pembaruan',
|
||||
'welcome.connectionFailed': 'Koneksi gagal: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Koneksi gagal: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'Buka sesi chat langsung dengan agen.',
|
||||
'channels.activeRouteValue': '{channel} lewat {authMode}',
|
||||
'privacy.dataKind.messages': 'Pesan',
|
||||
@@ -406,6 +409,7 @@ const id3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default id3;
|
||||
|
||||
@@ -85,6 +85,8 @@ const it1: TranslationMap = {
|
||||
"Disconnetti e cancella permanentemente tutti i dati locali dell'app",
|
||||
'settings.logOut': 'Disconnetti',
|
||||
'settings.logOutDesc': 'Disconnetti dal tuo account',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Lingua',
|
||||
'settings.languageDesc': "Lingua di visualizzazione dell'interfaccia dell'app",
|
||||
'settings.alerts': 'Avvisi',
|
||||
@@ -527,6 +529,18 @@ const it1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Percorso di navigazione',
|
||||
'settings.betaBuild': 'Build beta - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -218,6 +218,9 @@ const it3: TranslationMap = {
|
||||
'about.update.status.default': 'Verifica aggiornamenti',
|
||||
'welcome.connectionFailed': 'Connessione fallita: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Connessione fallita: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': "Apri una sessione di chat diretta con l'agente.",
|
||||
'channels.activeRouteValue': '{channel} via {authMode}',
|
||||
'privacy.dataKind.messages': 'Messaggi',
|
||||
@@ -406,6 +409,7 @@ const it3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default it3;
|
||||
|
||||
@@ -83,6 +83,8 @@ const ko1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': '로그아웃하고 모든 로컬 앱 데이터를 영구적으로 삭제',
|
||||
'settings.logOut': '로그아웃',
|
||||
'settings.logOutDesc': '계정에서 로그아웃',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': '언어',
|
||||
'settings.languageDesc': '앱 인터페이스 표시 언어',
|
||||
'settings.alerts': '알림',
|
||||
@@ -520,6 +522,18 @@ const ko1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': '이동 경로',
|
||||
'settings.betaBuild': '베타 빌드 - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -213,6 +213,9 @@ const ko3: TranslationMap = {
|
||||
'about.update.status.default': '업데이트 확인',
|
||||
'welcome.connectionFailed': '연결 실패: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': '연결 실패: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': '에이전트와 직접 채팅 세션을 엽니다.',
|
||||
'channels.activeRouteValue': '{channel} via {authMode}',
|
||||
'privacy.dataKind.messages': '메시지',
|
||||
@@ -403,5 +406,6 @@ const ko3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
export default ko3;
|
||||
|
||||
@@ -85,6 +85,8 @@ const pt1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'Sair e excluir permanentemente todos os dados locais do app',
|
||||
'settings.logOut': 'Sair',
|
||||
'settings.logOutDesc': 'Sair da sua conta',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Idioma',
|
||||
'settings.languageDesc': 'Idioma de exibição da interface do app',
|
||||
'settings.alerts': 'Alertas',
|
||||
@@ -532,6 +534,18 @@ const pt1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Trilha de navegação',
|
||||
'settings.betaBuild': 'Build beta - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -217,6 +217,9 @@ const pt3: TranslationMap = {
|
||||
'about.update.status.default': 'Verificar atualizações',
|
||||
'welcome.connectionFailed': 'Falha na conexão: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Falha na conexão: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'Abrir uma sessão de chat direto com o agente.',
|
||||
'channels.activeRouteValue': '{channel} via {authMode}',
|
||||
'privacy.dataKind.messages': 'Mensagens',
|
||||
@@ -405,6 +408,7 @@ const pt3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default pt3;
|
||||
|
||||
@@ -85,6 +85,8 @@ const ru1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'Выйти из аккаунта и удалить все локальные данные приложения',
|
||||
'settings.logOut': 'Выйти',
|
||||
'settings.logOutDesc': 'Выйти из своего аккаунта',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Язык',
|
||||
'settings.languageDesc': 'Язык отображения интерфейса',
|
||||
'settings.alerts': 'Оповещения',
|
||||
@@ -522,6 +524,18 @@ const ru1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Навигационная цепочка',
|
||||
'settings.betaBuild': 'Бета-сборка — v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -215,6 +215,9 @@ const ru3: TranslationMap = {
|
||||
'about.update.status.default': 'Проверить обновления',
|
||||
'welcome.connectionFailed': 'Ошибка подключения: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Ошибка подключения: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
'chat.agentChatDesc': 'Открыть прямой чат с агентом.',
|
||||
'channels.activeRouteValue': '{channel} через {authMode}',
|
||||
'privacy.dataKind.messages': 'Сообщения',
|
||||
@@ -402,6 +405,7 @@ const ru3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': 'Chat via the built-in web UI.',
|
||||
'channels.web.authMode.managed_dm.description': 'Use the embedded web chat — no setup required.',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default ru3;
|
||||
|
||||
@@ -83,6 +83,8 @@ const zhCN1: TranslationMap = {
|
||||
'settings.clearAppDataDesc': '退出登录并永久清除所有本地应用数据',
|
||||
'settings.logOut': '退出登录',
|
||||
'settings.logOutDesc': '退出当前账户',
|
||||
'settings.exitLocalSession': '退出本地会话',
|
||||
'settings.exitLocalSessionDesc': '返回登录页面',
|
||||
'settings.language': '语言',
|
||||
'settings.languageDesc': '应用界面显示语言',
|
||||
'settings.alerts': '通知',
|
||||
@@ -504,6 +506,18 @@ const zhCN1: TranslationMap = {
|
||||
'Must be a positive integer (use the Unlimited preset for no limit).',
|
||||
'autonomy.presetUnlimited': 'Unlimited (default)',
|
||||
'triggers.toggleFailed': '{action} failed for {trigger}: {message}',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': '面包屑导航',
|
||||
'settings.betaBuild': '测试版构建 - v{version}',
|
||||
'migration.vendor.openclaw': 'OpenClaw',
|
||||
|
||||
@@ -205,6 +205,9 @@ const zhCN3: TranslationMap = {
|
||||
'about.update.status.default': '检查更新',
|
||||
'welcome.connectionFailed': '连接失败: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': '连接失败: {message}',
|
||||
'welcome.continueLocally': '本地继续',
|
||||
'welcome.localSessionStarting': '正在启动本地会话...',
|
||||
'welcome.localSessionDesc': '使用离线本地配置文件,跳过 TinyHumans OAuth。',
|
||||
'chat.agentChatDesc': '与智能体进行直接对话。',
|
||||
'channels.activeRouteValue': '{channel} 通过 {authMode}',
|
||||
'privacy.dataKind.messages': '消息',
|
||||
@@ -396,6 +399,7 @@ const zhCN3: TranslationMap = {
|
||||
'channels.web.displayName': 'Web',
|
||||
'channels.web.description': '通过内置的 Web UI 聊天。',
|
||||
'channels.web.authMode.managed_dm.description': '使用嵌入式 Web 聊天 — 无需设置。',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
};
|
||||
|
||||
export default zhCN3;
|
||||
|
||||
+20
-1
@@ -7,6 +7,25 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// German (Deutsch) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const de: TranslationMap = { ...de1, ...de2, ...de3, ...de4, ...de5 };
|
||||
const de: TranslationMap = {
|
||||
...de1,
|
||||
...de2,
|
||||
...de3,
|
||||
...de4,
|
||||
...de5,
|
||||
'skills.composio.noApiKeyTitle': 'Kein Composio-API-Schlüssel konfiguriert',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Im lokalen Modus wird dein eigener Composio-API-Schlüssel verwendet. Öffne Einstellungen → Erweitert → Composio, um einen Schlüssel hinzuzufügen, bevor du hier Integrationen verbindest.',
|
||||
'skills.composio.noApiKeyCta': 'In den Einstellungen öffnen',
|
||||
'channels.localManagedUnavailable': 'Verwaltete Kanäle sind für lokale Benutzer nicht verfügbar.',
|
||||
'rewards.localUnavailable':
|
||||
'Ein lokaler Login sammelt keine Belohnungen, Gutscheine oder Empfehlungsguthaben. Melde dich ab und anschließend mit einem OpenHuman-Konto an, wenn Belohnungen zählen sollen.',
|
||||
'rewards.localUnavailableCta': 'Kontoeinstellungen öffnen',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'Die von OpenHuman verwaltete Suche ist für lokale Benutzer nicht verfügbar. Füge deinen eigenen Parallel- oder Brave-API-Schlüssel hinzu, um die Websuche zu aktivieren.',
|
||||
'devices.comingSoonDescription':
|
||||
'Gerätekopplung kommt bald. Diese Seite wird für das Koppeln von iPhones und die Verwaltung verbundener Geräte zuständig sein.',
|
||||
'welcome.continueLocallyExperimental': 'Lokal fortfahren (Experimentell)',
|
||||
};
|
||||
|
||||
export default de;
|
||||
|
||||
@@ -90,6 +90,8 @@ const en: TranslationMap = {
|
||||
'settings.clearAppDataDesc': 'Sign out and permanently clear all local app data',
|
||||
'settings.logOut': 'Log out',
|
||||
'settings.logOutDesc': 'Sign out of your account',
|
||||
'settings.exitLocalSession': 'Exit local session',
|
||||
'settings.exitLocalSessionDesc': 'Return to the sign-in screen',
|
||||
'settings.language': 'Language',
|
||||
'settings.betaBuild': 'Beta build - v{version}',
|
||||
'settings.languageDesc': 'Display language for the app interface',
|
||||
@@ -251,6 +253,10 @@ const en: TranslationMap = {
|
||||
'skills.integrations': 'Composio Integrations',
|
||||
'skills.integrationsSubtitle':
|
||||
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
|
||||
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
|
||||
'skills.composio.noApiKeyCta': 'Open in Settings',
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
@@ -277,6 +283,9 @@ const en: TranslationMap = {
|
||||
'rewards.title': 'Rewards',
|
||||
'rewards.referrals': 'Referrals',
|
||||
'rewards.coupons': 'Redeem',
|
||||
'rewards.localUnavailable':
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.',
|
||||
'rewards.localUnavailableCta': 'Open Account Settings',
|
||||
'rewards.credits': 'Credits',
|
||||
'rewards.referralCode': 'Your referral code',
|
||||
'rewards.copyCode': 'Copy code',
|
||||
@@ -420,6 +429,7 @@ const en: TranslationMap = {
|
||||
'channels.configure': 'Configure Channel',
|
||||
'channels.setup': 'Setup',
|
||||
'channels.noChannels': 'No channels configured',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'channels.addChannel': 'Add Channel',
|
||||
'channels.status.connected': 'Connected',
|
||||
'channels.status.disconnected': 'Disconnected',
|
||||
@@ -584,6 +594,8 @@ const en: TranslationMap = {
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
@@ -627,6 +639,8 @@ const en: TranslationMap = {
|
||||
'devices.betaBadge': 'Beta',
|
||||
'devices.betaText':
|
||||
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'devices.title': 'Devices',
|
||||
'devices.pairIphone': 'Pair iPhone',
|
||||
'devices.noPaired': 'No paired devices',
|
||||
@@ -1660,6 +1674,10 @@ const en: TranslationMap = {
|
||||
// Welcome: connection error messages
|
||||
'welcome.connectionFailed': 'Connection failed: {status} {statusText}',
|
||||
'welcome.connectionFailedMsg': 'Connection failed: {message}',
|
||||
'welcome.continueLocally': 'Continue locally',
|
||||
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
|
||||
'welcome.localSessionStarting': 'Starting local session...',
|
||||
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
|
||||
|
||||
// Chat: Agent chat panel description
|
||||
'chat.agentChatDesc': 'Open a direct chat session with the agent.',
|
||||
|
||||
+21
-1
@@ -7,6 +7,26 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Spanish (Español) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const es: TranslationMap = { ...es1, ...es2, ...es3, ...es4, ...es5 };
|
||||
const es: TranslationMap = {
|
||||
...es1,
|
||||
...es2,
|
||||
...es3,
|
||||
...es4,
|
||||
...es5,
|
||||
'skills.composio.noApiKeyTitle': 'No hay una API key de Composio configurada',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'El modo local usa tu propia API key de Composio. Abre Ajustes → Avanzado → Composio para añadir una antes de conectar integraciones aquí.',
|
||||
'skills.composio.noApiKeyCta': 'Abrir en Ajustes',
|
||||
'channels.localManagedUnavailable':
|
||||
'Los canales gestionados no están disponibles para usuarios locales.',
|
||||
'rewards.localUnavailable':
|
||||
'El acceso local no obtiene recompensas, cupones ni crédito por referidos. Cierra sesión y continúa iniciando sesión con una cuenta de OpenHuman si quieres que las recompensas cuenten.',
|
||||
'rewards.localUnavailableCta': 'Abrir ajustes de la cuenta',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'La búsqueda gestionada por OpenHuman no está disponible para usuarios locales. Añade tu propia API key de Parallel o Brave para habilitar la búsqueda web.',
|
||||
'devices.comingSoonDescription':
|
||||
'El emparejamiento de dispositivos llegará pronto. Esta página será el lugar para emparejar iPhones y gestionar dispositivos conectados.',
|
||||
'welcome.continueLocallyExperimental': 'Continuar localmente (Experimental)',
|
||||
};
|
||||
|
||||
export default es;
|
||||
|
||||
+21
-1
@@ -7,6 +7,26 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// French (Français) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const fr: TranslationMap = { ...fr1, ...fr2, ...fr3, ...fr4, ...fr5 };
|
||||
const fr: TranslationMap = {
|
||||
...fr1,
|
||||
...fr2,
|
||||
...fr3,
|
||||
...fr4,
|
||||
...fr5,
|
||||
'skills.composio.noApiKeyTitle': 'Aucune clé API Composio configurée',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Le mode local utilise votre propre clé API Composio. Ouvrez Paramètres → Avancé → Composio pour en ajouter une avant de connecter des intégrations ici.',
|
||||
'skills.composio.noApiKeyCta': 'Ouvrir dans les paramètres',
|
||||
'channels.localManagedUnavailable':
|
||||
'Les canaux gérés ne sont pas disponibles pour les utilisateurs locaux.',
|
||||
'rewards.localUnavailable':
|
||||
'La connexion locale ne permet pas de gagner des récompenses, des coupons ou du crédit de parrainage. Déconnecte-toi puis connecte-toi avec un compte OpenHuman si tu veux que les récompenses comptent.',
|
||||
'rewards.localUnavailableCta': 'Ouvrir les paramètres du compte',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'La recherche gérée par OpenHuman n’est pas disponible pour les utilisateurs locaux. Ajoutez votre propre clé API Parallel ou Brave pour activer la recherche web.',
|
||||
'devices.comingSoonDescription':
|
||||
'L’appairage des appareils arrive bientôt. Cette page servira à appairer des iPhone et à gérer les appareils connectés.',
|
||||
'welcome.continueLocallyExperimental': 'Continuer en local (Expérimental)',
|
||||
};
|
||||
|
||||
export default fr;
|
||||
|
||||
+20
-1
@@ -7,6 +7,25 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Hindi (हिन्दी) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const hi: TranslationMap = { ...hi1, ...hi2, ...hi3, ...hi4, ...hi5 };
|
||||
const hi: TranslationMap = {
|
||||
...hi1,
|
||||
...hi2,
|
||||
...hi3,
|
||||
...hi4,
|
||||
...hi5,
|
||||
'skills.composio.noApiKeyTitle': 'कोई Composio API key कॉन्फ़िगर नहीं है',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'लोकल मोड आपकी अपनी Composio API key का उपयोग करता है। यहाँ integrations जोड़ने से पहले Settings → Advanced → Composio खोलकर key जोड़ें।',
|
||||
'skills.composio.noApiKeyCta': 'Settings में खोलें',
|
||||
'channels.localManagedUnavailable': 'लोकल उपयोगकर्ताओं के लिए managed channels उपलब्ध नहीं हैं।',
|
||||
'rewards.localUnavailable':
|
||||
'लोकल लॉगिन पर rewards, coupons या referral credit नहीं मिलते। rewards पाने के लिए लॉग आउट करें और OpenHuman खाते से साइन इन करें।',
|
||||
'rewards.localUnavailableCta': 'Account Settings खोलें',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'लोकल उपयोगकर्ताओं के लिए OpenHuman Managed search उपलब्ध नहीं है। वेब सर्च चालू करने के लिए अपनी Parallel या Brave API key जोड़ें।',
|
||||
'devices.comingSoonDescription':
|
||||
'डिवाइस पेयरिंग जल्द आ रही है। यह पेज iPhone पेयर करने और जुड़े हुए डिवाइस प्रबंधित करने का स्थान होगा।',
|
||||
'welcome.continueLocallyExperimental': 'लोकल रूप से जारी रखें (प्रायोगिक)',
|
||||
};
|
||||
|
||||
export default hi;
|
||||
|
||||
+20
-1
@@ -7,6 +7,25 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Bahasa Indonesia translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const id: TranslationMap = { ...id1, ...id2, ...id3, ...id4, ...id5 };
|
||||
const id: TranslationMap = {
|
||||
...id1,
|
||||
...id2,
|
||||
...id3,
|
||||
...id4,
|
||||
...id5,
|
||||
'skills.composio.noApiKeyTitle': 'Belum ada API key Composio yang dikonfigurasi',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Mode lokal menggunakan API key Composio milik Anda sendiri. Buka Pengaturan → Lanjutan → Composio untuk menambahkannya sebelum menghubungkan integrasi di sini.',
|
||||
'skills.composio.noApiKeyCta': 'Buka di Pengaturan',
|
||||
'channels.localManagedUnavailable': 'Channel terkelola tidak tersedia untuk pengguna lokal.',
|
||||
'rewards.localUnavailable':
|
||||
'Login lokal tidak mendapatkan reward, kupon, atau kredit referral. Keluar lalu lanjutkan dengan masuk menggunakan akun OpenHuman jika Anda ingin reward dihitung.',
|
||||
'rewards.localUnavailableCta': 'Buka Pengaturan Akun',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'Pencarian OpenHuman Managed tidak tersedia untuk pengguna lokal. Tambahkan API key Parallel atau Brave Anda sendiri untuk mengaktifkan pencarian web.',
|
||||
'devices.comingSoonDescription':
|
||||
'Pemasangan perangkat akan segera hadir. Halaman ini akan menjadi tempat untuk memasangkan iPhone dan mengelola perangkat yang terhubung.',
|
||||
'welcome.continueLocallyExperimental': 'Lanjutkan Secara Lokal (Eksperimental)',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
+21
-1
@@ -7,6 +7,26 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Italian (Italiano) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const it: TranslationMap = { ...it1, ...it2, ...it3, ...it4, ...it5 };
|
||||
const it: TranslationMap = {
|
||||
...it1,
|
||||
...it2,
|
||||
...it3,
|
||||
...it4,
|
||||
...it5,
|
||||
'skills.composio.noApiKeyTitle': 'Nessuna chiave API Composio configurata',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'La modalità locale usa la tua chiave API Composio. Apri Impostazioni → Avanzate → Composio per aggiungerne una prima di collegare le integrazioni qui.',
|
||||
'skills.composio.noApiKeyCta': 'Apri nelle impostazioni',
|
||||
'channels.localManagedUnavailable':
|
||||
'I canali gestiti non sono disponibili per gli utenti locali.',
|
||||
'rewards.localUnavailable':
|
||||
"L'accesso locale non guadagna ricompense, coupon o credito referral. Esci e continua accedendo con un account OpenHuman se vuoi che le ricompense vengano conteggiate.",
|
||||
'rewards.localUnavailableCta': 'Apri le impostazioni account',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'La ricerca gestita da OpenHuman non è disponibile per gli utenti locali. Aggiungi la tua chiave API Parallel o Brave per abilitare la ricerca web.',
|
||||
'devices.comingSoonDescription':
|
||||
"L'abbinamento dei dispositivi arriverà presto. Questa pagina sarà il punto centrale per abbinare gli iPhone e gestire i dispositivi connessi.",
|
||||
'welcome.continueLocallyExperimental': 'Continua Localmente (Sperimentale)',
|
||||
};
|
||||
|
||||
export default it;
|
||||
|
||||
@@ -2110,6 +2110,19 @@ const ko: TranslationMap = {
|
||||
'settings.localModel.status.ollamaDocs': 'Ollama 문서',
|
||||
'settings.localModel.status.thenRetry':
|
||||
'설정 지침을 확인한 다음 런타임에 연결할 수 있게 되면 다시 시도하세요.',
|
||||
'skills.composio.noApiKeyTitle': 'Composio API 키가 설정되지 않았습니다',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'로컬 모드는 사용자의 Composio API 키를 사용합니다. 여기서 통합을 연결하기 전에 설정 → 고급 → Composio에서 키를 추가하세요.',
|
||||
'skills.composio.noApiKeyCta': '설정에서 열기',
|
||||
'channels.localManagedUnavailable': '로컬 사용자에게는 관리형 채널을 사용할 수 없습니다.',
|
||||
'rewards.localUnavailable':
|
||||
'로컬 로그인으로는 보상, 쿠폰 또는 추천 크레딧을 받을 수 없습니다. 보상을 받으려면 로그아웃한 뒤 OpenHuman 계정으로 로그인하세요.',
|
||||
'rewards.localUnavailableCta': '계정 설정 열기',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'로컬 사용자에게는 OpenHuman Managed 검색을 사용할 수 없습니다. 웹 검색을 사용하려면 Parallel 또는 Brave API 키를 추가하세요.',
|
||||
'devices.comingSoonDescription':
|
||||
'기기 페어링은 곧 제공됩니다. 이 페이지는 iPhone 페어링과 연결된 기기 관리를 위한 공간이 됩니다.',
|
||||
'welcome.continueLocallyExperimental': '로컬로 계속하기 (실험적)',
|
||||
};
|
||||
|
||||
export default ko;
|
||||
|
||||
+21
-1
@@ -7,6 +7,26 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Portuguese (Português) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const pt: TranslationMap = { ...pt1, ...pt2, ...pt3, ...pt4, ...pt5 };
|
||||
const pt: TranslationMap = {
|
||||
...pt1,
|
||||
...pt2,
|
||||
...pt3,
|
||||
...pt4,
|
||||
...pt5,
|
||||
'skills.composio.noApiKeyTitle': 'Nenhuma chave de API do Composio configurada',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'O modo local usa sua própria chave de API do Composio. Abra Configurações → Avançado → Composio para adicionar uma antes de conectar integrações aqui.',
|
||||
'skills.composio.noApiKeyCta': 'Abrir nas Configurações',
|
||||
'channels.localManagedUnavailable':
|
||||
'Canais gerenciados não estão disponíveis para usuários locais.',
|
||||
'rewards.localUnavailable':
|
||||
'O login local não rende recompensas, cupons nem crédito de indicação. Saia e continue entrando com uma conta OpenHuman se quiser que as recompensas contem.',
|
||||
'rewards.localUnavailableCta': 'Abrir configurações da conta',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'A busca gerenciada pela OpenHuman não está disponível para usuários locais. Adicione sua própria chave de API do Parallel ou Brave para habilitar a busca na web.',
|
||||
'devices.comingSoonDescription':
|
||||
'O pareamento de dispositivos está chegando em breve. Esta página será o lugar para parear iPhones e gerenciar dispositivos conectados.',
|
||||
'welcome.continueLocallyExperimental': 'Continuar Localmente (Experimental)',
|
||||
};
|
||||
|
||||
export default pt;
|
||||
|
||||
+20
-1
@@ -7,6 +7,25 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Russian (Русский) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const ru: TranslationMap = { ...ru1, ...ru2, ...ru3, ...ru4, ...ru5 };
|
||||
const ru: TranslationMap = {
|
||||
...ru1,
|
||||
...ru2,
|
||||
...ru3,
|
||||
...ru4,
|
||||
...ru5,
|
||||
'skills.composio.noApiKeyTitle': 'Ключ API Composio не настроен',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'Локальный режим использует ваш собственный ключ API Composio. Откройте Настройки → Дополнительно → Composio, чтобы добавить ключ перед подключением интеграций здесь.',
|
||||
'skills.composio.noApiKeyCta': 'Открыть в настройках',
|
||||
'channels.localManagedUnavailable': 'Управляемые каналы недоступны для локальных пользователей.',
|
||||
'rewards.localUnavailable':
|
||||
'Локальный вход не приносит награды, купоны или реферальный кредит. Выйдите и войдите с аккаунтом OpenHuman, если хотите, чтобы награды начислялись.',
|
||||
'rewards.localUnavailableCta': 'Открыть настройки аккаунта',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'Поиск OpenHuman Managed недоступен для локальных пользователей. Добавьте свой ключ API Parallel или Brave, чтобы включить веб-поиск.',
|
||||
'devices.comingSoonDescription':
|
||||
'Сопряжение устройств скоро появится. Эта страница будет местом для подключения iPhone и управления подключёнными устройствами.',
|
||||
'welcome.continueLocallyExperimental': 'Продолжить локально (Экспериментально)',
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -7,6 +7,24 @@ import type { TranslationMap } from './types';
|
||||
|
||||
// Simplified Chinese (简体中文) translations. Each chunk maps to chunks/en-N.ts.
|
||||
// Missing keys fall back to English via I18nContext.resolveEn().
|
||||
const zhCN: TranslationMap = { ...zhCN1, ...zhCN2, ...zhCN3, ...zhCN4, ...zhCN5 };
|
||||
const zhCN: TranslationMap = {
|
||||
...zhCN1,
|
||||
...zhCN2,
|
||||
...zhCN3,
|
||||
...zhCN4,
|
||||
...zhCN5,
|
||||
'skills.composio.noApiKeyTitle': '尚未配置 Composio API 密钥',
|
||||
'skills.composio.noApiKeyDescription':
|
||||
'本地模式使用你自己的 Composio API 密钥。在此连接集成之前,请打开 设置 → 高级 → Composio 添加一个密钥。',
|
||||
'skills.composio.noApiKeyCta': '在设置中打开',
|
||||
'channels.localManagedUnavailable': '本地用户无法使用托管频道。',
|
||||
'rewards.localUnavailable':
|
||||
'本地登录不会获得奖励、优惠券或推荐积分。若要累计奖励,请先登出,然后使用 OpenHuman 账号登录。',
|
||||
'rewards.localUnavailableCta': '打开账户设置',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'本地用户无法使用 OpenHuman 托管搜索。请添加你自己的 Parallel 或 Brave API 密钥以启用网页搜索。',
|
||||
'devices.comingSoonDescription': '设备配对即将推出。此页面将用于配对 iPhone 并管理已连接设备。',
|
||||
'welcome.continueLocallyExperimental': '本地继续(实验性)',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import EmptyStateCard from '../components/EmptyStateCard';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
import RewardsCommunityTab from '../components/rewards/RewardsCommunityTab';
|
||||
import RewardsRedeemTab from '../components/rewards/RewardsRedeemTab';
|
||||
import RewardsReferralsTab from '../components/rewards/RewardsReferralsTab';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { rewardsApi } from '../services/api/rewardsApi';
|
||||
import type { RewardsSnapshot } from '../types/rewards';
|
||||
import { isLocalSessionToken } from '../utils/localSession';
|
||||
|
||||
type RewardsTab = 'referrals' | 'redeem' | 'rewards';
|
||||
|
||||
@@ -25,8 +29,11 @@ function errorMessage(err: unknown): string {
|
||||
|
||||
const Rewards = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot: coreSnapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(coreSnapshot.sessionToken);
|
||||
const [selectedTab, setSelectedTab] = useState<RewardsTab>('rewards');
|
||||
const [snapshot, setSnapshot] = useState<RewardsSnapshot | null>(null);
|
||||
const [rewardsSnapshot, setRewardsSnapshot] = useState<RewardsSnapshot | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -37,7 +44,7 @@ const Rewards = () => {
|
||||
try {
|
||||
const result = await rewardsApi.getMyRewards();
|
||||
if (signal?.cancelled) return;
|
||||
setSnapshot(result);
|
||||
setRewardsSnapshot(result);
|
||||
log(
|
||||
'snapshot applied unlockedCount=%d totalCount=%d',
|
||||
result.summary.unlockedCount,
|
||||
@@ -47,7 +54,7 @@ const Rewards = () => {
|
||||
const message = errorMessage(err);
|
||||
log('snapshot load failed error=%s', message);
|
||||
if (signal?.cancelled) return;
|
||||
setSnapshot(null);
|
||||
setRewardsSnapshot(null);
|
||||
setError(message);
|
||||
} finally {
|
||||
if (!signal?.cancelled) {
|
||||
@@ -57,12 +64,15 @@ const Rewards = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalSession) {
|
||||
return;
|
||||
}
|
||||
const signal = { cancelled: false };
|
||||
void loadRewards(signal);
|
||||
return () => {
|
||||
signal.cancelled = true;
|
||||
};
|
||||
}, [loadRewards]);
|
||||
}, [isLocalSession, loadRewards]);
|
||||
|
||||
const handleTabChange = useCallback((next: RewardsTab) => {
|
||||
log('tab changed next=%s', next);
|
||||
@@ -74,6 +84,37 @@ const Rewards = () => {
|
||||
void loadRewards();
|
||||
}, [loadRewards]);
|
||||
|
||||
if (isLocalSession) {
|
||||
return (
|
||||
<div className="min-h-full px-4 pt-6 pb-10">
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<EmptyStateCard
|
||||
className="shadow-soft"
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 8v8m0-8l-3-3m3 3l3-3M8 14H6a2 2 0 01-2-2V7a2 2 0 012-2h2m8 9h2a2 2 0 002-2V7a2 2 0 00-2-2h-2M7 19h10"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
title={t('rewards.title')}
|
||||
description={t('rewards.localUnavailable')}
|
||||
actionLabel={t('rewards.localUnavailableCta')}
|
||||
onAction={() => navigate('/settings/account')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full px-4 pt-6 pb-10">
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
@@ -97,7 +138,7 @@ const Rewards = () => {
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
onRetry={handleRetry}
|
||||
snapshot={snapshot}
|
||||
snapshot={rewardsSnapshot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import ComposioPanel from '../components/settings/panels/ComposioPanel';
|
||||
import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel';
|
||||
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
|
||||
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
|
||||
import DevicesPanel from '../components/settings/panels/DevicesPanel';
|
||||
import DevicesComingSoonPanel from '../components/settings/panels/DevicesComingSoonPanel';
|
||||
import HeartbeatPanel from '../components/settings/panels/HeartbeatPanel';
|
||||
import LedgerUsagePanel from '../components/settings/panels/LedgerUsagePanel';
|
||||
import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel';
|
||||
@@ -441,7 +441,7 @@ const Settings = () => {
|
||||
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
|
||||
<Route path="composio-routing" element={wrapSettingsPage(<ComposioPanel />)} />
|
||||
{/* Mobile devices */}
|
||||
<Route path="devices" element={wrapSettingsPage(<DevicesPanel />)} />
|
||||
<Route path="devices" element={wrapSettingsPage(<DevicesComingSoonPanel />)} />
|
||||
{/* About / updates */}
|
||||
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
|
||||
{/* Fallback */}
|
||||
|
||||
+105
-47
@@ -8,6 +8,7 @@ import {
|
||||
type ComposioToolkitMeta,
|
||||
KNOWN_COMPOSIO_TOOLKITS,
|
||||
} from '../components/composio/toolkitMeta';
|
||||
import EmptyStateCard from '../components/EmptyStateCard';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
|
||||
@@ -35,6 +36,7 @@ import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
|
||||
import { useAgentReadyComposioToolkits, useComposioIntegrations } from '../lib/composio/hooks';
|
||||
import { canonicalizeComposioToolkitSlug } from '../lib/composio/toolkitSlug';
|
||||
import { type ComposioConnection, deriveComposioState } from '../lib/composio/types';
|
||||
import { getCoreStateSnapshot } from '../lib/coreState/store';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { channelConnectionsApi } from '../services/api/channelConnectionsApi';
|
||||
import { skillsApi, type SkillSummary } from '../services/api/skillsApi';
|
||||
@@ -43,7 +45,8 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
|
||||
import type { ToastNotification } from '../types/intelligence';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { subconsciousEscalationsDismiss } from '../utils/tauriCommands';
|
||||
import { isLocalSessionToken } from '../utils/localSession';
|
||||
import { openhumanComposioGetMode, subconsciousEscalationsDismiss } from '../utils/tauriCommands';
|
||||
|
||||
function channelStatusLabel(status: ChannelConnectionStatus, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
@@ -286,8 +289,8 @@ function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
|
||||
function McpComingSoonPanel() {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-stone-300 dark:border-neutral-700 bg-stone-50/80 dark:bg-neutral-900/80 px-6 py-16 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-500/10">
|
||||
<EmptyStateCard
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
@@ -300,17 +303,38 @@ function McpComingSoonPanel() {
|
||||
d="M6.75 7.5h10.5m-10.5 4.5h10.5m-10.5 4.5h6m-9.75 3h13.5A2.25 2.25 0 0 0 19.5 17.25V6.75A2.25 2.25 0 0 0 17.25 4.5H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5A2.25 2.25 0 0 0 6.75 19.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('skills.mcpComingSoon.title')}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.mcpComingSoon.description')}
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
{t('common.comingSoon')}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
title={t('skills.mcpComingSoon.title')}
|
||||
description={t('skills.mcpComingSoon.description')}
|
||||
footer={
|
||||
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
{t('common.comingSoon')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposioApiKeyEmptyState({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<EmptyStateCard
|
||||
className="mx-1 mb-3 py-10"
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7Z" />
|
||||
</svg>
|
||||
}
|
||||
title={t('skills.composio.noApiKeyTitle')}
|
||||
description={t('skills.composio.noApiKeyDescription')}
|
||||
actionLabel={t('skills.composio.noApiKeyCta')}
|
||||
onAction={onOpenSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,6 +386,7 @@ export default function Skills() {
|
||||
const channelIcons = useMemo(() => getChannelIcons(t), [t]);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken);
|
||||
const [activeTab, setActiveTab] = useState<ConnectionsTab>('composio');
|
||||
const dispatch = useAppDispatch();
|
||||
const [defaultChannelBusy, setDefaultChannelBusy] = useState<ChannelType | null>(null);
|
||||
@@ -432,6 +457,8 @@ export default function Skills() {
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [uninstallCandidate, setUninstallCandidate] = useState<SkillSummary | null>(null);
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
const [hasComposioApiKey, setHasComposioApiKey] = useState<boolean | null>(null);
|
||||
const showLocalComposioApiKeyBanner = isLocalSession && hasComposioApiKey === false;
|
||||
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
||||
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
@@ -511,6 +538,29 @@ export default function Skills() {
|
||||
};
|
||||
}, [refreshDiscoveredSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalSession) {
|
||||
setHasComposioApiKey(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void openhumanComposioGetMode()
|
||||
.then(res => {
|
||||
if (!cancelled) {
|
||||
setHasComposioApiKey(Boolean(res.result?.api_key_set));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) {
|
||||
console.warn('[skills][composio] failed to load composio mode status:', err);
|
||||
setHasComposioApiKey(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLocalSession]);
|
||||
|
||||
const bestChannelStatus = (channelId: ChannelType): ChannelConnectionStatus => {
|
||||
const conns = channelConnections.connections[channelId];
|
||||
if (!conns) return 'disconnected';
|
||||
@@ -1016,41 +1066,49 @@ export default function Skills() {
|
||||
{t('skills.integrationsSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 px-1 pb-3">
|
||||
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
<SkillCategoryFilter
|
||||
categories={availableCategories}
|
||||
selected={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
{showLocalComposioApiKeyBanner && (
|
||||
<ComposioApiKeyEmptyState
|
||||
onOpenSettings={() => navigate('/settings/composio-routing')}
|
||||
/>
|
||||
</div>
|
||||
{composioSortedEntries.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
|
||||
{composioSortedEntries.map(({ meta, connection }) => (
|
||||
<div key={meta.slug} data-testid={`skill-row-composio-${meta.slug}`}>
|
||||
<ComposioConnectorTile
|
||||
meta={meta}
|
||||
connection={connection}
|
||||
hasComposioError={Boolean(composioError)}
|
||||
isAgentReady={
|
||||
agentReadyLoading ||
|
||||
Boolean(agentReadyError) ||
|
||||
agentReadyToolkits.has(meta.slug)
|
||||
}
|
||||
testId={`skill-install-composio-${meta.slug}`}
|
||||
onOpen={() => setComposioModalToolkit(meta)}
|
||||
onRetryGlobal={() => void refreshComposio()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-1 py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.noResults')}
|
||||
</p>
|
||||
)}
|
||||
{!showLocalComposioApiKeyBanner && (
|
||||
<div className="space-y-3 px-1 pb-3">
|
||||
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
<SkillCategoryFilter
|
||||
categories={availableCategories}
|
||||
selected={selectedCategory}
|
||||
onChange={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!showLocalComposioApiKeyBanner &&
|
||||
(composioSortedEntries.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
|
||||
{composioSortedEntries.map(({ meta, connection }) => (
|
||||
<div key={meta.slug} data-testid={`skill-row-composio-${meta.slug}`}>
|
||||
<ComposioConnectorTile
|
||||
meta={meta}
|
||||
connection={connection}
|
||||
hasComposioError={Boolean(composioError)}
|
||||
isAgentReady={
|
||||
agentReadyLoading ||
|
||||
Boolean(agentReadyError) ||
|
||||
agentReadyToolkits.has(meta.slug)
|
||||
}
|
||||
testId={`skill-install-composio-${meta.slug}`}
|
||||
onOpen={() => setComposioModalToolkit(meta)}
|
||||
onRetryGlobal={() => void refreshComposio()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-1 py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.noResults')}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import createDebug from 'debug';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import OAuthProviderButton from '../components/oauth/OAuthProviderButton';
|
||||
import { oauthProviderConfigs } from '../components/oauth/providerConfigs';
|
||||
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
|
||||
import Button from '../components/ui/Button';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { clearBackendUrlCache } from '../services/backendUrl';
|
||||
import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../services/coreRpcClient';
|
||||
import { resetCoreMode } from '../store/coreModeSlice';
|
||||
import { useDeepLinkAuthState } from '../store/deepLinkAuthState';
|
||||
import { useAppDispatch } from '../store/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { resolveTheme, setThemeMode, type ThemeMode } from '../store/themeSlice';
|
||||
import { clearAllAppData } from '../utils/clearAllAppData';
|
||||
import { clearStoredCoreMode, clearStoredCoreToken, storeRpcUrl } from '../utils/configPersistence';
|
||||
import { PRIVACY_POLICY_URL, TERMS_OF_USE_URL } from '../utils/links';
|
||||
import { createLocalSessionToken, LOCAL_SESSION_USER } from '../utils/localSession';
|
||||
import { openUrl } from '../utils/openUrl';
|
||||
|
||||
const log = createDebug('app:welcome');
|
||||
|
||||
const Welcome = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { storeSessionToken } = useCoreState();
|
||||
const { isProcessing, errorMessage, requiresAppDataReset } = useDeepLinkAuthState();
|
||||
const themeMode = useAppSelector(state => state.theme?.mode ?? 'system') as ThemeMode;
|
||||
const resolvedTheme = resolveTheme(themeMode);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
const [isClearingAppData, setIsClearingAppData] = useState(false);
|
||||
const [isLocalSigningIn, setIsLocalSigningIn] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const [localLoginError, setLocalLoginError] = useState<string | null>(null);
|
||||
|
||||
const handleClearAppData = async () => {
|
||||
setIsClearingAppData(true);
|
||||
@@ -52,10 +63,69 @@ const Welcome = () => {
|
||||
dispatch(resetCoreMode());
|
||||
};
|
||||
|
||||
const handleLocalLogin = async () => {
|
||||
setIsLocalSigningIn(true);
|
||||
setLocalLoginError(null);
|
||||
try {
|
||||
log('[welcome] local session login requested');
|
||||
await storeSessionToken(createLocalSessionToken(), LOCAL_SESSION_USER);
|
||||
navigate('/onboarding/custom/inference', { replace: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log('[welcome] local session login failed: %s', message);
|
||||
setLocalLoginError(message || 'Could not start a local session.');
|
||||
setIsLocalSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
dispatch(setThemeMode(isDark ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-8 animate-fade-up">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="w-9" aria-hidden="true" />
|
||||
<div className="w-9" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? t('home.themeToggle.toLight') : t('home.themeToggle.toDark')}
|
||||
title={isDark ? t('home.themeToggle.toLight') : t('home.themeToggle.toDark')}
|
||||
className="p-2 rounded-full text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800/60 transition-colors">
|
||||
{isDark ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="h-20 w-20">
|
||||
<RotatingTetrahedronCanvas />
|
||||
@@ -158,7 +228,7 @@ const Welcome = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 px-2">
|
||||
<div className="mt-4 px-2 space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
@@ -166,6 +236,21 @@ const Welcome = () => {
|
||||
className="w-full py-3">
|
||||
{t('welcome.selectRuntime')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={handleLocalLogin}
|
||||
disabled={isLocalSigningIn}
|
||||
className="w-full py-3">
|
||||
{isLocalSigningIn
|
||||
? t('welcome.localSessionStarting')
|
||||
: t('welcome.continueLocallyExperimental')}
|
||||
</Button>
|
||||
{localLoginError ? (
|
||||
<p className="text-[11px] leading-4 text-center font-medium text-red-700">
|
||||
{localLoginError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,10 @@ const { rewardsApi, openUrl } = vi.hoisted(() => ({
|
||||
openUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const coreStateMock = vi.hoisted(() => vi.fn(() => ({ snapshot: { sessionToken: 'jwt-abc' } })));
|
||||
|
||||
vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => coreStateMock() }));
|
||||
|
||||
vi.mock('../../components/rewards/RewardsReferralsTab', () => ({
|
||||
default: () => <div>Referral Rewards Section</div>,
|
||||
}));
|
||||
@@ -27,6 +31,24 @@ vi.mock('../../utils/openUrl', () => ({ openUrl }));
|
||||
describe('Rewards page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'jwt-abc' } });
|
||||
});
|
||||
|
||||
it('shows a local-only message and skips rewards fetch for local sessions', () => {
|
||||
coreStateMock.mockReturnValue({ snapshot: { sessionToken: 'header.payload.local' } });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/rewards']}>
|
||||
<Rewards />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(rewardsApi.getMyRewards).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local login does not earn rewards, coupons, or referral credit. To earn rewards, log out and continue by signing in with an OpenHuman account.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders backend-backed achievements', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import '../../test/mockDefaultSkillStatusHooks';
|
||||
@@ -9,6 +9,8 @@ let composioRefresh = vi.fn();
|
||||
let composioError: string | null = null;
|
||||
let composioToolkits: string[] = [];
|
||||
let composioConnectionByToolkit = new Map();
|
||||
let sessionToken = 'jwt-abc';
|
||||
let composioModeStatus = { result: { mode: 'backend', api_key_set: true }, logs: [] };
|
||||
// CodeRabbit on #2361: failure-path coverage for the agent-ready
|
||||
// RPC requires overriding the hook's state per test. Default state
|
||||
// keeps Preview badges off (loading=true) so legacy assertions on
|
||||
@@ -46,12 +48,32 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
useAgentReadyComposioToolkits: () => agentReadyState,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/coreState/store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../lib/coreState/store')>(
|
||||
'../../lib/coreState/store'
|
||||
);
|
||||
return { ...actual, getCoreStateSnapshot: () => ({ snapshot: { sessionToken } }) };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/tauriCommands', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../utils/tauriCommands')>(
|
||||
'../../utils/tauriCommands'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
openhumanComposioGetMode: vi.fn(async () => composioModeStatus),
|
||||
subconsciousEscalationsDismiss: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Skills page — Composio catalog fallback', () => {
|
||||
beforeEach(() => {
|
||||
composioRefresh = vi.fn();
|
||||
composioError = null;
|
||||
composioToolkits = [];
|
||||
composioConnectionByToolkit = new Map();
|
||||
sessionToken = 'jwt-abc';
|
||||
composioModeStatus = { result: { mode: 'backend', api_key_set: true }, logs: [] };
|
||||
agentReadyState = { agentReady: new Set<string>(), loading: true, error: null };
|
||||
});
|
||||
|
||||
@@ -153,4 +175,19 @@ describe('Skills page — Composio catalog fallback', () => {
|
||||
);
|
||||
expect(previewBadges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows a local-mode composio API key banner when no key is configured', async () => {
|
||||
sessionToken = 'header.payload.local';
|
||||
composioModeStatus = { result: { mode: 'direct', api_key_set: false }, logs: [] };
|
||||
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No Composio API Key Configured/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Local mode uses your own Composio API key/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Open.*Settings/i })).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Search skills…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Gmail')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,12 @@ import { PRIVACY_POLICY_URL, TERMS_OF_USE_URL } from '../../utils/links';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import Welcome from '../Welcome';
|
||||
|
||||
const mockStoreSessionToken = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('../../providers/CoreStateProvider', () => ({
|
||||
useCoreState: () => ({ storeSessionToken: mockStoreSessionToken }),
|
||||
}));
|
||||
|
||||
const oauthButtonSpy = vi.fn();
|
||||
const oauthOverrideSpy = vi.fn();
|
||||
|
||||
@@ -54,6 +60,12 @@ vi.mock('../../components/oauth/providerConfigs', () => ({
|
||||
|
||||
vi.mock('../../store/deepLinkAuthState', () => ({ useDeepLinkAuthState: vi.fn() }));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
const { mockClearAllAppData } = vi.hoisted(() => ({
|
||||
mockClearAllAppData: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -313,3 +325,57 @@ describe('Welcome — OAuth buttons presence', () => {
|
||||
expect(screen.queryByRole('button', { name: 'google' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome — local login', () => {
|
||||
beforeEach(() => {
|
||||
mockStoreSessionToken.mockReset().mockResolvedValue(undefined);
|
||||
mockNavigate.mockReset();
|
||||
vi.mocked(useDeepLinkAuthState).mockReturnValue({
|
||||
isProcessing: false,
|
||||
errorMessage: null,
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the "Continue locally" button regardless of runtime mode', () => {
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Continue locally/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "Continue locally" button in cloud mode too', () => {
|
||||
renderWithProviders(<Welcome />, {
|
||||
preloadedState: { coreMode: { mode: { kind: 'cloud', url: 'http://x', token: 't' } } },
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /Continue locally/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls storeSessionToken with a local session token and navigates to /home', async () => {
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
const localBtn = screen.getByRole('button', { name: /Continue locally/i });
|
||||
fireEvent.click(localBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStoreSessionToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const [tokenArg, userArg] = mockStoreSessionToken.mock.calls[0];
|
||||
expect(tokenArg).toContain('local');
|
||||
expect(userArg).toEqual(expect.objectContaining({ id: 'local' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/onboarding/custom/inference', { replace: true });
|
||||
});
|
||||
|
||||
it('shows error when storeSessionToken rejects', async () => {
|
||||
mockStoreSessionToken.mockRejectedValueOnce(new Error('token save failed'));
|
||||
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
const localBtn = screen.getByRole('button', { name: /Continue locally/i });
|
||||
fireEvent.click(localBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/token save failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import CustomInferencePage from './pages/CustomInferencePage';
|
||||
// disk; uncomment alongside customWizardSteps.ts to re-enable):
|
||||
// import CustomMemoryPage from './pages/CustomMemoryPage';
|
||||
import CustomOAuthPage from './pages/CustomOAuthPage';
|
||||
// import CustomSearchPage from './pages/CustomSearchPage';
|
||||
import CustomSearchPage from './pages/CustomSearchPage';
|
||||
import CustomVoicePage from './pages/CustomVoicePage';
|
||||
import RuntimeChoicePage from './pages/RuntimeChoicePage';
|
||||
import WelcomePage from './pages/WelcomePage';
|
||||
@@ -36,7 +36,7 @@ const Onboarding = () => {
|
||||
<Route path="custom/inference" element={<CustomInferencePage />} />
|
||||
<Route path="custom/voice" element={<CustomVoicePage />} />
|
||||
<Route path="custom/oauth" element={<CustomOAuthPage />} />
|
||||
{/* <Route path="custom/search" element={<CustomSearchPage />} /> */}
|
||||
<Route path="custom/search" element={<CustomSearchPage />} />
|
||||
{/* <Route path="custom/memory" element={<CustomMemoryPage />} /> */}
|
||||
<Route path="*" element={<Navigate to="welcome" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -7,7 +7,7 @@ export const CUSTOM_WIZARD_STEPS: CustomStepKey[] = [
|
||||
'inference',
|
||||
'voice',
|
||||
'oauth',
|
||||
// 'search',
|
||||
'search',
|
||||
// 'memory',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { 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 { I18nProvider } from '../../../lib/i18n/I18nContext';
|
||||
import type { Locale } from '../../../lib/i18n/types';
|
||||
import localeReducer from '../../../store/localeSlice';
|
||||
import CustomInferencePage from './CustomInferencePage';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const setDraftMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => navigateMock };
|
||||
});
|
||||
|
||||
vi.mock('../../../components/settings/panels/AIPanel', () => ({
|
||||
default: () => <div data-testid="ai-panel">AI Panel</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({
|
||||
useCoreState: () => ({ snapshot: { sessionToken: 'header.payload.local' } }),
|
||||
}));
|
||||
|
||||
vi.mock('../OnboardingContext', () => ({
|
||||
useOnboardingContext: () => ({
|
||||
draft: { connectedSources: [] },
|
||||
setDraft: setDraftMock,
|
||||
completeAndExit: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const store = configureStore({
|
||||
reducer: { locale: localeReducer },
|
||||
preloadedState: { locale: { current: 'en' as Locale } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<I18nProvider>
|
||||
<CustomInferencePage />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CustomInferencePage', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
setDraftMock.mockReset();
|
||||
});
|
||||
|
||||
it('forces configure mode and hides the default/configure chooser for local sessions', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId('ai-panel')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('onboarding-custom-inference-step-default')
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('onboarding-custom-inference-step-configure')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AIPanel from '../../../components/settings/panels/AIPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'inference' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
const LOCAL_DEFAULT_DISABLED_REASON =
|
||||
'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
|
||||
|
||||
const CustomInferencePage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot } = useCoreState();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalSession) {
|
||||
return;
|
||||
}
|
||||
setChoice('configure');
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
|
||||
}));
|
||||
}, [isLocalSession, setDraft]);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
@@ -37,6 +54,9 @@ const CustomInferencePage = () => {
|
||||
defaultDescription={t('onboarding.custom.inference.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.inference.configureDesc')}
|
||||
configureContent={<AIPanel embedded />}
|
||||
defaultDisabled={isLocalSession}
|
||||
defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
|
||||
hideChoiceCards={isLocalSession}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate('/onboarding/runtime-choice')}
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ComposioPanel from '../../../components/settings/panels/ComposioPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'oauth' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
const LOCAL_DEFAULT_DISABLED_REASON =
|
||||
'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
|
||||
|
||||
const CustomOAuthPage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot } = useCoreState();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalSession) {
|
||||
return;
|
||||
}
|
||||
setChoice('configure');
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
|
||||
}));
|
||||
}, [isLocalSession, setDraft]);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
@@ -36,7 +53,10 @@ const CustomOAuthPage = () => {
|
||||
subtitle={t('onboarding.custom.oauth.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.oauth.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.oauth.configureDesc')}
|
||||
configureContent={<ComposioPanel embedded />}
|
||||
configureContent={<ComposioPanel embedded managedAuthEnabled={false} />}
|
||||
defaultDisabled={isLocalSession}
|
||||
defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
|
||||
hideChoiceCards={isLocalSession}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { 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 { I18nProvider } from '../../../lib/i18n/I18nContext';
|
||||
import type { Locale } from '../../../lib/i18n/types';
|
||||
import localeReducer from '../../../store/localeSlice';
|
||||
import CustomSearchPage from './CustomSearchPage';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const setDraftMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => navigateMock };
|
||||
});
|
||||
|
||||
vi.mock('../../../components/settings/panels/SearchPanel', () => ({
|
||||
default: () => <div data-testid="search-panel">Search Panel</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({
|
||||
useCoreState: () => ({ snapshot: { sessionToken: 'header.payload.local' } }),
|
||||
}));
|
||||
|
||||
vi.mock('../OnboardingContext', () => ({
|
||||
useOnboardingContext: () => ({
|
||||
draft: { connectedSources: [] },
|
||||
setDraft: setDraftMock,
|
||||
completeAndExit: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const store = configureStore({
|
||||
reducer: { locale: localeReducer },
|
||||
preloadedState: { locale: { current: 'en' as Locale } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<I18nProvider>
|
||||
<CustomSearchPage />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CustomSearchPage', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
setDraftMock.mockReset();
|
||||
});
|
||||
|
||||
it('forces configure mode and hides the default/configure chooser for local sessions', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId('search-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('onboarding-custom-search-step-default')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('onboarding-custom-search-step-configure')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ToolsPanel from '../../../components/settings/panels/ToolsPanel';
|
||||
import SearchPanel from '../../../components/settings/panels/SearchPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'search' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
const LOCAL_DEFAULT_DISABLED_REASON =
|
||||
'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
|
||||
|
||||
const CustomSearchPage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { draft, setDraft } = useOnboardingContext();
|
||||
const { snapshot } = useCoreState();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalSession) {
|
||||
return;
|
||||
}
|
||||
setChoice('configure');
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
|
||||
}));
|
||||
}, [isLocalSession, setDraft]);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
};
|
||||
|
||||
const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<CustomWizardStep
|
||||
testId="onboarding-custom-search-step"
|
||||
@@ -34,17 +53,29 @@ const CustomSearchPage = () => {
|
||||
subtitle={t('onboarding.custom.search.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.search.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.search.configureDesc')}
|
||||
configureContent={<ToolsPanel embedded />}
|
||||
configureContent={<SearchPanel embedded />}
|
||||
defaultDisabled={isLocalSession}
|
||||
defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
|
||||
hideChoiceCards={isLocalSession}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
onContinue={() => {
|
||||
onContinue={async () => {
|
||||
trackEvent('onboarding_step_complete', {
|
||||
step_name: 'custom_search',
|
||||
choice: choice ?? 'default',
|
||||
});
|
||||
if (isLast) {
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:custom-search] completeAndExit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
|
||||
}}
|
||||
continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import VoicePanel from '../../../components/settings/panels/VoicePanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'voice' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
const LOCAL_DEFAULT_DISABLED_REASON =
|
||||
'Managed setup requires OpenHuman sign-in and is unavailable in local mode.';
|
||||
|
||||
const CustomVoicePage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot } = useCoreState();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
draft.customChoices?.[STEP_KEY] ?? (isLocalSession ? 'configure' : null)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalSession) {
|
||||
return;
|
||||
}
|
||||
setChoice('configure');
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
customChoices: { ...prev.customChoices, [STEP_KEY]: 'configure' },
|
||||
}));
|
||||
}, [isLocalSession, setDraft]);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
@@ -37,6 +54,9 @@ const CustomVoicePage = () => {
|
||||
defaultDescription={t('onboarding.custom.voice.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.voice.configureDesc')}
|
||||
configureContent={<VoicePanel embedded />}
|
||||
defaultDisabled={isLocalSession}
|
||||
defaultDisabledReason={isLocalSession ? LOCAL_DEFAULT_DISABLED_REASON : undefined}
|
||||
hideChoiceCards={isLocalSession}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
import RuntimeChoiceStep from '../steps/RuntimeChoiceStep';
|
||||
|
||||
const RuntimeChoicePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setDraft, completeAndExit } = useOnboardingContext();
|
||||
const { snapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalSession) {
|
||||
navigate('/onboarding/custom/inference', { replace: true });
|
||||
}
|
||||
}, [isLocalSession, navigate]);
|
||||
|
||||
if (isLocalSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RuntimeChoiceStep
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { isLocalSessionToken } from '../../../utils/localSession';
|
||||
import WelcomeStep from '../steps/WelcomeStep';
|
||||
|
||||
const WelcomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { snapshot } = useCoreState();
|
||||
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('onboarding_start');
|
||||
}, []);
|
||||
if (isLocalSession) {
|
||||
navigate('/onboarding/custom/inference', { replace: true });
|
||||
}
|
||||
}, [isLocalSession, navigate]);
|
||||
|
||||
if (isLocalSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeStep
|
||||
|
||||
@@ -12,9 +12,18 @@ interface ChoiceCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
testId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ChoiceCard = ({ selected, onClick, accent, title, description, testId }: ChoiceCardProps) => {
|
||||
const ChoiceCard = ({
|
||||
selected,
|
||||
onClick,
|
||||
accent,
|
||||
title,
|
||||
description,
|
||||
testId,
|
||||
disabled = false,
|
||||
}: ChoiceCardProps) => {
|
||||
const selectedClasses =
|
||||
accent === 'sage'
|
||||
? '!border-sage-500 bg-sage-50 dark:bg-sage-500/10 shadow-sm'
|
||||
@@ -23,9 +32,10 @@ const ChoiceCard = ({ selected, onClick, accent, title, description, testId }: C
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
data-testid={testId}
|
||||
className={`flex h-full w-full flex-col rounded-2xl border-2 p-5 text-left transition-colors focus:outline-none ${
|
||||
className={`flex h-full w-full flex-col rounded-2xl border-2 p-5 text-left transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
selected
|
||||
? selectedClasses
|
||||
: '!border-stone-200 dark:!border-neutral-700 bg-white dark:bg-neutral-900 hover:!border-stone-300 dark:hover:!border-neutral-600 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
|
||||
@@ -59,6 +69,9 @@ interface CustomWizardStepProps {
|
||||
continueLoading?: boolean;
|
||||
continueLoadingLabel?: string;
|
||||
testId?: string;
|
||||
defaultDisabled?: boolean;
|
||||
defaultDisabledReason?: string;
|
||||
hideChoiceCards?: boolean;
|
||||
}
|
||||
|
||||
const CustomWizardStep = ({
|
||||
@@ -78,6 +91,9 @@ const CustomWizardStep = ({
|
||||
continueLoading,
|
||||
continueLoadingLabel,
|
||||
testId,
|
||||
defaultDisabled = false,
|
||||
defaultDisabledReason,
|
||||
hideChoiceCards = false,
|
||||
}: CustomWizardStepProps) => {
|
||||
const { t } = useT();
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
@@ -113,26 +129,37 @@ const CustomWizardStep = ({
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-stretch">
|
||||
<ChoiceCard
|
||||
testId={`${testId ?? 'onboarding-custom-wizard-step'}-default`}
|
||||
accent="sage"
|
||||
selected={choice === 'default'}
|
||||
onClick={() => onChoiceChange('default')}
|
||||
title={t('onboarding.custom.defaultTitle')}
|
||||
description={defaultDescription || t('onboarding.custom.defaultSubtitle')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
testId={`${testId ?? 'onboarding-custom-wizard-step'}-configure`}
|
||||
accent="primary"
|
||||
selected={choice === 'configure'}
|
||||
onClick={() => onChoiceChange('configure')}
|
||||
title={t('onboarding.custom.configureTitle')}
|
||||
description={configureDescription || t('onboarding.custom.configureSubtitle')}
|
||||
/>
|
||||
</div>
|
||||
{!hideChoiceCards ? (
|
||||
<>
|
||||
<div className="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-stretch">
|
||||
<ChoiceCard
|
||||
testId={`${testId ?? 'onboarding-custom-wizard-step'}-default`}
|
||||
accent="sage"
|
||||
selected={choice === 'default'}
|
||||
onClick={() => onChoiceChange('default')}
|
||||
disabled={defaultDisabled}
|
||||
title={t('onboarding.custom.defaultTitle')}
|
||||
description={defaultDescription || t('onboarding.custom.defaultSubtitle')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
testId={`${testId ?? 'onboarding-custom-wizard-step'}-configure`}
|
||||
accent="primary"
|
||||
selected={choice === 'configure'}
|
||||
onClick={() => onChoiceChange('configure')}
|
||||
title={t('onboarding.custom.configureTitle')}
|
||||
description={configureDescription || t('onboarding.custom.configureSubtitle')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{choice === 'configure' && configureContent ? (
|
||||
{defaultDisabled && defaultDisabledReason ? (
|
||||
<p className="mt-3 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
|
||||
{defaultDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{(choice === 'configure' || hideChoiceCards) && configureContent ? (
|
||||
<div className="mt-6 rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
|
||||
{configureContent}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import debugFactory from 'debug';
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
|
||||
import {
|
||||
type CoreAppSnapshot,
|
||||
type CoreOnboardingTasks,
|
||||
type CoreState,
|
||||
getCoreStateSnapshot,
|
||||
setCoreStateSnapshot,
|
||||
@@ -30,6 +28,7 @@ import { store } from '../store';
|
||||
import { resetUserScopedState } from '../store/resetActions';
|
||||
import { loadThreads, resetThreadCachesPreservingSelection } from '../store/threadSlice';
|
||||
import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage';
|
||||
import { isLocalSessionToken } from '../utils/localSession';
|
||||
import {
|
||||
openhumanUpdateAnalyticsSettings,
|
||||
openhumanUpdateMeetSettings,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
syncMemoryClientToken,
|
||||
logout as tauriLogout,
|
||||
} from '../utils/tauriCommands';
|
||||
import { CoreStateContext, type CoreStateContextValue } from './coreStateContext';
|
||||
|
||||
const log = debugFactory('core-state');
|
||||
|
||||
@@ -114,34 +114,8 @@ function isPlausibleSessionToken(token: unknown): token is string {
|
||||
return payload.exp * 1000 > Date.now();
|
||||
}
|
||||
|
||||
interface CoreStateContextValue extends CoreState {
|
||||
refresh: () => Promise<void>;
|
||||
refreshTeams: () => Promise<void>;
|
||||
refreshTeamMembers: (teamId: string) => Promise<void>;
|
||||
refreshTeamInvites: (teamId: string) => Promise<void>;
|
||||
setAnalyticsEnabled: (enabled: boolean) => Promise<void>;
|
||||
setMeetAutoOrchestratorHandoff: (enabled: boolean) => Promise<void>;
|
||||
setOnboardingCompletedFlag: (value: boolean) => Promise<void>;
|
||||
setEncryptionKey: (value: string | null) => Promise<void>;
|
||||
/**
|
||||
* Shallow-merge `patch` into `state.snapshot`. Top-level keys in `patch`
|
||||
* REPLACE the existing value — they are not deep-merged.
|
||||
*
|
||||
* This means passing a nested object (e.g. `{ localState: { encryptionKey: 'x' } }`)
|
||||
* will CLOBBER sibling fields on that object (`onboardingTasks`). Only flat
|
||||
* top-level fields are safe to patch directly:
|
||||
* `currentUser`, `onboardingCompleted`, `chatOnboardingCompleted`,
|
||||
* `analyticsEnabled`, `sessionToken`. For nested-object updates, use the
|
||||
* dedicated setter (`setEncryptionKey`, `setOnboardingTasks`) which
|
||||
* preserves siblings.
|
||||
*/
|
||||
patchSnapshot: (patch: Partial<CoreAppSnapshot>) => void;
|
||||
setOnboardingTasks: (value: CoreOnboardingTasks | null) => Promise<void>;
|
||||
storeSessionToken: (token: string, user?: object) => Promise<void>;
|
||||
clearSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CoreStateContext = createContext<CoreStateContextValue | null>(null);
|
||||
// CoreStateContextValue and CoreStateContext are defined in ./coreStateContext.ts
|
||||
// to avoid mock-interception issues when tests vi.mock this module.
|
||||
|
||||
function snapshotIdentity(snapshot: CoreAppSnapshot): string | null {
|
||||
return snapshot.auth.userId ?? snapshot.currentUser?._id ?? null;
|
||||
@@ -290,7 +264,8 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
// - poll-detected flip (core-side user swap)
|
||||
// - re-login as a different user after sign-out
|
||||
const seedUserId = getActiveUserId();
|
||||
const isFlip = Boolean(nextIdentity) && seedUserId !== nextIdentity;
|
||||
const isLocalSession = isLocalSessionToken(nextSnapshot.sessionToken);
|
||||
const isFlip = Boolean(nextIdentity) && seedUserId !== nextIdentity && !isLocalSession;
|
||||
const isLogout = Boolean(previousAuthed) && !nextAuthed;
|
||||
// Clear team caches whenever the visible identity changes (in-memory user
|
||||
// shift) so the post-commit UI doesn't show user A's team list during the
|
||||
@@ -345,6 +320,10 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
});
|
||||
}
|
||||
|
||||
if (nextIdentity && isLocalSession && seedUserId !== nextIdentity) {
|
||||
setActiveUserId(nextIdentity);
|
||||
}
|
||||
|
||||
if (isFlip && nextIdentity) {
|
||||
await handleIdentityFlip({ reason: 'identity-flip', nextUserId: nextIdentity }).catch(err => {
|
||||
log('handleIdentityFlip failed: %O', sanitizeError(err));
|
||||
@@ -463,7 +442,10 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
await doRefresh();
|
||||
if (!cancelled) {
|
||||
const next = getCoreStateSnapshot();
|
||||
if (next.snapshot.auth.isAuthenticated) {
|
||||
if (
|
||||
next.snapshot.auth.isAuthenticated &&
|
||||
!isLocalSessionToken(next.snapshot.sessionToken)
|
||||
) {
|
||||
await refreshTeams().catch(err => {
|
||||
log('refreshTeams failed during bootstrap: %O', sanitizeError(err));
|
||||
});
|
||||
@@ -609,9 +591,11 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
await refresh().catch(err => {
|
||||
log('refresh failed after session store: %O', sanitizeError(err));
|
||||
});
|
||||
await refreshTeams().catch(err => {
|
||||
log('refreshTeams failed after session store: %O', sanitizeError(err));
|
||||
});
|
||||
if (!isLocalSessionToken(token)) {
|
||||
await refreshTeams().catch(err => {
|
||||
log('refreshTeams failed after session store: %O', sanitizeError(err));
|
||||
});
|
||||
}
|
||||
},
|
||||
[refresh, refreshTeams]
|
||||
);
|
||||
@@ -681,6 +665,10 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
// so re-registers are rare.
|
||||
useEffect(() => {
|
||||
const runReauth = (method: string, source: string) => {
|
||||
if (isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken)) {
|
||||
log('auth-expired ignored for local session (method=%s source=%s)', method, source);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now < suppressReauthUntilRef.current) {
|
||||
log(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { socketService } from '../services/socketService';
|
||||
import { setBackend, setCore } from '../store/connectivitySlice';
|
||||
import { store } from '../store/index';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { isLocalSessionToken } from '../utils/localSession';
|
||||
import { useCoreState } from './CoreStateProvider';
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,7 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
// Handle socket connection based on token
|
||||
useEffect(() => {
|
||||
const previousToken = previousTokenRef.current;
|
||||
const localSession = isLocalSessionToken(token);
|
||||
|
||||
// Token was set - connect
|
||||
if (token && token !== previousToken) {
|
||||
@@ -47,33 +49,35 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
socketService.connect(token);
|
||||
// Also connect the Rust sidecar to backend-alphahuman so inbound
|
||||
// Discord/Telegram managed-DM messages reach the agent loop.
|
||||
void callCoreRpc({ method: 'openhuman.socket_connect_with_session', params: {} }).catch(
|
||||
(err: unknown) => {
|
||||
// Non-fatal: sidecar may not be running yet or backend unreachable.
|
||||
console.error(
|
||||
'[SocketProvider] openhuman.socket_connect_with_session: RPC connection failed (non-fatal) — sidecar may not be running yet or backend unreachable',
|
||||
err
|
||||
);
|
||||
// (#1527) Surface the failure into the core connectivity channel so
|
||||
// the UI can show an actionable "core offline" state instead of a
|
||||
// single conflated "Disconnected" pill. coreHealthMonitor will flip
|
||||
// the state back to `reachable` once the sidecar answers the next
|
||||
// poll.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Route the failure to the right channel: transport/connection errors
|
||||
// (ECONNREFUSED, fetch failure) mean the local core sidecar is
|
||||
// unreachable; everything else is a backend-level rejection and should
|
||||
// not pop the "core offline" blocking screen. (addresses @coderabbitai
|
||||
// on SocketProvider.tsx:63)
|
||||
const isCoreTransportFailure =
|
||||
/ECONNREFUSED|ERR_CONNECTION_REFUSED|Failed to fetch|NetworkError/i.test(message);
|
||||
if (isCoreTransportFailure) {
|
||||
store.dispatch(setCore({ value: 'unreachable', error: message }));
|
||||
} else {
|
||||
store.dispatch(setBackend({ value: 'disconnected', error: message }));
|
||||
if (!localSession) {
|
||||
void callCoreRpc({ method: 'openhuman.socket_connect_with_session', params: {} }).catch(
|
||||
(err: unknown) => {
|
||||
// Non-fatal: sidecar may not be running yet or backend unreachable.
|
||||
console.error(
|
||||
'[SocketProvider] openhuman.socket_connect_with_session: RPC connection failed (non-fatal) — sidecar may not be running yet or backend unreachable',
|
||||
err
|
||||
);
|
||||
// (#1527) Surface the failure into the core connectivity channel so
|
||||
// the UI can show an actionable "core offline" state instead of a
|
||||
// single conflated "Disconnected" pill. coreHealthMonitor will flip
|
||||
// the state back to `reachable` once the sidecar answers the next
|
||||
// poll.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Route the failure to the right channel: transport/connection errors
|
||||
// (ECONNREFUSED, fetch failure) mean the local core sidecar is
|
||||
// unreachable; everything else is a backend-level rejection and should
|
||||
// not pop the "core offline" blocking screen. (addresses @coderabbitai
|
||||
// on SocketProvider.tsx:63)
|
||||
const isCoreTransportFailure =
|
||||
/ECONNREFUSED|ERR_CONNECTION_REFUSED|Failed to fetch|NetworkError/i.test(message);
|
||||
if (isCoreTransportFailure) {
|
||||
store.dispatch(setCore({ value: 'unreachable', error: message }));
|
||||
} else {
|
||||
store.dispatch(setBackend({ value: 'disconnected', error: message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Token was unset - disconnect
|
||||
|
||||
@@ -476,6 +476,63 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
|
||||
expect(screen.getByTestId('token').textContent).toBe(token);
|
||||
});
|
||||
|
||||
it('storeSessionToken skips refreshTeams for a local session token', async () => {
|
||||
const localToken = `eyJhbGciOiJub25lIn0.${window.btoa(JSON.stringify({ sub: 'local' }))}.local`;
|
||||
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'local', sessionToken: localToken }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
vi.mocked(tauriCommands.storeSession).mockReset();
|
||||
vi.mocked(tauriCommands.storeSession).mockResolvedValue(undefined as never);
|
||||
|
||||
let ctx: CoreStateContextValue | undefined;
|
||||
render(
|
||||
<CoreStateProvider>
|
||||
<Consumer
|
||||
captureCtx={next => {
|
||||
ctx = next;
|
||||
}}
|
||||
/>
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
|
||||
await act(async () => {
|
||||
await ctx!.storeSessionToken(localToken, { id: 'local' });
|
||||
});
|
||||
|
||||
expect(vi.mocked(tauriCommands.storeSession)).toHaveBeenCalledWith(localToken, { id: 'local' });
|
||||
expect(listTeams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores auth-expired events when the current session is a local token', async () => {
|
||||
const localToken = `eyJhbGciOiJub25lIn0.${window.btoa(JSON.stringify({ sub: 'local' }))}.local`;
|
||||
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'local', sessionToken: localToken }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
vi.mocked(tauriCommands.logout).mockReset();
|
||||
vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never);
|
||||
|
||||
render(
|
||||
<CoreStateProvider>
|
||||
<Consumer />
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('core-rpc-auth-expired', {
|
||||
detail: { method: 'openhuman.team_get_usage', source: 'rpc' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Wait a tick to ensure any async handler had a chance to run.
|
||||
await act(async () => {});
|
||||
|
||||
expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setMeetAutoOrchestratorHandoff(true) calls update RPC + flips snapshot optimistically (#1299)', async () => {
|
||||
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
|
||||
@@ -214,4 +214,19 @@ describe('SocketProvider — RPC failure dispatches (lines 62, 69-71, 73)', () =
|
||||
|
||||
expect(setCoreMock).toHaveBeenCalledWith(expect.objectContaining({ value: 'unreachable' }));
|
||||
});
|
||||
|
||||
it('connects socket but skips sidecar backend-session RPC for a local session token', () => {
|
||||
setToken('eyJhbGciOiJub25lIn0.dGVzdA.local');
|
||||
render(
|
||||
<SocketProvider>
|
||||
<div />
|
||||
</SocketProvider>
|
||||
);
|
||||
|
||||
expect(vi.mocked(socketService.connect)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(socketService.connect)).toHaveBeenCalledWith(
|
||||
'eyJhbGciOiJub25lIn0.dGVzdA.local'
|
||||
);
|
||||
expect(vi.mocked(callCoreRpc)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { CoreAppSnapshot, CoreOnboardingTasks, CoreState } from '../lib/coreState/store';
|
||||
|
||||
export interface CoreStateContextValue extends CoreState {
|
||||
refresh: () => Promise<void>;
|
||||
refreshTeams: () => Promise<void>;
|
||||
refreshTeamMembers: (teamId: string) => Promise<void>;
|
||||
refreshTeamInvites: (teamId: string) => Promise<void>;
|
||||
setAnalyticsEnabled: (enabled: boolean) => Promise<void>;
|
||||
setMeetAutoOrchestratorHandoff: (enabled: boolean) => Promise<void>;
|
||||
setOnboardingCompletedFlag: (value: boolean) => Promise<void>;
|
||||
setEncryptionKey: (value: string | null) => Promise<void>;
|
||||
patchSnapshot: (patch: Partial<CoreAppSnapshot>) => void;
|
||||
setOnboardingTasks: (value: CoreOnboardingTasks | null) => Promise<void>;
|
||||
storeSessionToken: (token: string, user?: object) => Promise<void>;
|
||||
clearSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const CoreStateContext = createContext<CoreStateContextValue | null>(null);
|
||||
@@ -8,6 +8,8 @@ import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { getCoreStateSnapshot } from '../lib/coreState/store';
|
||||
import { CoreStateContext } from '../providers/coreStateContext';
|
||||
import channelConnectionsReducer from '../store/channelConnectionsSlice';
|
||||
import companionReducer from '../store/companionSlice';
|
||||
import connectivityReducer from '../store/connectivitySlice';
|
||||
@@ -58,10 +60,28 @@ export function renderWithProviders(
|
||||
...renderOptions
|
||||
}: ExtendedRenderOptions = {}
|
||||
) {
|
||||
const coreStateStub = {
|
||||
...getCoreStateSnapshot(),
|
||||
refresh: async () => {},
|
||||
refreshTeams: async () => {},
|
||||
refreshTeamMembers: async () => {},
|
||||
refreshTeamInvites: async () => {},
|
||||
setAnalyticsEnabled: async () => {},
|
||||
setMeetAutoOrchestratorHandoff: async () => {},
|
||||
setOnboardingCompletedFlag: async () => {},
|
||||
setEncryptionKey: async () => {},
|
||||
patchSnapshot: () => {},
|
||||
setOnboardingTasks: async () => {},
|
||||
storeSessionToken: async () => {},
|
||||
clearSession: async () => {},
|
||||
};
|
||||
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||
<CoreStateContext.Provider value={coreStateStub}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||
</CoreStateContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createLocalSessionToken,
|
||||
isLocalSessionToken,
|
||||
LOCAL_SESSION_USER,
|
||||
LOCAL_SESSION_USER_ID,
|
||||
} from './localSession';
|
||||
|
||||
function decodeBase64UrlJson<T>(value: string): T {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
||||
return JSON.parse(globalThis.atob(padded)) as T;
|
||||
}
|
||||
|
||||
describe('localSession', () => {
|
||||
it('creates a local JWT-shaped token with the local signature marker', () => {
|
||||
const token = createLocalSessionToken(1_700_000_000_000);
|
||||
const [headerPart, payloadPart, signaturePart] = token.split('.');
|
||||
|
||||
expect(signaturePart).toBe('local');
|
||||
expect(isLocalSessionToken(token)).toBe(true);
|
||||
expect(decodeBase64UrlJson<Record<string, unknown>>(headerPart)).toEqual({
|
||||
alg: 'none',
|
||||
typ: 'JWT',
|
||||
});
|
||||
expect(decodeBase64UrlJson<Record<string, unknown>>(payloadPart)).toMatchObject({
|
||||
sub: LOCAL_SESSION_USER_ID,
|
||||
user_id: LOCAL_SESSION_USER_ID,
|
||||
iat: 1_700_000_000,
|
||||
exp: 1_731_536_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires exactly three token parts and a local marker', () => {
|
||||
expect(isLocalSessionToken('header.payload.local')).toBe(true);
|
||||
expect(isLocalSessionToken('header.payload.remote')).toBe(false);
|
||||
expect(isLocalSessionToken('header.payload.local.extra')).toBe(false);
|
||||
expect(isLocalSessionToken('not-a-jwt')).toBe(false);
|
||||
expect(isLocalSessionToken(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('exports the local user payload used by the welcome flow', () => {
|
||||
expect(LOCAL_SESSION_USER).toEqual({
|
||||
_id: LOCAL_SESSION_USER_ID,
|
||||
id: LOCAL_SESSION_USER_ID,
|
||||
name: 'Local User',
|
||||
email: 'local@openhuman.local',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export const LOCAL_SESSION_USER_ID = 'local';
|
||||
|
||||
export const LOCAL_SESSION_USER = {
|
||||
_id: LOCAL_SESSION_USER_ID,
|
||||
id: LOCAL_SESSION_USER_ID,
|
||||
name: 'Local User',
|
||||
email: 'local@openhuman.local',
|
||||
};
|
||||
|
||||
function base64UrlEncode(value: object): string {
|
||||
return globalThis
|
||||
.btoa(JSON.stringify(value))
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
export function createLocalSessionToken(nowMs = Date.now()): string {
|
||||
const now = Math.floor(nowMs / 1000);
|
||||
return [
|
||||
base64UrlEncode({ alg: 'none', typ: 'JWT' }),
|
||||
base64UrlEncode({
|
||||
sub: LOCAL_SESSION_USER_ID,
|
||||
user_id: LOCAL_SESSION_USER_ID,
|
||||
iat: now,
|
||||
exp: now + 31536000,
|
||||
}),
|
||||
'local',
|
||||
].join('.');
|
||||
}
|
||||
|
||||
export function isLocalSessionToken(token: string | null | undefined): boolean {
|
||||
if (!token) return false;
|
||||
const parts = token.split('.');
|
||||
return parts.length === 3 && parts[2] === 'local';
|
||||
}
|
||||
+9
-3
@@ -186,9 +186,15 @@ pub async fn invoke_method(state: AppState, method: &str, params: Value) -> Resu
|
||||
method,
|
||||
sanitized_reason
|
||||
);
|
||||
// Scrub before publishing — subscribers log `reason`, and the
|
||||
// upstream error string could include API keys / tokens from
|
||||
// pasted-through provider replies.
|
||||
// pasted-through provider replies. `sanitize_api_error` runs
|
||||
// `scrub_secret_patterns` and truncates.
|
||||
//
|
||||
// Local-session protection is handled by `SessionExpiredSubscriber`
|
||||
// in `src/openhuman/credentials/bus.rs` — it checks `is_local_session_token`
|
||||
// after config load and short-circuits teardown with
|
||||
// `scheduler_gate::set_signed_out(false)`. Duplicating that check
|
||||
// here would pull a domain concern into the transport layer and would
|
||||
// add an extra config-load round-trip on every 401.
|
||||
crate::core::event_bus::publish_global(
|
||||
crate::core::event_bus::DomainEvent::SessionExpired {
|
||||
source: format!("jsonrpc.invoke_method:{method}"),
|
||||
|
||||
@@ -20,7 +20,8 @@ use crate::openhuman::autocomplete::AutocompleteStatus;
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::credentials::session_support::{
|
||||
load_app_session_profile, session_state_from_profile, session_token_from_profile,
|
||||
is_local_session_token, load_app_session_profile, session_state_from_profile,
|
||||
session_token_from_profile,
|
||||
};
|
||||
use crate::openhuman::inference::LocalAiStatus;
|
||||
use crate::openhuman::screen_intelligence::AccessibilityStatus;
|
||||
@@ -539,20 +540,24 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
|
||||
let session_token = session_token_from_profile(session_profile.as_ref());
|
||||
let stored_user = sanitize_snapshot_user(auth.user.clone());
|
||||
let current_user = if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) {
|
||||
match tokio::time::timeout(
|
||||
AUTH_FETCH_TIMEOUT,
|
||||
fetch_current_user_cached(&config, &token),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(fresh_user)) => fresh_user.or(stored_user.clone()),
|
||||
Ok(Err(error)) => {
|
||||
warn!("{LOG_PREFIX} current user refresh failed; using stored snapshot fallback: {error}");
|
||||
stored_user.clone()
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("{LOG_PREFIX} current user fetch timed out after {}s; using stored snapshot fallback", AUTH_FETCH_TIMEOUT.as_secs());
|
||||
stored_user.clone()
|
||||
if is_local_session_token(&token) {
|
||||
stored_user.clone()
|
||||
} else {
|
||||
match tokio::time::timeout(
|
||||
AUTH_FETCH_TIMEOUT,
|
||||
fetch_current_user_cached(&config, &token),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(fresh_user)) => fresh_user.or(stored_user.clone()),
|
||||
Ok(Err(error)) => {
|
||||
warn!("{LOG_PREFIX} current user refresh failed; using stored snapshot fallback: {error}");
|
||||
stored_user.clone()
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("{LOG_PREFIX} current user fetch timed out after {}s; using stored snapshot fallback", AUTH_FETCH_TIMEOUT.as_secs());
|
||||
stored_user.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -55,22 +55,43 @@ impl EventHandler for SessionExpiredSubscriber {
|
||||
return;
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
reason = %reason,
|
||||
"[auth] SessionExpired received — pausing background LLM work and clearing session"
|
||||
);
|
||||
|
||||
// (1) Stand down background workers immediately. Cheap atomic — safe
|
||||
// even if called repeatedly from concurrent publishers.
|
||||
// (1) Stand down background workers immediately — before any async work.
|
||||
// Cheap atomic flip; safe to call repeatedly from concurrent publishers.
|
||||
// We may override this back to `false` below if the current session
|
||||
// turns out to be a local offline session (never truly expired).
|
||||
scheduler_gate::set_signed_out(true);
|
||||
|
||||
// (2) Tear down the session. We must call clear_session against a
|
||||
// loaded config; if the config can't load (rare — disk issue),
|
||||
// we've at least pinned the scheduler gate so background work
|
||||
// can't make things worse.
|
||||
match crate::openhuman::config::rpc::load_config_with_timeout().await {
|
||||
Ok(config) => {
|
||||
let is_local_session = crate::api::jwt::get_session_token(&config)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|token| {
|
||||
crate::openhuman::credentials::session_support::is_local_session_token(
|
||||
&token,
|
||||
)
|
||||
});
|
||||
if is_local_session {
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
reason = %reason,
|
||||
"[auth] SessionExpired ignored for local offline session — re-enabling scheduler gate"
|
||||
);
|
||||
// Undo the eager flip: local sessions are never truly expired.
|
||||
scheduler_gate::set_signed_out(false);
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
reason = %reason,
|
||||
"[auth] SessionExpired received — pausing background LLM work and clearing session"
|
||||
);
|
||||
|
||||
// (2) Tear down the session. We must call clear_session against a
|
||||
// loaded config; if the config can't load (rare — disk issue),
|
||||
// we've at least pinned the scheduler gate so background work
|
||||
// can't make things worse.
|
||||
if let Err(err) = crate::openhuman::credentials::rpc::clear_session(&config).await {
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
@@ -85,6 +106,8 @@ impl EventHandler for SessionExpiredSubscriber {
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// set_signed_out(true) was already called above; scheduler gate
|
||||
// is pinned even though we cannot clear the JWT this cycle.
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
error = %err,
|
||||
|
||||
@@ -7,7 +7,8 @@ use crate::api::jwt::get_session_token;
|
||||
use crate::api::rest::{user_id_from_profile_payload, BackendOAuthClient};
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::credentials::session_support::{
|
||||
build_session_state, parse_fields_value, profile_name_or_default, summarize_auth_profile,
|
||||
build_session_state, is_local_session_token, local_session_user_id, parse_fields_value,
|
||||
profile_name_or_default, summarize_auth_profile, LOCAL_SESSION_USER_ID,
|
||||
};
|
||||
use crate::openhuman::security::SecretStore;
|
||||
use crate::rpc::RpcOutcome;
|
||||
@@ -128,24 +129,43 @@ pub async fn store_session(
|
||||
}
|
||||
|
||||
let api_url = effective_backend_api_url(&config.api_url);
|
||||
|
||||
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
|
||||
let settings = client
|
||||
.fetch_current_user(trimmed_token)
|
||||
.await
|
||||
.map_err(|e| format!("Session validation failed (GET /auth/me): {e:#}"))?;
|
||||
let local_session = is_local_session_token(trimmed_token);
|
||||
let local_user_id = local_session.then(local_session_user_id);
|
||||
let settings = if local_session {
|
||||
sanitize_stored_session_user(user.clone())
|
||||
.map(|value| {
|
||||
normalize_local_session_user(
|
||||
value,
|
||||
local_user_id.as_deref().unwrap_or(LOCAL_SESSION_USER_ID),
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| "local session requires a user payload".to_string())?
|
||||
} else {
|
||||
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
|
||||
client
|
||||
.fetch_current_user(trimmed_token)
|
||||
.await
|
||||
.map_err(|e| format!("Session validation failed (GET /auth/me): {e:#}"))?
|
||||
};
|
||||
|
||||
let mut metadata = std::collections::HashMap::new();
|
||||
if let Some(uid) = user_id
|
||||
.and_then(|v| {
|
||||
let t = v.trim().to_string();
|
||||
(!t.is_empty()).then_some(t)
|
||||
})
|
||||
.or_else(|| user_id_from_profile_payload(&settings))
|
||||
{
|
||||
if let Some(uid) = if local_session {
|
||||
local_user_id.clone()
|
||||
} else {
|
||||
user_id
|
||||
.and_then(|v| {
|
||||
let t = v.trim().to_string();
|
||||
(!t.is_empty()).then_some(t)
|
||||
})
|
||||
.or_else(|| user_id_from_profile_payload(&settings))
|
||||
} {
|
||||
metadata.insert("user_id".to_string(), uid);
|
||||
}
|
||||
let user_for_store = sanitize_stored_session_user(user).unwrap_or(settings);
|
||||
let user_for_store = if local_session {
|
||||
settings.clone()
|
||||
} else {
|
||||
sanitize_stored_session_user(user).unwrap_or(settings)
|
||||
};
|
||||
metadata.insert("user_json".to_string(), user_for_store.to_string());
|
||||
|
||||
// Determine user_id so we can scope the openhuman directory to this user.
|
||||
@@ -153,10 +173,14 @@ pub async fn store_session(
|
||||
|
||||
// If we know the user_id, activate the user-scoped directory BEFORE storing
|
||||
// the auth profile so that credentials land in the correct place.
|
||||
let mut logs = vec![format!(
|
||||
"session JWT verified via GET /auth/me on {}",
|
||||
api_url.trim_end_matches('/')
|
||||
)];
|
||||
let mut logs = if local_session {
|
||||
vec!["local session accepted without backend validation".to_string()]
|
||||
} else {
|
||||
vec![format!(
|
||||
"session JWT verified via GET /auth/me on {}",
|
||||
api_url.trim_end_matches('/')
|
||||
)]
|
||||
};
|
||||
|
||||
if let Some(ref uid) = resolved_user_id {
|
||||
if let Ok(root_dir) = default_root_openhuman_dir() {
|
||||
@@ -233,6 +257,15 @@ pub async fn store_session(
|
||||
config.clone()
|
||||
};
|
||||
|
||||
if local_session {
|
||||
match crate::openhuman::config::ops::set_onboarding_completed(false).await {
|
||||
Ok(_) => logs.push("onboarding left incomplete for local session setup".to_string()),
|
||||
Err(error) => logs.push(format!(
|
||||
"onboarding setup warning for local session: {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
let auth = AuthService::from_config(&effective_config);
|
||||
let profile = auth
|
||||
.store_provider_token(
|
||||
@@ -280,6 +313,22 @@ fn sanitize_stored_session_user(user: Option<serde_json::Value>) -> Option<serde
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_local_session_user(user: serde_json::Value, local_user_id: &str) -> serde_json::Value {
|
||||
let mut map = match user {
|
||||
serde_json::Value::Object(map) => map,
|
||||
other => return other,
|
||||
};
|
||||
map.insert(
|
||||
"id".to_string(),
|
||||
serde_json::Value::String(local_user_id.to_string()),
|
||||
);
|
||||
map.insert(
|
||||
"_id".to_string(),
|
||||
serde_json::Value::String(local_user_id.to_string()),
|
||||
);
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
pub async fn clear_session(config: &Config) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
// Flip the scheduler-gate override first so any background worker that
|
||||
// is mid-iteration (or wakes up while we tear down) stalls at its next
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::openhuman::credentials::session_support::local_session_user_id;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -73,6 +74,96 @@ fn sanitize_stored_session_user_discards_empty_objects() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── store_session (local session) ─────────────────────────────
|
||||
|
||||
/// A local session token requires a non-empty user payload — the backend
|
||||
/// fetch path is bypassed entirely, so there is no fallback to derive the
|
||||
/// user from an API response.
|
||||
#[tokio::test]
|
||||
async fn store_session_local_token_rejects_missing_user_payload() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let local_token = "header.payload.local";
|
||||
let err = store_session(&config, local_token, None, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("local session requires a user payload"),
|
||||
"expected 'local session requires a user payload', got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A local session token with a user payload must be accepted without any
|
||||
/// network call, must force a deterministic `local-<device>` user id
|
||||
/// regardless of what the caller passes, and must return a stored profile
|
||||
/// summary.
|
||||
#[tokio::test]
|
||||
async fn store_session_local_token_succeeds_without_network_and_forces_local_user_id() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let local_token = "header.payload.local";
|
||||
let user = serde_json::json!({
|
||||
"id": "local",
|
||||
"name": "Local User",
|
||||
"email": "local@openhuman.local"
|
||||
});
|
||||
// Pass a different user_id to verify it is overridden.
|
||||
let result = store_session(
|
||||
&config,
|
||||
local_token,
|
||||
Some("should-be-overridden".to_string()),
|
||||
Some(user),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Profile must be stored (no network call was required).
|
||||
assert!(
|
||||
result.value.has_token,
|
||||
"local session should result in a stored token"
|
||||
);
|
||||
// Logs must mention that backend validation was skipped.
|
||||
let log_text = result.logs.join(" ");
|
||||
assert!(
|
||||
log_text.contains("local session accepted without backend validation"),
|
||||
"expected log confirming no backend call, got: {log_text}"
|
||||
);
|
||||
let expected_local_user_id = local_session_user_id();
|
||||
assert!(
|
||||
log_text.contains(&format!(
|
||||
"user directory activated for {expected_local_user_id}"
|
||||
)),
|
||||
"expected user-directory activation log for deterministic local uid, got: {log_text}"
|
||||
);
|
||||
assert!(
|
||||
log_text.contains("onboarding left incomplete for local session setup"),
|
||||
"expected local session to remain in onboarding, got: {log_text}"
|
||||
);
|
||||
// The profile_id or metadata must reflect the forced user_id.
|
||||
// Because store_session re-activates the user directory and reloads
|
||||
// config (so it picks up the user-scoped workspace path), we verify
|
||||
// via the returned profile summary rather than a secondary profile lookup.
|
||||
assert_eq!(
|
||||
result.value.provider, "app-session",
|
||||
"profile must be stored under the app-session provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_local_session_user_overwrites_id_fields() {
|
||||
let out = normalize_local_session_user(
|
||||
json!({
|
||||
"id": "old",
|
||||
"_id": "old",
|
||||
"name": "Local User"
|
||||
}),
|
||||
"local-device-123",
|
||||
);
|
||||
|
||||
assert_eq!(out["id"], "local-device-123");
|
||||
assert_eq!(out["_id"], "local-device-123");
|
||||
assert_eq!(out["name"], "Local User");
|
||||
}
|
||||
|
||||
// ── clear_session ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -8,6 +8,43 @@ use super::AuthService;
|
||||
|
||||
use super::{APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME};
|
||||
|
||||
pub const LOCAL_SESSION_USER_ID: &str = "local";
|
||||
|
||||
pub fn local_session_user_id() -> String {
|
||||
let host = hostname::get()
|
||||
.ok()
|
||||
.and_then(|value| value.into_string().ok())
|
||||
.unwrap_or_default();
|
||||
let slug = slugify_local_session_host(&host);
|
||||
format!("local-{slug}")
|
||||
}
|
||||
|
||||
fn slugify_local_session_host(host: &str) -> String {
|
||||
let mut slug = String::with_capacity(host.len());
|
||||
let mut last_was_sep = false;
|
||||
|
||||
for ch in host.trim().chars() {
|
||||
let normalized = ch.to_ascii_lowercase();
|
||||
if normalized.is_ascii_alphanumeric() {
|
||||
slug.push(normalized);
|
||||
last_was_sep = false;
|
||||
} else if !slug.is_empty() && !last_was_sep {
|
||||
slug.push('-');
|
||||
last_was_sep = true;
|
||||
}
|
||||
}
|
||||
|
||||
while slug.ends_with('-') {
|
||||
slug.pop();
|
||||
}
|
||||
|
||||
if slug.is_empty() {
|
||||
"device".to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile_name_or_default(value: Option<&str>) -> &str {
|
||||
value
|
||||
.map(str::trim)
|
||||
@@ -15,6 +52,15 @@ pub fn profile_name_or_default(value: Option<&str>) -> &str {
|
||||
.unwrap_or(DEFAULT_AUTH_PROFILE_NAME)
|
||||
}
|
||||
|
||||
pub fn is_local_session_token(token: &str) -> bool {
|
||||
let trimmed = token.trim();
|
||||
let mut parts = trimmed.split('.');
|
||||
matches!(
|
||||
(parts.next(), parts.next(), parts.next(), parts.next()),
|
||||
(Some(_), Some(_), Some("local"), None)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_fields_value(
|
||||
input: Option<serde_json::Value>,
|
||||
) -> Result<std::collections::HashMap<String, String>, String> {
|
||||
@@ -181,6 +227,26 @@ mod tests {
|
||||
assert_eq!(profile_name_or_default(Some(" work ")), "work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_local_session_token_requires_local_signature_marker() {
|
||||
assert!(is_local_session_token("header.payload.local"));
|
||||
assert!(is_local_session_token(" header.payload.local "));
|
||||
assert!(!is_local_session_token("header.payload.remote"));
|
||||
assert!(!is_local_session_token("header.payload.local.extra"));
|
||||
assert!(!is_local_session_token("not-a-jwt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_local_session_host_normalizes_machine_names() {
|
||||
assert_eq!(
|
||||
slugify_local_session_host("My MacBook Pro"),
|
||||
"my-macbook-pro"
|
||||
);
|
||||
assert_eq!(slugify_local_session_host("DESKTOP_123"), "desktop-123");
|
||||
assert_eq!(slugify_local_session_host(" "), "device");
|
||||
assert_eq!(slugify_local_session_host("---"), "device");
|
||||
}
|
||||
|
||||
// ── parse_fields_value ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user