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:
shadowdoggie
2026-05-24 15:20:35 -07:00
committed by GitHub
co-authored by shadowdoggie Steven Enamakel
parent 76ce014e20
commit fa62b7be2a
88 changed files with 2269 additions and 316 deletions
+1 -1
View File
@@ -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 (
+54
View File
@@ -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' });
+14 -1
View File
@@ -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';
+14 -1
View File
@@ -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);
});
});
+34 -23
View File
@@ -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'));
});
});
+59
View File
@@ -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;
}
+40
View File
@@ -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', () => {
+57 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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',
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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 dAriane',
'settings.betaBuild': 'Build bêta - v{version}',
'migration.vendor.openclaw': 'OpenClaw',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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;
+14
View File
@@ -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',
+4
View File
@@ -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
View File
@@ -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;
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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 nest pas disponible pour les utilisateurs locaux. Ajoutez votre propre clé API Parallel ou Brave pour activer la recherche web.',
'devices.comingSoonDescription':
'Lappairage 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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+19 -1
View File
@@ -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;
+46 -5
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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>
)}
+87 -2
View File
@@ -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>
+22
View File
@@ -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();
});
});
+66
View File
@@ -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();
});
});
});
+2 -2
View File
@@ -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
+12 -1
View File
@@ -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>
+23 -35
View File
@@ -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(
+30 -26
View File
@@ -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();
});
});
+20
View File
@@ -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);
+21 -1
View File
@@ -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>
);
}
+51
View File
@@ -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',
});
});
});
+36
View File
@@ -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
View File
@@ -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 -15
View File
@@ -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 {
+35 -12
View File
@@ -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,
+68 -19
View File
@@ -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
+91
View File
@@ -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]