refactor(core): move app state ownership into the Rust core (#320)

* refactor(core): integrate CoreStateProvider and streamline state management

- Replaced UserProvider with CoreStateProvider in App component to centralize state management.
- Updated various components to utilize the new CoreStateProvider for accessing session tokens and user data.
- Refactored hooks and components to eliminate direct Redux store dependencies, enhancing modularity and maintainability.
- Introduced a new core state management structure to improve the handling of user authentication and onboarding states.

This refactor aims to simplify state access across the application and improve overall code clarity.

* fix: restore polyfills import and clean up component imports

- Reintroduced the import of polyfills in main.tsx to ensure compatibility.
- Adjusted import statements in PrivacyPanel and SocketProvider for consistency.
- Simplified conditional expressions in TeamInvitesPanel and TeamMembersPanel for better readability.
- Refactored CoreStateProvider and store.ts to streamline state initialization.
- Enhanced formatting in various files for improved code clarity and maintainability.

* refactor(tests): streamline onboarding and protected route tests

- Updated OnboardingOverlay tests to utilize mock state management and simplify assertions.
- Refactored ProtectedRoute tests to improve readability and ensure consistent use of mock state.
- Enhanced teamApi tests to replace direct API calls with core RPC method calls, improving test isolation and clarity.
- Adjusted socketSelectors tests to utilize a centralized core state snapshot for better state management during tests.

* refactor(onboarding): simplify onboarding state management and improve loading logic

- Removed unnecessary local state for onboarding completion in OnboardingOverlay, directly utilizing snapshot data.
- Enhanced loading logic to prevent unnecessary renders and improve user experience during onboarding.
- Updated PrivacyPanel to handle analytics consent persistence with error handling.
- Refactored TeamManagementPanel and TeamPanel to improve team data fetching logic and loading states.
- Streamlined CoreStateProvider to manage session token synchronization and state updates more effectively.

* chore(deps): update tempfile dependency and refactor app state management

- Added `tempfile` dependency to Cargo.toml for improved temporary file handling.
- Refactored app state loading and saving functions to enhance error handling and ensure data integrity.
- Introduced quarantine mechanism for corrupted app state files to prevent application crashes.
- Updated URL parsing logic to ensure proper formatting of API URLs.
- Adjusted type definitions in schemas for better clarity and consistency.

* refactor(tests): simplify mock state usage in onboarding and protected route tests

- Consolidated mock state management in OnboardingOverlay and ProtectedRoute tests for improved readability.
- Streamlined assertions by reducing unnecessary lines in test setups.
- Enhanced consistency in mock return values across tests to ensure clarity and maintainability.

* refactor(tests): enhance PublicRoute tests with mock state and routing

- Updated PublicRoute tests to utilize MemoryRouter for improved routing simulation.
- Simplified mock state management by integrating `useCoreState` for user authentication scenarios.
- Streamlined test assertions and removed redundant preloaded state configurations for clarity and maintainability.

* refactor(tests): streamline mock state usage in PublicRoute and Mnemonic tests

- Simplified mock state management in PublicRoute and Mnemonic tests for improved readability.
- Consolidated mock return values to reduce redundancy and enhance clarity in test setups.
- Improved consistency in the usage of `useCoreState` across test files.

* Refactor billing components for improved readability

- Cleaned up import statements in BillingPanel.tsx for better organization.
- Enhanced formatting of billing plan descriptions in billingHelpers.ts for consistency.
- Improved readability of conditional checks in BillingPanel component.

* Refactor API endpoint handling for clarity and consistency

- Updated references from `/settings` to `/auth/me` in various modules to standardize user authentication flows.
- Renamed `parse_settings_response_json` to `parse_api_response_json` for improved clarity in response handling.
- Adjusted user ID extraction functions to reflect the new endpoint structure, enhancing maintainability across the codebase.
- Updated test cases to align with the new endpoint naming conventions, ensuring consistency in API interactions.
This commit is contained in:
Steven Enamakel
2026-04-04 03:20:18 -07:00
committed by GitHub
parent 24876497df
commit ef708bdd20
60 changed files with 1890 additions and 1437 deletions
+1 -1
View File
@@ -88,6 +88,7 @@ url = "2"
socketioxide = { version = "0.15", features = ["extensions"] }
whisper-rs = "0.16"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
tempfile = "3"
matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
@@ -108,7 +109,6 @@ landlock = { version = "0.4", optional = true }
rppal = { version = "0.22", optional = true }
[dev-dependencies]
tempfile = "3"
[features]
sandbox-landlock = ["dep:landlock"]
+4 -15
View File
@@ -10,11 +10,10 @@ import DictationOverlay from './components/dictation/DictationOverlay';
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
import OnboardingOverlay from './components/OnboardingOverlay';
import CoreStateProvider from './providers/CoreStateProvider';
import SocketProvider from './providers/SocketProvider';
import UserProvider from './providers/UserProvider';
import { tagErrorSource } from './services/errorReportQueue';
import { persistor, store } from './store';
import { syncMemoryClientToken } from './utils/tauriCommands';
function App() {
return (
@@ -26,18 +25,8 @@ function App() {
tagErrorSource(eventId, 'react', componentStack);
}}>
<Provider store={store}>
<PersistGate
loading={null}
persistor={persistor}
onBeforeLift={() => {
const token = store.getState().auth.token;
console.info('[memory] PersistGate onBeforeLift: token_present=%s', !!token);
if (token) {
// Do not block initial render on core/memory availability.
void syncMemoryClientToken(token);
}
}}>
<UserProvider>
<PersistGate loading={null} persistor={persistor}>
<CoreStateProvider>
<SocketProvider>
<Router>
<ServiceBlockingGate>
@@ -53,7 +42,7 @@ function App() {
</ServiceBlockingGate>
</Router>
</SocketProvider>
</UserProvider>
</CoreStateProvider>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>
+3 -1
View File
@@ -1,5 +1,6 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useCoreState } from '../providers/CoreStateProvider';
import { useAppSelector } from '../store/hooks';
const tabs = [
@@ -104,7 +105,8 @@ const tabs = [
const BottomTabBar = () => {
const location = useLocation();
const navigate = useNavigate();
const token = useAppSelector(state => state.auth.token);
const { snapshot } = useCoreState();
const token = snapshot.sessionToken;
const conversationsUnreadCount = useAppSelector(state => {
const { threads, lastViewedAt } = state.thread;
+4 -5
View File
@@ -1,6 +1,6 @@
import { Navigate } from 'react-router-dom';
import { useAppSelector } from '../store/hooks';
import { useCoreState } from '../providers/CoreStateProvider';
import RouteLoadingScreen from './RouteLoadingScreen';
/**
@@ -9,14 +9,13 @@ import RouteLoadingScreen from './RouteLoadingScreen';
* - Logged in → /home (Home handles onboarding redirect if needed)
*/
const DefaultRedirect = () => {
const token = useAppSelector(state => state.auth.token);
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
const { isBootstrapping, snapshot } = useCoreState();
if (!isAuthBootstrapComplete) {
if (isBootstrapping) {
return <RouteLoadingScreen />;
}
if (token) {
if (snapshot.sessionToken) {
return <Navigate to="/home" replace />;
}
+3 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useCoreState } from '../providers/CoreStateProvider';
import { useAppSelector } from '../store/hooks';
import { isTauri } from '../utils/tauriCommands';
import DaemonHealthIndicator from './daemon/DaemonHealthIndicator';
@@ -138,7 +139,8 @@ const navItems = [
const MiniSidebar = () => {
const location = useLocation();
const navigate = useNavigate();
const token = useAppSelector(state => state.auth.token);
const { snapshot } = useCoreState();
const token = snapshot.sessionToken;
const [showDaemonPanel, setShowDaemonPanel] = useState(false);
// Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return)
+11 -43
View File
@@ -2,13 +2,8 @@ import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import Onboarding from '../pages/onboarding/Onboarding';
import { selectIsOnboarded } from '../store/authSelectors';
import { useAppSelector } from '../store/hooks';
import { useCoreState } from '../providers/CoreStateProvider';
import { DEV_FORCE_ONBOARDING } from '../utils/config';
import {
getOnboardingCompleted,
setOnboardingCompleted as persistOnboardingCompleted,
} from '../utils/tauriCommands';
/**
* Full-screen overlay that renders the onboarding flow on top of any page
@@ -17,70 +12,43 @@ import {
* Reads `onboarding_completed` from the core config (persisted in config.toml).
*/
const OnboardingOverlay = () => {
const token = useAppSelector(state => state.auth.token);
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
const user = useAppSelector(state => state.user.user);
const isOnboardedRedux = useAppSelector(selectIsOnboarded);
/** null = still loading, true/false = resolved from core config */
const [onboardingCompleted, setOnboardingCompleted] = useState<boolean | null>(null);
const { isBootstrapping, snapshot, setOnboardingCompletedFlag } = useCoreState();
const token = snapshot.sessionToken;
const user = snapshot.currentUser;
const [userLoadTimedOut, setUserLoadTimedOut] = useState(false);
// Reset local state on logout so re-login starts fresh.
useEffect(() => {
if (!token) {
setUserLoadTimedOut(false);
setOnboardingCompleted(null);
}
}, [token]);
// Timeout: if user profile hasn't loaded after 3s but we have token + bootstrap,
// proceed anyway so onboarding isn't permanently invisible.
useEffect(() => {
if (!token || !isAuthBootstrapComplete || user?._id) return;
if (!token || isBootstrapping || user?._id) return;
const timer = setTimeout(() => setUserLoadTimedOut(true), 3000);
return () => clearTimeout(timer);
}, [token, isAuthBootstrapComplete, user?._id]);
}, [token, isBootstrapping, user?._id]);
// User is ready when profile loaded or timeout elapsed.
const userReady = !!user?._id || userLoadTimedOut;
// Read onboarding_completed from core config.
useEffect(() => {
if (!token || !isAuthBootstrapComplete || !userReady) return;
let mounted = true;
const check = async () => {
try {
const completed = await getOnboardingCompleted();
if (mounted) setOnboardingCompleted(completed);
} catch {
if (mounted) setOnboardingCompleted(isOnboardedRedux);
}
};
void check();
return () => {
mounted = false;
};
}, [token, isAuthBootstrapComplete, userReady, isOnboardedRedux]);
const onboardingCompleted = snapshot.onboardingCompleted;
const handleDone = useCallback(async () => {
setOnboardingCompleted(true);
try {
await persistOnboardingCompleted(true);
await setOnboardingCompletedFlag(true);
} catch {
console.warn('[onboarding] Failed to persist onboarding_completed');
}
}, []);
}, [setOnboardingCompletedFlag]);
// Don't show if not logged in, bootstrap not complete, or user not ready
if (!token || !isAuthBootstrapComplete || !userReady) return null;
if (!token || isBootstrapping || !userReady) return null;
// Still loading the flag from core
if (onboardingCompleted === null) return null;
const shouldShow = DEV_FORCE_ONBOARDING || (!onboardingCompleted && !isOnboardedRedux);
const shouldShow = DEV_FORCE_ONBOARDING || !onboardingCompleted;
if (!shouldShow) return null;
+4 -5
View File
@@ -1,6 +1,6 @@
import { Navigate } from 'react-router-dom';
import { useAppSelector } from '../store/hooks';
import { useCoreState } from '../providers/CoreStateProvider';
import RouteLoadingScreen from './RouteLoadingScreen';
interface ProtectedRouteProps {
@@ -14,14 +14,13 @@ interface ProtectedRouteProps {
* Onboarding is handled separately via OnboardingOverlay.
*/
const ProtectedRoute = ({ children, requireAuth = true, redirectTo }: ProtectedRouteProps) => {
const token = useAppSelector(state => state.auth.token);
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
const { isBootstrapping, snapshot } = useCoreState();
if (!isAuthBootstrapComplete) {
if (isBootstrapping) {
return <RouteLoadingScreen />;
}
if (requireAuth && !token) {
if (requireAuth && !snapshot.sessionToken) {
return <Navigate to={redirectTo || '/'} replace />;
}
+4 -5
View File
@@ -1,6 +1,6 @@
import { Navigate } from 'react-router-dom';
import { useAppSelector } from '../store/hooks';
import { useCoreState } from '../providers/CoreStateProvider';
import RouteLoadingScreen from './RouteLoadingScreen';
interface PublicRouteProps {
@@ -13,16 +13,15 @@ interface PublicRouteProps {
* Home handles the onboarding redirect once the user profile is loaded.
*/
const PublicRoute = ({ children, redirectTo }: PublicRouteProps) => {
const token = useAppSelector(state => state.auth.token);
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
const { isBootstrapping, snapshot } = useCoreState();
if (!isAuthBootstrapComplete) {
if (isBootstrapping) {
return <RouteLoadingScreen />;
}
// If user is logged in, always go to home.
// Home itself will redirect to onboarding if needed.
if (token) {
if (snapshot.sessionToken) {
return <Navigate to={redirectTo || '/home'} replace />;
}
+3 -4
View File
@@ -3,8 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useUser } from '../hooks/useUser';
import { useSkillConnectionStatus } from '../lib/skills/hooks';
import { skillManager } from '../lib/skills/manager';
import { useCoreState } from '../providers/CoreStateProvider';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import { useAppSelector } from '../store/hooks';
function truncateAddress(address: string): string {
if (!address || address.length < 12) return address;
@@ -14,9 +14,8 @@ function truncateAddress(address: string): string {
export default function WalletInfoSection() {
const walletStatus = useSkillConnectionStatus('wallet');
const { user } = useUser();
const primaryAddress = useAppSelector(state =>
user?._id ? state.auth.primaryWalletAddressByUser[user._id] : undefined
);
const { snapshot } = useCoreState();
const primaryAddress = user?._id ? snapshot.localState.primaryWalletAddress : undefined;
const [networkName, setNetworkName] = useState<string | null>(null);
const [balance, setBalance] = useState<string | null>(null);
@@ -1,137 +1,75 @@
import { screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearToken, setToken } from '../../store/authSlice';
import { renderWithProviders } from '../../test/test-utils';
import OnboardingOverlay from '../OnboardingOverlay';
// Mock tauriCommands — onboarding defaults to not completed
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => false),
getOnboardingCompleted: vi.fn().mockResolvedValue(false),
setOnboardingCompleted: vi.fn().mockResolvedValue(true),
const mockUseCoreState = vi.fn();
vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
vi.mock('../../pages/onboarding/Onboarding', () => ({
default: ({ onComplete }: { onComplete: () => void }) => (
<div>
<button onClick={onComplete}>Skip</button>
</div>
),
}));
// DEV_FORCE_ONBOARDING is already mocked as false in test/setup.ts
const baseAuth = {
token: 'test-jwt',
isAuthBootstrapComplete: true,
isOnboardedByUser: {} as Record<string, boolean>,
onboardingDeferredByUser: {} as Record<string, number>,
isAnalyticsEnabledByUser: {},
onboardingTasksByUser: {},
hasIncompleteOnboardingByUser: {},
encryptionKeyByUser: {},
primaryWalletAddressByUser: {},
};
const baseUser = { user: { _id: 'user-1', username: 'tester', firstName: 'Test' } };
function makeCoreState(overrides?: Record<string, unknown>) {
return {
isBootstrapping: false,
snapshot: {
sessionToken: 'test-jwt',
currentUser: { _id: 'user-1', username: 'tester', firstName: 'Test' },
onboardingCompleted: false,
...overrides,
},
setOnboardingCompletedFlag: vi.fn().mockResolvedValue(undefined),
};
}
describe('OnboardingOverlay', () => {
it('does not render when onboarding is completed', async () => {
const { getOnboardingCompleted } = await import('../../utils/tauriCommands');
vi.mocked(getOnboardingCompleted).mockResolvedValue(true);
beforeEach(() => {
mockUseCoreState.mockReset();
});
renderWithProviders(<OnboardingOverlay />, {
preloadedState: { auth: baseAuth, user: baseUser },
});
it('does not render when onboarding is completed', () => {
mockUseCoreState.mockReturnValue(makeCoreState({ onboardingCompleted: true }));
await vi.waitFor(() => {
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
render(<OnboardingOverlay />);
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
it('does not render when no token', () => {
renderWithProviders(<OnboardingOverlay />, {
preloadedState: { auth: { ...baseAuth, token: null }, user: baseUser },
});
mockUseCoreState.mockReturnValue(makeCoreState({ sessionToken: null }));
render(<OnboardingOverlay />);
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
it('does not render when user profile is not loaded', () => {
renderWithProviders(<OnboardingOverlay />, {
preloadedState: { auth: baseAuth, user: { user: {} } },
});
it('does not render when user profile is not loaded yet', () => {
mockUseCoreState.mockReturnValue(makeCoreState({ currentUser: {} }));
render(<OnboardingOverlay />);
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
it('resets local state on logout so re-login starts fresh', async () => {
// Ensure getOnboardingCompleted returns false (not completed) for this test
const { getOnboardingCompleted } = await import('../../utils/tauriCommands');
vi.mocked(getOnboardingCompleted).mockResolvedValue(false);
it('renders when the user is authenticated and onboarding is incomplete', () => {
mockUseCoreState.mockReturnValue(makeCoreState());
// Start with a loaded user profile so userReady is true via user._id,
// and onboarding not completed — overlay should show.
const { store } = renderWithProviders(<OnboardingOverlay />, {
preloadedState: { auth: baseAuth, user: baseUser },
});
render(<OnboardingOverlay />);
// Wait for getOnboardingCompleted to resolve and overlay to appear
await vi.waitFor(() => {
expect(screen.queryByText('Skip')).toBeInTheDocument();
});
expect(screen.getByText('Skip')).toBeInTheDocument();
});
// Simulate logout — the reset useEffect clears onboardingCompleted + userLoadTimedOut
await store.dispatch(clearToken());
it('does not render while bootstrapping', () => {
mockUseCoreState.mockReturnValue({ ...makeCoreState(), isBootstrapping: true });
await vi.waitFor(() => {
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
// Simulate re-login WITHOUT a loaded user profile.
// If the fix works, onboardingCompleted was reset to null and userLoadTimedOut
// was reset to false, so userReady is false — overlay should NOT appear.
// Without the fix, stale state would make the overlay appear immediately.
store.dispatch(setToken('new-jwt'));
render(<OnboardingOverlay />);
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
it('does not render when RPC fails but Redux says onboarded', async () => {
const { getOnboardingCompleted } = await import('../../utils/tauriCommands');
vi.mocked(getOnboardingCompleted).mockRejectedValue(new Error('RPC error'));
renderWithProviders(<OnboardingOverlay />, {
preloadedState: {
auth: { ...baseAuth, isOnboardedByUser: { 'user-1': true } },
user: baseUser,
},
});
await vi.waitFor(() => {
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
});
it('does not render when RPC returns false but Redux says onboarded', async () => {
const { getOnboardingCompleted } = await import('../../utils/tauriCommands');
vi.mocked(getOnboardingCompleted).mockResolvedValue(false);
renderWithProviders(<OnboardingOverlay />, {
preloadedState: {
auth: { ...baseAuth, isOnboardedByUser: { 'user-1': true } },
user: baseUser,
},
});
await vi.waitFor(() => {
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
});
it('renders when both RPC fails and Redux says not onboarded', async () => {
const { getOnboardingCompleted } = await import('../../utils/tauriCommands');
vi.mocked(getOnboardingCompleted).mockRejectedValue(new Error('RPC error'));
renderWithProviders(<OnboardingOverlay />, {
preloadedState: { auth: { ...baseAuth, isOnboardedByUser: {} }, user: baseUser },
});
await vi.waitFor(() => {
expect(screen.queryByText('Skip')).toBeInTheDocument();
});
});
});
@@ -1,41 +1,64 @@
import { screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import ProtectedRoute from '../ProtectedRoute';
const mockUseCoreState = vi.fn();
vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
function renderRoute(routes: React.ReactNode, initialEntries = ['/']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>{routes}</Routes>
</MemoryRouter>
);
}
describe('ProtectedRoute', () => {
it('renders children when token exists', () => {
renderWithProviders(
<Routes>
<Route
path="/"
element={
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
}
/>
</Routes>,
{
preloadedState: {
auth: {
token: 'valid-jwt',
isAuthBootstrapComplete: true,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
},
},
}
it('renders a loading screen while bootstrapping', () => {
mockUseCoreState.mockReturnValue({ isBootstrapping: true, snapshot: { sessionToken: null } });
renderRoute(
<Route
path="/"
element={
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
}
/>
);
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('renders children when a session token exists', () => {
mockUseCoreState.mockReturnValue({
isBootstrapping: false,
snapshot: { sessionToken: 'valid-jwt' },
});
renderRoute(
<Route
path="/"
element={
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
}
/>
);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
it('redirects to / when no token and requireAuth=true', () => {
renderWithProviders(
<Routes>
mockUseCoreState.mockReturnValue({ isBootstrapping: false, snapshot: { sessionToken: null } });
renderRoute(
<>
<Route
path="/dashboard"
element={
@@ -45,18 +68,8 @@ describe('ProtectedRoute', () => {
}
/>
<Route path="/" element={<div>Landing</div>} />
</Routes>,
{
initialEntries: ['/dashboard'],
preloadedState: {
auth: {
token: null,
isAuthBootstrapComplete: true,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
},
},
}
</>,
['/dashboard']
);
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
@@ -64,60 +77,23 @@ describe('ProtectedRoute', () => {
});
it('redirects to custom redirectTo when no token', () => {
renderWithProviders(
<Routes>
mockUseCoreState.mockReturnValue({ isBootstrapping: false, snapshot: { sessionToken: null } });
renderRoute(
<>
<Route
path="/dashboard"
path="/custom"
element={
<ProtectedRoute redirectTo="/login">
<div>Dashboard</div>
<div>Custom Protected</div>
</ProtectedRoute>
}
/>
<Route path="/login" element={<div>Login Page</div>} />
</Routes>,
{
initialEntries: ['/dashboard'],
preloadedState: {
auth: {
token: null,
isAuthBootstrapComplete: true,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
},
},
}
</>,
['/custom']
);
expect(screen.getByText('Login Page')).toBeInTheDocument();
});
it('renders children when authenticated (onboarding handled by overlay)', () => {
renderWithProviders(
<Routes>
<Route
path="/home"
element={
<ProtectedRoute>
<div>Home Content</div>
</ProtectedRoute>
}
/>
</Routes>,
{
initialEntries: ['/home'],
preloadedState: {
auth: {
token: 'valid-jwt',
isAuthBootstrapComplete: true,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
},
user: { user: { _id: 'u1' }, isLoading: false, error: null },
},
}
);
expect(screen.getByText('Home Content')).toBeInTheDocument();
});
});
@@ -1,41 +1,47 @@
import { screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import PublicRoute from '../PublicRoute';
const mockUseCoreState = vi.fn();
vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
function renderRoute(routes: React.ReactNode, initialEntries = ['/']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>{routes}</Routes>
</MemoryRouter>
);
}
describe('PublicRoute', () => {
it('renders children when user is not authenticated', () => {
renderWithProviders(
<Routes>
<Route
path="/"
element={
<PublicRoute>
<div>Welcome Page</div>
</PublicRoute>
}
/>
</Routes>,
{
preloadedState: {
auth: {
token: null,
isAuthBootstrapComplete: true,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
},
},
}
mockUseCoreState.mockReturnValue({ isBootstrapping: false, snapshot: { sessionToken: null } });
renderRoute(
<Route
path="/"
element={
<PublicRoute>
<div>Welcome Page</div>
</PublicRoute>
}
/>
);
expect(screen.getByText('Welcome Page')).toBeInTheDocument();
});
it('redirects to /home when user is authenticated', () => {
renderWithProviders(
<Routes>
mockUseCoreState.mockReturnValue({
isBootstrapping: false,
snapshot: { sessionToken: 'jwt-token' },
});
renderRoute(
<>
<Route
path="/"
element={
@@ -45,17 +51,7 @@ describe('PublicRoute', () => {
}
/>
<Route path="/home" element={<div>Home</div>} />
</Routes>,
{
preloadedState: {
auth: {
token: 'jwt-token',
isAuthBootstrapComplete: true,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
},
},
}
</>
);
expect(screen.queryByText('Welcome Page')).not.toBeInTheDocument();
@@ -63,8 +59,13 @@ describe('PublicRoute', () => {
});
it('redirects to custom path when authenticated', () => {
renderWithProviders(
<Routes>
mockUseCoreState.mockReturnValue({
isBootstrapping: false,
snapshot: { sessionToken: 'jwt-token' },
});
renderRoute(
<>
<Route
path="/"
element={
@@ -74,17 +75,7 @@ describe('PublicRoute', () => {
}
/>
<Route path="/dashboard" element={<div>Dashboard</div>} />
</Routes>,
{
preloadedState: {
auth: {
token: 'jwt-token',
isAuthBootstrapComplete: true,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
},
},
}
</>
);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
+6 -10
View File
@@ -1,30 +1,27 @@
import { useState } from 'react';
import { skillManager } from '../../lib/skills/manager';
import { useCoreState } from '../../providers/CoreStateProvider';
import { persistor } from '../../store';
import { clearToken } from '../../store/authSlice';
import { useAppDispatch } from '../../store/hooks';
import { setOnboardingCompleted, logout as tauriLogout } from '../../utils/tauriCommands';
import SettingsHeader from './components/SettingsHeader';
import SettingsMenuItem from './components/SettingsMenuItem';
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
const SettingsHome = () => {
const { navigateToSettings } = useSettingsNavigation();
const dispatch = useAppDispatch();
const { clearSession, setOnboardingCompletedFlag } = useCoreState();
const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogout = async () => {
await dispatch(clearToken());
try {
await setOnboardingCompleted(false);
await setOnboardingCompletedFlag(false);
} catch (err) {
console.warn('[Settings] Failed to clear onboarding_completed in config:', err);
}
try {
await tauriLogout();
await clearSession();
} catch (err) {
console.warn('[Settings] Rust logout failed:', err);
}
@@ -32,14 +29,13 @@ const SettingsHome = () => {
};
const clearAllAppData = async () => {
await dispatch(clearToken());
try {
await setOnboardingCompleted(false);
await setOnboardingCompletedFlag(false);
} catch (err) {
console.warn('[Settings] Failed to clear onboarding_completed in config:', err);
}
try {
await tauriLogout();
await clearSession();
} catch (err) {
console.warn('[Settings] Rust logout failed during clearAllAppData:', err);
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { billingApi } from '../../../services/api/billingApi';
import {
type AutoRechargeSettings,
@@ -9,8 +10,6 @@ import {
type SavedCard,
type TeamUsage,
} from '../../../services/api/creditsApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { fetchCurrentUser } from '../../../store/userSlice';
import type { CurrentPlanData, PlanTier } from '../../../types/api';
import { openUrl } from '../../../utils/openUrl';
import SettingsHeader from '../components/SettingsHeader';
@@ -18,11 +17,11 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import {
annualSavings,
buildPlanId,
isUpgrade as checkIsUpgrade,
displayPrice,
formatStorageLimit,
formatUsdAmount,
getPlanMeta,
isUpgrade as checkIsUpgrade,
PLANS,
} from './billingHelpers';
@@ -49,9 +48,8 @@ function cardBrandLabel(brand: string) {
// ── Component ───────────────────────────────────────────────────────────
const BillingPanel = () => {
const { navigateBack } = useSettingsNavigation();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const { teams } = useAppSelector(state => state.team);
const { snapshot, teams, refresh } = useCoreState();
const user = snapshot.currentUser;
// Active team context
const activeTeamId = user?.activeTeamId;
@@ -104,7 +102,9 @@ const BillingPanel = () => {
const currentTier: PlanTier = currentPlan?.plan ?? activeTeam?.team.subscription?.plan ?? 'FREE';
const hasActive =
currentPlan?.hasActiveSubscription ?? activeTeam?.team.subscription?.hasActiveSubscription ?? false;
currentPlan?.hasActiveSubscription ??
activeTeam?.team.subscription?.hasActiveSubscription ??
false;
const planExpiry = currentPlan?.planExpiry ?? activeTeam?.team.subscription?.planExpiry ?? null;
const currentPlanMeta = getPlanMeta(currentTier);
@@ -288,7 +288,7 @@ const BillingPanel = () => {
} catch (e) {
console.error('Failed to fetch current plan after payment', e);
}
dispatch(fetchCurrentUser());
await refresh();
// Auto-hide the success banner after 5 s
timeoutRef.current = window.setTimeout(() => setPaymentConfirmed(false), 5_000);
@@ -302,7 +302,7 @@ const BillingPanel = () => {
timeoutRef.current = null;
}
};
}, [dispatch]);
}, [refresh]);
// ── Poll for plan change after checkout ─────────────────────────────
const currentTierRef = useRef(currentTier);
@@ -328,7 +328,7 @@ const BillingPanel = () => {
log('[poll] plan=%s active=%s', plan.plan, plan.hasActiveSubscription);
setCurrentPlan(plan);
if (plan.hasActiveSubscription && plan.plan !== currentTierRef.current) {
dispatch(fetchCurrentUser());
await refresh();
setIsPurchasing(false);
setPurchasingTier(null);
if (pollRef.current) clearInterval(pollRef.current);
@@ -337,7 +337,7 @@ const BillingPanel = () => {
// Ignore polling errors
}
}, 5_000);
}, [dispatch]);
}, [refresh]);
// ── Purchase handlers ───────────────────────────────────────────────
const handleUpgrade = async (tier: PlanTier) => {
@@ -1134,7 +1134,10 @@ const BillingPanel = () => {
d="M5 13l4 4L19 7"
/>
</svg>
<span>Higher tiers increase your premium-usage discount and included usage every cycle</span>
<span>
Higher tiers increase your premium-usage discount and included usage every
cycle
</span>
</li>
{currentTier === 'FREE' && (
<li className="flex items-start gap-2">
@@ -1,31 +1,18 @@
import { syncAnalyticsConsent } from '../../../services/analytics';
import { setAnalyticsForUser } from '../../../store/authSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { isTauri, openhumanUpdateAnalyticsSettings } from '../../../utils/tauriCommands';
import { useCoreState } from '../../../providers/CoreStateProvider';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const PrivacyPanel = () => {
const { navigateBack } = useSettingsNavigation();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const analyticsEnabled = useAppSelector(state => {
const userId = state.user.user?._id;
if (!userId) return false;
return state.auth.isAnalyticsEnabledByUser[userId] !== false;
});
const { snapshot, setAnalyticsEnabled } = useCoreState();
const analyticsEnabled = snapshot.analyticsEnabled;
const handleToggleAnalytics = () => {
if (!user?._id) return;
const handleToggleAnalytics = async () => {
const newValue = !analyticsEnabled;
dispatch(setAnalyticsForUser({ userId: user._id, enabled: newValue }));
syncAnalyticsConsent(newValue);
// Sync to core config so the Rust process also respects the setting
if (isTauri()) {
openhumanUpdateAnalyticsSettings({ enabled: newValue }).catch(() => {
/* best-effort */
});
try {
await setAnalyticsEnabled(newValue);
} catch (error) {
console.warn('[privacy] failed to persist analytics setting:', error);
}
};
@@ -1,8 +1,7 @@
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { skillManager } from '../../../lib/skills/manager';
import { setEncryptionKeyForUser } from '../../../store/authSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { useCoreState } from '../../../providers/CoreStateProvider';
import {
deriveAesKeyFromMnemonic,
deriveEvmAddressFromMnemonic,
@@ -18,9 +17,9 @@ const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
const RecoveryPhrasePanel = () => {
const dispatch = useAppDispatch();
const { navigateBack } = useSettingsNavigation();
const user = useAppSelector(state => state.user.user);
const { snapshot, setEncryptionKey } = useCoreState();
const user = snapshot.currentUser;
const [mode, setMode] = useState<'generate' | 'import'>('generate');
const [copied, setCopied] = useState(false);
@@ -184,7 +183,7 @@ const RecoveryPhrasePanel = () => {
setError('User not loaded. Please sign in again or refresh the page.');
return;
}
dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey }));
await setEncryptionKey(aesKey);
await skillManager.setWalletAddress(walletAddress);
setSuccess(true);
} catch (e) {
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { fetchInvites } from '../../../store/teamSlice';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -11,27 +10,30 @@ const TeamInvitesPanel = () => {
const { teamId } = useParams<{ teamId: string }>();
const location = useLocation();
const { navigateBack } = useSettingsNavigation();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const { teams, invites, isLoadingInvites } = useAppSelector(state => state.team);
const { snapshot, teams, teamInvitesById, refreshTeamInvites } = useCoreState();
const user = snapshot.currentUser;
// Check if we're in team management context (has teamId in URL)
const isInManagementContext = location.pathname.includes('/team/manage/');
const currentTeamId = isInManagementContext ? teamId : user?.activeTeamId;
const currentTeam = teams.find(t => t.team._id === currentTeamId);
const isAdmin = currentTeam?.role.toUpperCase() === 'ADMIN';
const invites = currentTeamId ? (teamInvitesById[currentTeamId] ?? []) : [];
const [isGenerating, setIsGenerating] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoadingInvites, setIsLoadingInvites] = useState(false);
// Confirmation modal state
const [inviteToRevoke, setInviteToRevoke] = useState<{ id: string; code: string } | null>(null);
useEffect(() => {
if (currentTeamId) dispatch(fetchInvites(currentTeamId));
}, [currentTeamId, dispatch]);
if (!currentTeamId) return;
setIsLoadingInvites(true);
void refreshTeamInvites(currentTeamId).finally(() => setIsLoadingInvites(false));
}, [currentTeamId, refreshTeamInvites]);
const handleGenerate = async () => {
if (!currentTeamId) return;
@@ -39,7 +41,7 @@ const TeamInvitesPanel = () => {
setError(null);
try {
await teamApi.createInvite(currentTeamId);
dispatch(fetchInvites(currentTeamId));
await refreshTeamInvites(currentTeamId);
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
@@ -74,7 +76,7 @@ const TeamInvitesPanel = () => {
try {
await teamApi.revokeInvite(currentTeamId, inviteToRevoke.id);
dispatch(fetchInvites(currentTeamId));
await refreshTeamInvites(currentTeamId);
setInviteToRevoke(null);
} catch (err) {
setError(
@@ -1,17 +1,16 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { fetchTeams } from '../../../store/teamSlice';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const TeamManagementPanel = () => {
const { teamId } = useParams<{ teamId: string }>();
const { navigateBack, navigateToSettings } = useSettingsNavigation();
const dispatch = useAppDispatch();
const { teams } = useAppSelector(state => state.team);
const { teams, refreshTeams } = useCoreState();
const initialFetchAttemptedRef = useRef(false);
const teamEntry = teams.find(t => t.team._id === teamId);
const isAdmin = teamEntry?.role.toUpperCase() === 'ADMIN';
@@ -25,10 +24,16 @@ const TeamManagementPanel = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (teams.length === 0) {
dispatch(fetchTeams());
if (teams.length > 0) {
initialFetchAttemptedRef.current = true;
return;
}
}, [dispatch, teams.length]);
if (!initialFetchAttemptedRef.current) {
initialFetchAttemptedRef.current = true;
void refreshTeams();
}
}, [refreshTeams, teams.length]);
// Redirect if user doesn't have admin access to this team
useEffect(() => {
@@ -50,7 +55,7 @@ const TeamManagementPanel = () => {
setError(null);
try {
await teamApi.updateTeam(teamId, { name: editTeamName.trim() });
dispatch(fetchTeams());
await refreshTeams();
setIsEditModalOpen(false);
} catch (err) {
setError(
@@ -69,6 +74,7 @@ const TeamManagementPanel = () => {
setError(null);
try {
await teamApi.deleteTeam(teamId);
await refreshTeams();
navigateBack(); // Navigate back after deletion
} catch (err) {
setError(
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { fetchMembers } from '../../../store/teamSlice';
import type { TeamMember, TeamRole } from '../../../types/team';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -14,19 +13,20 @@ const TeamMembersPanel = () => {
const { teamId } = useParams<{ teamId: string }>();
const location = useLocation();
const { navigateBack } = useSettingsNavigation();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const { teams, members, isLoadingMembers } = useAppSelector(state => state.team);
const { snapshot, teams, teamMembersById, refreshTeamMembers } = useCoreState();
const user = snapshot.currentUser;
// Check if we're in team management context (has teamId in URL)
const isInManagementContext = location.pathname.includes('/team/manage/');
const currentTeamId = isInManagementContext ? teamId : user?.activeTeamId;
const currentTeam = teams.find(t => t.team._id === currentTeamId);
const isAdmin = currentTeam?.role.toUpperCase() === 'ADMIN';
const members = currentTeamId ? (teamMembersById[currentTeamId] ?? []) : [];
const [removingId, setRemovingId] = useState<string | null>(null);
const [changingRoleId, setChangingRoleId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoadingMembers, setIsLoadingMembers] = useState(false);
// Confirmation modals state
const [memberToRemove, setMemberToRemove] = useState<TeamMember | null>(null);
@@ -37,8 +37,10 @@ const TeamMembersPanel = () => {
} | null>(null);
useEffect(() => {
if (currentTeamId) dispatch(fetchMembers(currentTeamId));
}, [currentTeamId, dispatch]);
if (!currentTeamId) return;
setIsLoadingMembers(true);
void refreshTeamMembers(currentTeamId).finally(() => setIsLoadingMembers(false));
}, [currentTeamId, refreshTeamMembers]);
const handleChangeRole = (member: TeamMember, newRole: TeamRole) => {
if (!currentTeamId || member.role === newRole) return;
@@ -56,7 +58,7 @@ const TeamMembersPanel = () => {
try {
await teamApi.changeMemberRole(currentTeamId, member.user._id, newRole);
dispatch(fetchMembers(currentTeamId));
await refreshTeamMembers(currentTeamId);
setRoleChangeConfirmation(null);
} catch (err) {
setError(
@@ -82,7 +84,7 @@ const TeamMembersPanel = () => {
try {
await teamApi.removeMember(currentTeamId, memberToRemove.user._id);
dispatch(fetchMembers(currentTeamId));
await refreshTeamMembers(currentTeamId);
setMemberToRemove(null);
} catch (err) {
setError(
@@ -1,21 +1,19 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { fetchTeams } from '../../../store/teamSlice';
import { fetchCurrentUser } from '../../../store/userSlice';
import type { TeamWithRole } from '../../../types/team';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const TeamPanel = () => {
const { navigateBack, navigateToTeamManagement } = useSettingsNavigation();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const { teams, isLoading } = useAppSelector(state => state.team);
const { snapshot, teams, refresh, refreshTeams } = useCoreState();
const user = snapshot.currentUser;
const [newTeamName, setNewTeamName] = useState('');
const [joinCode, setJoinCode] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isJoining, setIsJoining] = useState(false);
const [isSwitching, setIsSwitching] = useState<string | null>(null);
@@ -27,9 +25,18 @@ const TeamPanel = () => {
const activeTeamId = user?.activeTeamId;
const refreshTeamsWithLoading = useCallback(async () => {
setIsLoading(true);
try {
await refreshTeams();
} finally {
setIsLoading(false);
}
}, [refreshTeams]);
useEffect(() => {
dispatch(fetchTeams());
}, [dispatch]);
void refreshTeamsWithLoading();
}, [refreshTeamsWithLoading]);
const handleCreateTeam = async () => {
const name = newTeamName.trim();
@@ -39,7 +46,7 @@ const TeamPanel = () => {
try {
await teamApi.createTeam(name);
setNewTeamName('');
dispatch(fetchTeams());
await refreshTeamsWithLoading();
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
@@ -59,8 +66,7 @@ const TeamPanel = () => {
try {
await teamApi.joinTeam(code);
setJoinCode('');
await dispatch(fetchCurrentUser());
dispatch(fetchTeams());
await Promise.all([refresh(), refreshTeamsWithLoading()]);
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
@@ -78,8 +84,7 @@ const TeamPanel = () => {
setError(null);
try {
await teamApi.switchTeam(teamId);
await dispatch(fetchCurrentUser());
dispatch(fetchTeams());
await Promise.all([refresh(), refreshTeamsWithLoading()]);
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
@@ -104,8 +109,7 @@ const TeamPanel = () => {
try {
await teamApi.leaveTeam(teamToLeave.team._id);
await dispatch(fetchCurrentUser());
dispatch(fetchTeams());
await Promise.all([refresh(), refreshTeamsWithLoading()]);
setTeamToLeave(null);
} catch (err) {
setError(
@@ -47,7 +47,10 @@ export const PLANS: PlanMeta[] = [
storageLimitBytes: 10 * 1024 * 1024 * 1024,
features: [
{ text: 'Higher included premium usage every billing cycle', included: true },
{ text: '20% premium-usage discount across integrations, bandwidth, and inference', included: true },
{
text: '20% premium-usage discount across integrations, bandwidth, and inference',
included: true,
},
{ text: 'Pay-as-you-go top-ups for overflow usage', included: true },
],
},
+3 -5
View File
@@ -15,10 +15,9 @@
*/
import { listen } from '@tauri-apps/api/event';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { getBackendUrl } from '../services/backendUrl';
import type { RootState } from '../store';
import type {
ActionableItem,
ActionableItemPriority,
@@ -143,8 +142,6 @@ export interface UseConsciousItemsResult {
}
export function useConsciousItems(): UseConsciousItemsResult {
const authToken = useSelector((state: RootState) => state.auth.token);
const [items, setItems] = useState<ActionableItem[]>([]);
const [loading, setLoading] = useState(false);
const [isRunning, setIsRunning] = useState(false);
@@ -176,13 +173,14 @@ export function useConsciousItems(): UseConsciousItemsResult {
}, []);
const triggerAnalysis = useCallback(async () => {
const authToken = getCoreStateSnapshot().snapshot.sessionToken;
if (!isTauri() || !authToken || isRunning) return;
try {
await consciousLoopRun(authToken, await getBackendUrl());
} catch (err) {
console.warn('[conscious] Failed to trigger analysis:', err);
}
}, [authToken, isRunning]);
}, [isRunning]);
// Initial fetch
useEffect(() => {
+49 -338
View File
@@ -1,361 +1,72 @@
import { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useCoreState } from '../providers/CoreStateProvider';
import { socketService } from '../services/socketService';
import type { RootState } from '../store';
import {
addMessage,
setExecutionResult,
setTyping,
updateExecutionProgress,
} from '../store/intelligenceSlice';
import { createChatMessage } from '../utils/intelligenceTransforms';
import { useAppSelector } from '../store/hooks';
import { selectSocketStatus } from '../store/socketSelectors';
/**
* WebSocket event payloads for Intelligence system
*/
interface ProcessMessagePayload {
message: string;
threadId: string;
sessionId?: string;
context?: Record<string, unknown>;
}
interface AgentResponsePayload {
message: string;
threadId: string;
sessionId?: string;
shouldExecute?: boolean;
executionPlan?: unknown;
metadata?: Record<string, unknown>;
}
interface ExecutionProgressPayload {
executionId: string;
sessionId: string;
step: {
id: string;
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
timestamp: string;
};
progress: Array<{
id: string;
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
timestamp?: Date;
}>;
}
interface ExecutionCompletePayload {
executionId: string;
sessionId: string;
status: 'completed' | 'failed';
result?: unknown;
error?: string;
artifacts?: Array<{ type: string; url: string; title: string; description?: string }>;
}
interface ChatInitPayload {
tools: Record<string, unknown>[];
threadId: string;
sessionId?: string;
context?: Record<string, unknown>;
}
/**
* Hook for Intelligence WebSocket integration
* Handles real-time communication for chat and task execution
*/
export const useIntelligenceSocket = () => {
const dispatch = useDispatch();
const socket = socketService.getSocket();
const eventHandlersRegistered = useRef(false);
// Get current socket connection status
const isSocketConnected = useSelector((state: RootState) => {
const userId = state.user.user?._id || '__pending__';
return state.socket.byUser[userId]?.status === 'connected';
});
/**
* Send message to AI agent via WebSocket
*/
const sendMessage = useCallback(
async (payload: ProcessMessagePayload) => {
if (socket?.connected) {
socket.emit('processMessageForUser', payload);
} else {
console.warn('Cannot send message - socket not connected');
throw new Error('Socket not connected');
}
},
[socket]
);
/**
* Initialize chat session with tools
*/
const sendChatInit = useCallback(
async (payload: ChatInitPayload) => {
if (socket?.connected) {
socket.emit('chat:init', payload);
} else {
console.warn('Cannot initialize chat - socket not connected');
throw new Error('Socket not connected');
}
},
[socket]
);
/**
* Send typing indicator
*/
const sendTyping = useCallback(
(threadId: string, isTyping: boolean) => {
const payload = { threadId, isTyping };
if (socket?.connected) {
socket.emit('chat:typing', payload);
}
},
[socket]
);
/**
* Register WebSocket event handlers
*/
const registerEventHandlers = useCallback(() => {
if (!socket || eventHandlersRegistered.current) return;
// Agent response handler
const handleAgentResponse = (data: AgentResponsePayload) => {
console.log('Intelligence: Received agent response', {
threadId: data.threadId,
hasMessage: !!data.message,
shouldExecute: data.shouldExecute,
});
if (data.message && data.threadId) {
const aiMessage = createChatMessage(data.message, 'ai');
dispatch(addMessage({ threadId: data.threadId, message: aiMessage }));
}
// Stop typing indicator
dispatch(setTyping({ threadId: data.threadId, isTyping: false }));
// Handle execution trigger
if (data.shouldExecute && data.executionPlan) {
console.log('Intelligence: Execution requested', {
threadId: data.threadId,
plan: data.executionPlan,
});
// Execution will be handled by the component
}
};
// Execution progress handler
const handleExecutionProgress = (data: ExecutionProgressPayload) => {
console.log('Intelligence: Execution progress', {
executionId: data.executionId,
step: data.step?.label,
status: data.step?.status,
});
if (data.progress) {
dispatch(
updateExecutionProgress({ executionId: data.executionId, progress: data.progress })
);
}
};
// Execution complete handler
const handleExecutionComplete = (data: ExecutionCompletePayload) => {
console.log('Intelligence: Execution complete', {
executionId: data.executionId,
status: data.status,
hasResult: !!data.result,
hasArtifacts: !!data.artifacts?.length,
});
dispatch(
setExecutionResult({
executionId: data.executionId,
result: data.result,
status: data.status,
error: data.error,
})
);
// Send completion message if we have artifacts
if (data.artifacts?.length && data.sessionId) {
// This would need the threadId - we'd need to track execution to thread mapping
// For now, we'll let the component handle the completion message
console.log('Intelligence: Task completed with artifacts', {
artifactCount: data.artifacts.length,
sessionId: data.sessionId,
});
}
};
// Typing indicator handler
const handleTyping = (data: { threadId: string; isTyping: boolean }) => {
dispatch(setTyping({ threadId: data.threadId, isTyping: data.isTyping }));
};
// Register all handlers
socket.on('agentResponse', handleAgentResponse);
socket.on('execution:step_progress', handleExecutionProgress);
socket.on('execution:complete', handleExecutionComplete);
socket.on('chat:typing', handleTyping);
eventHandlersRegistered.current = true;
// Return cleanup function
return () => {
socket.off('agentResponse', handleAgentResponse);
socket.off('execution:step_progress', handleExecutionProgress);
socket.off('execution:complete', handleExecutionComplete);
socket.off('chat:typing', handleTyping);
eventHandlersRegistered.current = false;
};
}, [socket, dispatch]);
/**
* Register event handlers when socket is available
*/
useEffect(() => {
if (socket && isSocketConnected) {
const cleanup = registerEventHandlers();
return cleanup;
}
}, [socket, isSocketConnected, registerEventHandlers]);
/**
* Cleanup on unmount
*/
useEffect(() => {
return () => {
eventHandlersRegistered.current = false;
};
}, []);
const socketStatus = useAppSelector(selectSocketStatus);
return {
// Connection status
isConnected: isSocketConnected,
// Message sending
sendMessage,
sendChatInit,
sendTyping,
// Utility functions
isReady: isSocketConnected && !!socket,
isConnected: socketStatus === 'connected',
isReady: socketStatus === 'connected',
sendMessage: async () => {},
sendChatInit: async () => {},
sendTyping: () => {},
};
};
/**
* Hook for managing Intelligence WebSocket connection lifecycle
*/
export const useIntelligenceSocketManager = () => {
const token = useSelector((state: RootState) => state.auth.token);
const isConnected = useSelector((state: RootState) => {
const userId = state.user.user?._id || '__pending__';
return state.socket.byUser[userId]?.status === 'connected';
});
const { snapshot } = useCoreState();
const socketStatus = useAppSelector(selectSocketStatus);
const isConnected = socketStatus === 'connected';
const token = snapshot.sessionToken;
const previousTokenRef = useRef<string | null>(null);
/**
* Initialize Intelligence socket connection
*/
const connect = useCallback(() => {
if (token && !isConnected) {
console.log('Intelligence: Initializing socket connection');
socketService.connect(token);
}
}, [token, isConnected]);
const connect = useCallback(
(nextToken?: string | null) => {
const tokenToUse = nextToken ?? token;
if (tokenToUse) {
socketService.connect(tokenToUse);
}
},
[token]
);
/**
* Disconnect Intelligence socket
*/
const disconnect = useCallback(() => {
console.log('Intelligence: Disconnecting socket');
socketService.disconnect();
}, []);
/**
* Auto-connect when token is available
*/
useEffect(() => {
if (token && !isConnected) {
const previousToken = previousTokenRef.current;
if (!token) {
if (previousToken || isConnected) {
disconnect();
}
previousTokenRef.current = null;
return;
}
if (previousToken && previousToken !== token) {
disconnect();
previousTokenRef.current = token;
connect(token);
return;
}
if (!isConnected) {
previousTokenRef.current = token;
connect();
}
}, [token, isConnected, connect]);
}, [connect, disconnect, isConnected, token]);
return { connect, disconnect, isConnected, isReady: isConnected && !!token };
return { connect, disconnect, isConnected, isReady: Boolean(token) && isConnected };
};
/**
* Hook for Intelligence-specific event subscriptions
*/
export const useIntelligenceEvents = () => {
const socket = socketService.getSocket();
/**
* Subscribe to agent responses for a specific thread
*/
const onAgentResponse = useCallback(
(threadId: string, callback: (data: AgentResponsePayload) => void) => {
if (!socket) return () => {};
const handler = (data: AgentResponsePayload) => {
if (data.threadId === threadId) {
callback(data);
}
};
socket.on('agentResponse', handler);
return () => socket.off('agentResponse', handler);
},
[socket]
);
/**
* Subscribe to execution progress for a specific execution
*/
const onExecutionProgress = useCallback(
(executionId: string, callback: (data: ExecutionProgressPayload) => void) => {
if (!socket) return () => {};
const handler = (data: ExecutionProgressPayload) => {
if (data.executionId === executionId) {
callback(data);
}
};
socket.on('execution:step_progress', handler);
return () => socket.off('execution:step_progress', handler);
},
[socket]
);
/**
* Subscribe to execution completion for a specific execution
*/
const onExecutionComplete = useCallback(
(executionId: string, callback: (data: ExecutionCompletePayload) => void) => {
if (!socket) return () => {};
const handler = (data: ExecutionCompletePayload) => {
if (data.executionId === executionId) {
callback(data);
}
};
socket.on('execution:complete', handler);
return () => socket.off('execution:complete', handler);
},
[socket]
);
return { onAgentResponse, onExecutionProgress, onExecutionComplete };
};
export const useIntelligenceEvents = () => ({
onAgentResponse: () => () => {},
onExecutionProgress: () => () => {},
onExecutionComplete: () => () => {},
});
+4 -25
View File
@@ -1,31 +1,10 @@
import { useEffect, useRef } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { fetchCurrentUser } from '../store/userSlice';
import { useCoreState } from '../providers/CoreStateProvider';
/**
* Hook to access user data and automatically fetch it when token is available
* Hook to access the current core-owned user snapshot.
*/
export const useUser = () => {
const dispatch = useAppDispatch();
const token = useAppSelector(state => state.auth.token);
const user = useAppSelector(state => state.user.user);
const isLoading = useAppSelector(state => state.user.isLoading);
const error = useAppSelector(state => state.user.error);
const lastAutoFetchTokenRef = useRef<string | null>(null);
const { isBootstrapping, snapshot, refresh } = useCoreState();
useEffect(() => {
if (!token) {
lastAutoFetchTokenRef.current = null;
return;
}
// Auto-fetch at most once per token to avoid infinite retry loops on persistent 401s.
if (!user && !isLoading && lastAutoFetchTokenRef.current !== token) {
lastAutoFetchTokenRef.current = token;
dispatch(fetchCurrentUser());
}
}, [token, user, isLoading, dispatch]);
return { user, isLoading, error, refetch: () => dispatch(fetchCurrentUser()) };
return { user: snapshot.currentUser, isLoading: isBootstrapping, error: null, refetch: refresh };
};
+3 -1
View File
@@ -1,6 +1,7 @@
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCoreState } from '../providers/CoreStateProvider';
import { tunnelsApi } from '../services/api/tunnelsApi';
import { getCoreHttpBaseUrl } from '../services/coreRpcClient';
import { useAppDispatch, useAppSelector } from '../store/hooks';
@@ -45,11 +46,12 @@ function logToActivity(entry: WebhookDebugLogEntry): WebhookActivityEntry {
* - Subscribes to SSE /events/webhooks for real-time activity updates
*/
export function useWebhooks() {
const { snapshot } = useCoreState();
const dispatch = useAppDispatch();
const { tunnels, registrations, activity, loading, error } = useAppSelector(
state => state.webhooks
);
const token = useAppSelector(state => state.auth.token);
const token = snapshot.sessionToken;
const [coreConnected, setCoreConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
+85
View File
@@ -0,0 +1,85 @@
import type { User } from '../../types/api';
import type { TeamInvite, TeamMember, TeamWithRole } from '../../types/team';
export interface CoreOnboardingTasks {
accessibilityPermissionGranted: boolean;
localModelConsentGiven: boolean;
localModelDownloadStarted: boolean;
enabledTools: string[];
connectedSources: string[];
updatedAtMs?: number;
}
export interface CoreLocalState {
encryptionKey: string | null;
primaryWalletAddress: string | null;
onboardingTasks: CoreOnboardingTasks | null;
}
export interface CoreAppSnapshot {
auth: {
isAuthenticated: boolean;
userId: string | null;
user: unknown | null;
profileId: string | null;
};
sessionToken: string | null;
currentUser: User | null;
onboardingCompleted: boolean;
analyticsEnabled: boolean;
localState: CoreLocalState;
}
export interface CoreState {
isBootstrapping: boolean;
isReady: boolean;
snapshot: CoreAppSnapshot;
teams: TeamWithRole[];
teamMembersById: Record<string, TeamMember[]>;
teamInvitesById: Record<string, TeamInvite[]>;
}
const emptySnapshot: CoreAppSnapshot = {
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
sessionToken: null,
currentUser: null,
onboardingCompleted: false,
analyticsEnabled: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
};
let currentState: CoreState = {
isBootstrapping: true,
isReady: false,
snapshot: emptySnapshot,
teams: [],
teamMembersById: {},
teamInvitesById: {},
};
export function getCoreStateSnapshot(): CoreState {
return currentState;
}
export function setCoreStateSnapshot(next: CoreState): void {
currentState = next;
}
export function patchCoreStateSnapshot(patch: {
snapshot?: Record<string, unknown> & { localState?: Partial<CoreLocalState> };
[key: string]: unknown;
}): void {
currentState = {
...currentState,
...patch,
snapshot: patch.snapshot
? {
...currentState.snapshot,
...patch.snapshot,
localState: patch.snapshot.localState
? { ...currentState.snapshot.localState, ...patch.snapshot.localState }
: currentState.snapshot.localState,
}
: currentState.snapshot,
};
}
+14 -12
View File
@@ -6,6 +6,7 @@
*/
import { callCoreRpc } from "../../services/coreRpcClient";
import { getCoreStateSnapshot, patchCoreStateSnapshot } from "../../lib/coreState/store";
import { SkillRuntime } from "./runtime";
import { emitSkillStateChange } from "./skillEvents";
import {
@@ -25,8 +26,6 @@ import type {
SkillToolDefinition,
SkillOptionDefinition,
} from "./types";
import { store } from "../../store";
import { setPrimaryWalletAddressForUser } from "../../store/authSlice";
import {
runtimeSkillDataDir,
runtimeSkillDataRead,
@@ -47,10 +46,7 @@ class SkillManager {
const params: Record<string, unknown> = {};
if (skillId === "wallet") {
const state = store.getState();
const userId = state.user.user?._id;
const primaryAddress =
userId && state.auth.primaryWalletAddressByUser?.[userId];
const primaryAddress = getCoreStateSnapshot().snapshot.localState.primaryWalletAddress;
if (primaryAddress) {
params.walletAddress = primaryAddress;
}
@@ -525,12 +521,18 @@ class SkillManager {
* sends load params so the skill receives onLoad({ walletAddress }).
*/
async setWalletAddress(address: string): Promise<void> {
const state = store.getState();
const userId = state.user.user?._id;
if (!userId) {
return;
}
store.dispatch(setPrimaryWalletAddressForUser({ userId, address }));
await callCoreRpc({
method: "openhuman.app_state_update_local_state",
params: { primaryWalletAddress: address },
});
patchCoreStateSnapshot({
snapshot: {
localState: {
...getCoreStateSnapshot().snapshot.localState,
primaryWalletAddress: address,
},
},
});
const runtime = this.runtimes.get("wallet");
if (runtime?.isRunning) {
await runtime.load({ walletAddress: address });
+2 -2
View File
@@ -5,13 +5,13 @@ import ReactDOM from 'react-dom/client';
import App from './App';
import ErrorReportNotification from './components/ErrorReportNotification';
import './index.css';
import { getCoreStateSnapshot } from './lib/coreState/store';
import './polyfills';
import { initSentry } from './services/analytics';
import { setStoreForApiClient } from './services/apiClient';
import { store } from './store';
import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener';
setStoreForApiClient(() => store.getState().auth.token);
setStoreForApiClient(() => getCoreStateSnapshot().snapshot.sessionToken);
const ensureDefaultHashRoute = () => {
const hash = window.location.hash;
+12 -19
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ActionableCard } from '../components/intelligence/ActionableCard';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
@@ -17,8 +16,6 @@ import {
} from '../hooks/useIntelligenceSocket';
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
import { useScreenIntelligenceItems } from '../hooks/useScreenIntelligenceItems';
import type { RootState } from '../store';
import { setSearchFilter, setSourceFilter } from '../store/intelligenceSlice';
import type {
ActionableItem,
ActionableItemSource,
@@ -30,14 +27,12 @@ import type {
type IntelligenceTab = 'memory' | 'subconscious' | 'dreams';
export default function Intelligence() {
const dispatch = useDispatch();
const { aiStatus } = useIntelligenceStats();
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
// Redux state
const intelligenceState = useSelector((state: RootState) => state.intelligence);
const { filters } = intelligenceState;
const [sourceFilter, setSourceFilter] = useState<ActionableItemSource | 'all'>('all');
const [priorityFilter] = useState<'critical' | 'important' | 'normal' | 'all'>('all');
const [searchFilter, setSearchFilter] = useState('');
// Conscious memory items (real data from the background analysis loop)
const {
@@ -96,11 +91,11 @@ export default function Intelligence() {
const filteredItems = useMemo(() => {
const activeItems = items.filter(item => item.status === 'active');
return filterItems(activeItems, {
source: filters.source,
priority: filters.priority,
searchTerm: filters.search,
source: sourceFilter,
priority: priorityFilter,
searchTerm: searchFilter,
});
}, [items, filters.source, filters.priority, filters.search]);
}, [items, priorityFilter, searchFilter, sourceFilter]);
const timeGroups = useMemo(() => groupItemsByTime(filteredItems), [filteredItems]);
const stats = useMemo(() => getItemStats(filteredItems), [filteredItems]);
@@ -328,16 +323,14 @@ export default function Intelligence() {
<input
type="text"
placeholder="Search actionable items..."
value={filters.search}
onChange={e => dispatch(setSearchFilter(e.target.value))}
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
/>
</div>
<select
value={filters.source}
onChange={e =>
dispatch(setSourceFilter(e.target.value as ActionableItemSource | 'all'))
}
value={sourceFilter}
onChange={e => setSourceFilter(e.target.value as ActionableItemSource | 'all')}
className="px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors">
<option value="all">All Sources</option>
<option value="email">Email</option>
@@ -389,7 +382,7 @@ export default function Intelligence() {
/>
</svg>
</div>
{filters.search || filters.source !== 'all' ? (
{searchFilter || sourceFilter !== 'all' ? (
<>
<h2 className="text-lg font-semibold text-stone-900 mb-2">No matches</h2>
<p className="text-stone-400 text-sm">
+4 -5
View File
@@ -3,8 +3,7 @@ import { useNavigate } from 'react-router-dom';
import LottieAnimation from '../components/LottieAnimation';
import { skillManager } from '../lib/skills/manager';
import { setEncryptionKeyForUser } from '../store/authSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { useCoreState } from '../providers/CoreStateProvider';
import {
deriveAesKeyFromMnemonic,
deriveEvmAddressFromMnemonic,
@@ -20,8 +19,8 @@ const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
const Mnemonic = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const { snapshot, setEncryptionKey } = useCoreState();
const user = snapshot.currentUser;
const [mode, setMode] = useState<'generate' | 'import'>('generate');
const [copied, setCopied] = useState(false);
const [confirmed, setConfirmed] = useState(false);
@@ -167,7 +166,7 @@ const Mnemonic = () => {
console.error('[Mnemonic] Cannot save encryption key: user not loaded');
return;
}
dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey }));
await setEncryptionKey(aesKey);
await skillManager.setWalletAddress(walletAddress);
navigate('/home');
} catch (e) {
+11 -27
View File
@@ -2,12 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import ProgressIndicator from '../../components/ProgressIndicator';
import { useUser } from '../../hooks/useUser';
import { useCoreState } from '../../providers/CoreStateProvider';
import { userApi } from '../../services/api/userApi';
import { setOnboardedForUser, setOnboardingTasksForUser } from '../../store/authSlice';
import { useAppDispatch } from '../../store/hooks';
import { bootstrapLocalAiWithRecommendedPreset } from '../../utils/localAiBootstrap';
import { setOnboardingCompleted } from '../../utils/tauriCommands';
import LocalAIStep from './steps/LocalAIStep';
import ScreenPermissionsStep from './steps/ScreenPermissionsStep';
import SkillsStep from './steps/SkillsStep';
@@ -30,8 +27,7 @@ interface OnboardingDraft {
const LOCAL_AI_ERROR_DISMISS_MS = 10_000;
const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
const dispatch = useAppDispatch();
const { user } = useUser();
const { setOnboardingCompletedFlag, setOnboardingTasks } = useCoreState();
const [currentStep, setCurrentStep] = useState(0);
const [downloadError, setDownloadError] = useState<string | null>(null);
const retryInFlightRef = useRef(false);
@@ -108,21 +104,14 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
const handleSkillsNext = async (connectedSources: string[]) => {
setDraft(prev => ({ ...prev, connectedSources }));
// Persist onboarding tasks
if (user?._id) {
dispatch(
setOnboardingTasksForUser({
userId: user._id,
tasks: {
accessibilityPermissionGranted: draft.accessibilityPermissionGranted,
localModelConsentGiven: draft.localModelConsentGiven,
localModelDownloadStarted: draft.localModelDownloadStarted,
enabledTools: draft.enabledTools,
connectedSources,
},
})
);
}
await setOnboardingTasks({
accessibilityPermissionGranted: draft.accessibilityPermissionGranted,
localModelConsentGiven: draft.localModelConsentGiven,
localModelDownloadStarted: draft.localModelDownloadStarted,
enabledTools: draft.enabledTools,
connectedSources,
updatedAtMs: Date.now(),
});
// Notify backend (best-effort — don't block onboarding completion)
try {
@@ -131,14 +120,9 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
console.warn('[onboarding] Failed to notify backend of onboarding completion');
}
// Mark onboarded in Redux (belt-and-suspenders alongside config)
if (user?._id) {
dispatch(setOnboardedForUser({ userId: user._id, value: true }));
}
// Write onboarding_completed to core config (source of truth)
try {
await setOnboardingCompleted(true);
await setOnboardingCompletedFlag(true);
} catch {
console.warn('[onboarding] Failed to persist onboarding_completed to core config');
}
@@ -1,8 +1,7 @@
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { skillManager } from '../../../lib/skills/manager';
import { setEncryptionKeyForUser } from '../../../store/authSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { useCoreState } from '../../../providers/CoreStateProvider';
import {
deriveAesKeyFromMnemonic,
deriveEvmAddressFromMnemonic,
@@ -22,8 +21,8 @@ interface MnemonicStepProps {
}
const MnemonicStep = ({ onNext, onBack: _onBack }: MnemonicStepProps) => {
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const { snapshot, setEncryptionKey } = useCoreState();
const user = snapshot.currentUser;
const [mode, setMode] = useState<'generate' | 'import'>('generate');
const [copied, setCopied] = useState(false);
const [confirmed, setConfirmed] = useState(false);
@@ -164,7 +163,7 @@ const MnemonicStep = ({ onNext, onBack: _onBack }: MnemonicStepProps) => {
setError('User not loaded. Please sign in again or refresh the page.');
return;
}
dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey }));
await setEncryptionKey(aesKey);
await skillManager.setWalletAddress(walletAddress);
await onNext();
} catch (e) {
+300
View File
@@ -0,0 +1,300 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
type CoreAppSnapshot,
type CoreOnboardingTasks,
type CoreState,
getCoreStateSnapshot,
setCoreStateSnapshot,
} from '../lib/coreState/store';
import { syncAnalyticsConsent } from '../services/analytics';
import {
fetchCoreAppSnapshot,
getTeamInvites,
getTeamMembers,
listTeams,
updateCoreLocalState,
} from '../services/coreStateApi';
import {
openhumanUpdateAnalyticsSettings,
setOnboardingCompleted,
storeSession,
syncMemoryClientToken,
logout as tauriLogout,
} from '../utils/tauriCommands';
const POLL_MS = 3000;
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>;
setOnboardingCompletedFlag: (value: boolean) => Promise<void>;
setEncryptionKey: (value: string | null) => Promise<void>;
setPrimaryWalletAddress: (value: string | null) => Promise<void>;
setOnboardingTasks: (value: CoreOnboardingTasks | null) => Promise<void>;
storeSessionToken: (token: string, user?: object) => Promise<void>;
clearSession: () => Promise<void>;
}
const CoreStateContext = createContext<CoreStateContextValue | null>(null);
function snapshotIdentity(snapshot: CoreAppSnapshot): string | null {
return snapshot.auth.userId ?? snapshot.currentUser?._id ?? null;
}
function normalizeSnapshot(
result: Awaited<ReturnType<typeof fetchCoreAppSnapshot>>
): CoreAppSnapshot {
return {
auth: result.auth,
sessionToken: result.sessionToken,
currentUser: result.currentUser,
onboardingCompleted: result.onboardingCompleted,
analyticsEnabled: result.analyticsEnabled,
localState: {
encryptionKey: result.localState.encryptionKey ?? null,
primaryWalletAddress: result.localState.primaryWalletAddress ?? null,
onboardingTasks: result.localState.onboardingTasks ?? null,
},
};
}
export default function CoreStateProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<CoreState>(() => getCoreStateSnapshot());
const snapshotRequestIdRef = useRef(0);
const teamsRequestIdRef = useRef(0);
const memoryTokenRef = useRef<string | null>(state.snapshot.sessionToken);
const commitState = useCallback((updater: (previous: CoreState) => CoreState) => {
setState(previous => {
const next = updater(previous);
setCoreStateSnapshot(next);
return next;
});
}, []);
const refresh = useCallback(async () => {
const requestId = ++snapshotRequestIdRef.current;
const snapshot = normalizeSnapshot(await fetchCoreAppSnapshot());
commitState(previous => {
if (requestId !== snapshotRequestIdRef.current) {
return previous;
}
const previousIdentity = snapshotIdentity(previous.snapshot);
const nextIdentity = snapshotIdentity(snapshot);
const shouldClearScopedCaches =
previousIdentity !== nextIdentity ||
(previous.snapshot.auth.isAuthenticated && !snapshot.auth.isAuthenticated);
return {
...previous,
isBootstrapping: false,
isReady: true,
snapshot,
teams: shouldClearScopedCaches ? [] : previous.teams,
teamMembersById: shouldClearScopedCaches ? {} : previous.teamMembersById,
teamInvitesById: shouldClearScopedCaches ? {} : previous.teamInvitesById,
};
});
syncAnalyticsConsent(snapshot.analyticsEnabled);
if (!snapshot.sessionToken) {
memoryTokenRef.current = null;
return;
}
if (memoryTokenRef.current !== snapshot.sessionToken) {
try {
await syncMemoryClientToken(snapshot.sessionToken);
memoryTokenRef.current = snapshot.sessionToken;
} catch (error) {
console.warn('[core-state] memory client sync failed during refresh:', error);
}
}
}, [commitState]);
const refreshTeams = useCallback(async () => {
const requestId = ++teamsRequestIdRef.current;
const identityAtStart = snapshotIdentity(getCoreStateSnapshot().snapshot);
const teams = await listTeams();
commitState(previous => {
if (requestId !== teamsRequestIdRef.current) {
return previous;
}
if (snapshotIdentity(previous.snapshot) !== identityAtStart) {
return previous;
}
return { ...previous, teams };
});
}, [commitState]);
const refreshTeamMembers = useCallback(
async (teamId: string) => {
const members = await getTeamMembers(teamId);
commitState(previous => ({
...previous,
teamMembersById: { ...previous.teamMembersById, [teamId]: members },
}));
},
[commitState]
);
const refreshTeamInvites = useCallback(
async (teamId: string) => {
const invites = await getTeamInvites(teamId);
commitState(previous => ({
...previous,
teamInvitesById: { ...previous.teamInvitesById, [teamId]: invites },
}));
},
[commitState]
);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
await refresh();
const next = getCoreStateSnapshot();
if (next.snapshot.auth.isAuthenticated) {
await refreshTeams().catch(() => {});
}
} catch (error) {
if (!cancelled) {
console.warn('[core-state] initial refresh failed:', error);
commitState(previous => ({ ...previous, isBootstrapping: false }));
}
}
};
void load();
const interval = window.setInterval(() => {
void refresh().catch(error => {
console.warn('[core-state] poll failed:', error);
});
}, POLL_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [commitState, refresh, refreshTeams]);
const setAnalyticsEnabled = useCallback(
async (enabled: boolean) => {
await openhumanUpdateAnalyticsSettings({ enabled });
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, analyticsEnabled: enabled },
}));
syncAnalyticsConsent(enabled);
},
[commitState]
);
const setOnboardingCompletedFlag = useCallback(
async (value: boolean) => {
await setOnboardingCompleted(value);
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, onboardingCompleted: value },
}));
},
[commitState]
);
const updateLocalState = useCallback(
async (params: Parameters<typeof updateCoreLocalState>[0]) => {
await updateCoreLocalState(params);
await refresh();
},
[refresh]
);
const storeSessionToken = useCallback(
async (token: string, user?: object) => {
await storeSession(token, user ?? {});
try {
await syncMemoryClientToken(token);
memoryTokenRef.current = token;
} catch (error) {
console.warn('[core-state] memory client sync failed after session store:', error);
}
await refresh();
await refreshTeams().catch(() => {});
},
[refresh, refreshTeams]
);
const clearSession = useCallback(async () => {
await tauriLogout();
commitState(previous => ({
...previous,
teams: [],
teamMembersById: {},
teamInvitesById: {},
snapshot: {
...previous.snapshot,
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
sessionToken: null,
currentUser: null,
onboardingCompleted: false,
},
}));
memoryTokenRef.current = null;
}, [commitState]);
const value = useMemo<CoreStateContextValue>(
() => ({
...state,
refresh,
refreshTeams,
refreshTeamMembers,
refreshTeamInvites,
setAnalyticsEnabled,
setOnboardingCompletedFlag,
setEncryptionKey: value => updateLocalState({ encryptionKey: value }),
setPrimaryWalletAddress: value => updateLocalState({ primaryWalletAddress: value }),
setOnboardingTasks: value => updateLocalState({ onboardingTasks: value }),
storeSessionToken,
clearSession,
}),
[
clearSession,
refresh,
refreshTeamInvites,
refreshTeamMembers,
refreshTeams,
setAnalyticsEnabled,
setOnboardingCompletedFlag,
state,
storeSessionToken,
updateLocalState,
]
);
return <CoreStateContext.Provider value={value}>{children}</CoreStateContext.Provider>;
}
export function useCoreState() {
const context = useContext(CoreStateContext);
if (!context) {
throw new Error('useCoreState must be used within CoreStateProvider');
}
return context;
}
@@ -1,64 +0,0 @@
import { createContext, type ReactNode, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useIntelligenceSocketManager } from '../hooks/useIntelligenceSocket';
import { setConnectionStatus, setInitialized } from '../store/intelligenceSlice';
/**
* Intelligence context for managing system-wide Intelligence state
*/
interface IntelligenceContextValue {
isInitialized: boolean;
isConnected: boolean;
initialize: () => void;
}
const IntelligenceContext = createContext<IntelligenceContextValue | null>(null);
interface IntelligenceProviderProps {
children: ReactNode;
}
/**
* Intelligence Provider - manages Intelligence system initialization and state
*/
export function IntelligenceProvider({ children }: IntelligenceProviderProps) {
const dispatch = useDispatch();
const socketManager = useIntelligenceSocketManager();
// Initialize Intelligence system
useEffect(() => {
dispatch(setInitialized(true));
dispatch(setConnectionStatus(socketManager.isConnected ? 'connected' : 'connecting'));
}, [dispatch, socketManager.isConnected]);
// Monitor connection status
useEffect(() => {
if (socketManager.isConnected) {
dispatch(setConnectionStatus('connected'));
} else {
dispatch(setConnectionStatus('connecting'));
}
}, [dispatch, socketManager.isConnected]);
const contextValue: IntelligenceContextValue = {
isInitialized: true,
isConnected: socketManager.isConnected,
initialize: socketManager.connect,
};
return (
<IntelligenceContext.Provider value={contextValue}>{children}</IntelligenceContext.Provider>
);
}
/**
* Hook to access Intelligence context
*/
export function useIntelligenceContext() {
const context = useContext(IntelligenceContext);
if (!context) {
throw new Error('useIntelligenceContext must be used within IntelligenceProvider');
}
return context;
}
+5 -5
View File
@@ -2,9 +2,8 @@ import { useEffect, useRef } from 'react';
import { useDaemonLifecycle } from '../hooks/useDaemonLifecycle';
import { socketService } from '../services/socketService';
import { store } from '../store';
import { useAppSelector } from '../store/hooks';
import { IS_DEV } from '../utils/config';
import { useCoreState } from './CoreStateProvider';
/**
* SocketProvider manages the socket connection based on JWT token.
@@ -12,7 +11,8 @@ import { IS_DEV } from '../utils/config';
* for both desktop and web.
*/
const SocketProvider = ({ children }: { children: React.ReactNode }) => {
const token = useAppSelector(state => state.auth.token);
const { snapshot } = useCoreState();
const token = snapshot.sessionToken;
const previousTokenRef = useRef<string | null>(null);
// Keep daemon lifecycle management for desktop health/recovery.
@@ -54,12 +54,12 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => {
// Cleanup on unmount only
useEffect(() => {
return () => {
const currentToken = store.getState().auth.token;
const currentToken = snapshot.sessionToken;
if (!currentToken) {
socketService.disconnect();
}
};
}, []);
}, [snapshot.sessionToken]);
return <>{children}</>;
};
+3 -6
View File
@@ -20,7 +20,7 @@
*/
import * as Sentry from '@sentry/react';
import { store } from '../store';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { IS_DEV, SENTRY_DSN } from '../utils/config';
import { enqueueError, registerSentrySender, type SanitizedSentryEvent } from './errorReportQueue';
@@ -69,10 +69,7 @@ function sanitizeException(
/** Check if the current user has opted into analytics. */
export function isAnalyticsEnabled(): boolean {
const state = store.getState();
const userId = state.user?.user?._id;
if (!userId) return false;
return state.auth.isAnalyticsEnabledByUser[userId] !== false;
return getCoreStateSnapshot().snapshot.analyticsEnabled;
}
// ---------------------------------------------------------------------------
@@ -137,7 +134,7 @@ export function initSentry(): void {
delete event.request;
// Strip user PII — keep only a stable anonymous ID
const userId = store.getState().user?.user?._id;
const userId = getCoreStateSnapshot().snapshot.currentUser?._id;
event.user = userId ? { id: userId } : undefined;
// Strip any extra/contexts that could contain Redux or localStorage data
+101 -71
View File
@@ -2,105 +2,112 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { teamApi } from '../teamApi';
// Mock the apiClient module
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
const mockCallCoreRpc = vi.fn();
vi.mock('../../apiClient', () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
vi.mock('../../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
describe('teamApi', () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
mockPut.mockReset();
mockDelete.mockReset();
mockCallCoreRpc.mockReset();
});
describe('getTeams', () => {
it('should call GET /teams and return data', async () => {
it('calls team_list_teams and returns the result', async () => {
const teams = [{ team: { _id: 't1', name: 'Team 1' }, role: 'ADMIN' }];
mockGet.mockResolvedValue({ success: true, data: teams });
mockCallCoreRpc.mockResolvedValue({ result: teams });
const result = await teamApi.getTeams();
expect(mockGet).toHaveBeenCalledWith('/teams');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_list_teams',
params: undefined,
});
expect(result).toEqual(teams);
});
it('should propagate errors', async () => {
mockGet.mockRejectedValue({ success: false, error: 'Unauthorized' });
it('propagates RPC errors', async () => {
const error = new Error('Unauthorized');
mockCallCoreRpc.mockRejectedValue(error);
await expect(teamApi.getTeams()).rejects.toEqual({ success: false, error: 'Unauthorized' });
await expect(teamApi.getTeams()).rejects.toThrow('Unauthorized');
});
});
describe('getTeam', () => {
it('should call GET /teams/:teamId', async () => {
it('calls team_get_team', async () => {
const team = { _id: 't1', name: 'Team 1' };
mockGet.mockResolvedValue({ success: true, data: team });
mockCallCoreRpc.mockResolvedValue({ result: team });
const result = await teamApi.getTeam('t1');
expect(mockGet).toHaveBeenCalledWith('/teams/t1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_get_team',
params: { teamId: 't1' },
});
expect(result).toEqual(team);
});
});
describe('createTeam', () => {
it('should call POST /teams with name', async () => {
it('calls team_create_team with name', async () => {
const team = { _id: 't2', name: 'New Team' };
mockPost.mockResolvedValue({ success: true, data: team });
mockCallCoreRpc.mockResolvedValue({ result: team });
const result = await teamApi.createTeam('New Team');
expect(mockPost).toHaveBeenCalledWith('/teams', { name: 'New Team' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_create_team',
params: { name: 'New Team' },
});
expect(result).toEqual(team);
});
});
describe('updateTeam', () => {
it('should call PUT /teams/:teamId with data', async () => {
it('calls team_update_team with data', async () => {
const team = { _id: 't1', name: 'Updated' };
mockPut.mockResolvedValue({ success: true, data: team });
mockCallCoreRpc.mockResolvedValue({ result: team });
const result = await teamApi.updateTeam('t1', { name: 'Updated' });
expect(mockPut).toHaveBeenCalledWith('/teams/t1', { name: 'Updated' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_update_team',
params: { teamId: 't1', name: 'Updated' },
});
expect(result).toEqual(team);
});
});
describe('deleteTeam', () => {
it('should call DELETE /teams/:teamId', async () => {
mockDelete.mockResolvedValue({ success: true, data: null });
it('calls team_delete_team', async () => {
mockCallCoreRpc.mockResolvedValue({ result: null });
await teamApi.deleteTeam('t1');
expect(mockDelete).toHaveBeenCalledWith('/teams/t1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_delete_team',
params: { teamId: 't1' },
});
});
});
describe('switchTeam', () => {
it('should call POST /teams/:teamId/switch', async () => {
mockPost.mockResolvedValue({ success: true, data: null });
it('calls team_switch_team', async () => {
mockCallCoreRpc.mockResolvedValue({ result: null });
await teamApi.switchTeam('t1');
expect(mockPost).toHaveBeenCalledWith('/teams/t1/switch');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_switch_team',
params: { teamId: 't1' },
});
});
});
describe('getMembers', () => {
it('should call GET /teams/:teamId/members', async () => {
it('calls team_list_members', async () => {
const members = [
{
_id: 'm1',
@@ -109,108 +116,131 @@ describe('teamApi', () => {
joinedAt: '2026-01-01',
},
];
mockGet.mockResolvedValue({ success: true, data: members });
mockCallCoreRpc.mockResolvedValue({ result: members });
const result = await teamApi.getMembers('t1');
expect(mockGet).toHaveBeenCalledWith('/teams/t1/members');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_list_members',
params: { teamId: 't1' },
});
expect(result).toEqual(members);
});
});
describe('removeMember', () => {
it('should call DELETE /teams/:teamId/members/:userId', async () => {
mockDelete.mockResolvedValue({ success: true, data: null });
it('calls team_remove_member', async () => {
mockCallCoreRpc.mockResolvedValue({ result: null });
await teamApi.removeMember('t1', 'u2');
expect(mockDelete).toHaveBeenCalledWith('/teams/t1/members/u2');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_remove_member',
params: { teamId: 't1', userId: 'u2' },
});
});
});
describe('changeMemberRole', () => {
it('should call PUT /teams/:teamId/members/:userId/role', async () => {
mockPut.mockResolvedValue({ success: true, data: null });
it('calls team_change_member_role', async () => {
mockCallCoreRpc.mockResolvedValue({ result: null });
await teamApi.changeMemberRole('t1', 'u2', 'BILLING_MANAGER');
expect(mockPut).toHaveBeenCalledWith('/teams/t1/members/u2/role', {
role: 'BILLING_MANAGER',
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_change_member_role',
params: { teamId: 't1', userId: 'u2', role: 'BILLING_MANAGER' },
});
});
});
describe('leaveTeam', () => {
it('should call POST /teams/:teamId/leave', async () => {
mockPost.mockResolvedValue({ success: true, data: null });
it('calls team_leave_team', async () => {
mockCallCoreRpc.mockResolvedValue({ result: null });
await teamApi.leaveTeam('t1');
expect(mockPost).toHaveBeenCalledWith('/teams/t1/leave');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_leave_team',
params: { teamId: 't1' },
});
});
});
describe('createInvite', () => {
it('should call POST /teams/:teamId/invites without opts', async () => {
it('calls team_create_invite without optional fields by default', async () => {
const invite = { _id: 'inv1', code: 'ABC123' };
mockPost.mockResolvedValue({ success: true, data: invite });
mockCallCoreRpc.mockResolvedValue({ result: invite });
const result = await teamApi.createInvite('t1');
expect(mockPost).toHaveBeenCalledWith('/teams/t1/invites', undefined);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_create_invite',
params: { teamId: 't1' },
});
expect(result).toEqual(invite);
});
it('should pass options when provided', async () => {
it('passes invite options when provided', async () => {
const invite = { _id: 'inv2', code: 'XYZ789' };
mockPost.mockResolvedValue({ success: true, data: invite });
mockCallCoreRpc.mockResolvedValue({ result: invite });
await teamApi.createInvite('t1', { maxUses: 5, expiresInDays: 7 });
expect(mockPost).toHaveBeenCalledWith('/teams/t1/invites', { maxUses: 5, expiresInDays: 7 });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_create_invite',
params: { teamId: 't1', maxUses: 5, expiresInDays: 7 },
});
});
});
describe('getInvites', () => {
it('should call GET /teams/:teamId/invites', async () => {
it('calls team_list_invites', async () => {
const invites = [{ _id: 'inv1', code: 'ABC123' }];
mockGet.mockResolvedValue({ success: true, data: invites });
mockCallCoreRpc.mockResolvedValue({ result: invites });
const result = await teamApi.getInvites('t1');
expect(mockGet).toHaveBeenCalledWith('/teams/t1/invites');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_list_invites',
params: { teamId: 't1' },
});
expect(result).toEqual(invites);
});
});
describe('revokeInvite', () => {
it('should call DELETE /teams/:teamId/invites/:inviteId', async () => {
mockDelete.mockResolvedValue({ success: true, data: null });
it('calls team_revoke_invite', async () => {
mockCallCoreRpc.mockResolvedValue({ result: null });
await teamApi.revokeInvite('t1', 'inv1');
expect(mockDelete).toHaveBeenCalledWith('/teams/t1/invites/inv1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_revoke_invite',
params: { teamId: 't1', inviteId: 'inv1' },
});
});
});
describe('joinTeam', () => {
it('should call POST /teams/join with code in body', async () => {
it('calls team_join_team with code', async () => {
const team = { _id: 't3', name: 'Joined Team' };
mockPost.mockResolvedValue({ success: true, data: team });
mockCallCoreRpc.mockResolvedValue({ result: team });
const result = await teamApi.joinTeam('ABC123');
expect(mockPost).toHaveBeenCalledWith('/teams/join', { code: 'ABC123' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.team_join_team',
params: { code: 'ABC123' },
});
expect(result).toEqual(team);
});
it('should propagate errors for invalid codes', async () => {
mockPost.mockRejectedValue({ success: false, error: 'Invalid invite code' });
it('propagates errors for invalid codes', async () => {
const error = new Error('Invalid invite code');
mockCallCoreRpc.mockRejectedValue(error);
await expect(teamApi.joinTeam('INVALID')).rejects.toEqual({
success: false,
error: 'Invalid invite code',
});
await expect(teamApi.joinTeam('INVALID')).rejects.toThrow('Invalid invite code');
});
});
});
+25 -57
View File
@@ -1,89 +1,57 @@
import type { ApiResponse } from '../../types/api';
import type { Team, TeamInvite, TeamMember, TeamRole, TeamWithRole } from '../../types/team';
import { apiClient } from '../apiClient';
import { callCoreRpc } from '../coreRpcClient';
async function rpcResult<T>(method: string, params?: Record<string, unknown>): Promise<T> {
const response = await callCoreRpc<{ result: T }>({ method, params });
return response.result;
}
export const teamApi = {
/** GET /teams — list all teams the user belongs to */
getTeams: async (): Promise<TeamWithRole[]> => {
const response = await apiClient.get<ApiResponse<TeamWithRole[]>>('/teams');
return response.data;
},
getTeams: async (): Promise<TeamWithRole[]> => rpcResult('openhuman.team_list_teams'),
/** GET /teams/:teamId */
getTeam: async (teamId: string): Promise<Team> => {
const response = await apiClient.get<ApiResponse<Team>>(`/teams/${teamId}`);
return response.data;
},
getTeam: async (teamId: string): Promise<Team> =>
rpcResult('openhuman.team_get_team', { teamId }),
/** POST /teams — create a new team */
createTeam: async (name: string): Promise<Team> => {
const response = await apiClient.post<ApiResponse<Team>>('/teams', { name });
return response.data;
},
createTeam: async (name: string): Promise<Team> =>
rpcResult('openhuman.team_create_team', { name }),
/** PUT /teams/:teamId */
updateTeam: async (teamId: string, data: { name?: string }): Promise<Team> => {
const response = await apiClient.put<ApiResponse<Team>>(`/teams/${teamId}`, data);
return response.data;
},
updateTeam: async (teamId: string, data: { name?: string }): Promise<Team> =>
rpcResult('openhuman.team_update_team', { teamId, ...data }),
/** DELETE /teams/:teamId */
deleteTeam: async (teamId: string): Promise<void> => {
await apiClient.delete<ApiResponse<unknown>>(`/teams/${teamId}`);
await rpcResult('openhuman.team_delete_team', { teamId });
},
/** POST /teams/:teamId/switch — set as active team */
switchTeam: async (teamId: string): Promise<void> => {
await apiClient.post<ApiResponse<unknown>>(`/teams/${teamId}/switch`);
await rpcResult('openhuman.team_switch_team', { teamId });
},
/** GET /teams/:teamId/members */
getMembers: async (teamId: string): Promise<TeamMember[]> => {
const response = await apiClient.get<ApiResponse<TeamMember[]>>(`/teams/${teamId}/members`);
return response.data;
},
getMembers: async (teamId: string): Promise<TeamMember[]> =>
rpcResult('openhuman.team_list_members', { teamId }),
/** DELETE /teams/:teamId/members/:userId */
removeMember: async (teamId: string, userId: string): Promise<void> => {
await apiClient.delete<ApiResponse<unknown>>(`/teams/${teamId}/members/${userId}`);
await rpcResult('openhuman.team_remove_member', { teamId, userId });
},
/** PUT /teams/:teamId/members/:userId/role */
changeMemberRole: async (teamId: string, userId: string, role: TeamRole): Promise<void> => {
await apiClient.put<ApiResponse<unknown>>(`/teams/${teamId}/members/${userId}/role`, { role });
await rpcResult('openhuman.team_change_member_role', { teamId, userId, role });
},
/** POST /teams/:teamId/leave */
leaveTeam: async (teamId: string): Promise<void> => {
await apiClient.post<ApiResponse<unknown>>(`/teams/${teamId}/leave`);
await rpcResult('openhuman.team_leave_team', { teamId });
},
/** POST /teams/:teamId/invites */
createInvite: async (
teamId: string,
opts?: { maxUses?: number; expiresInDays?: number }
): Promise<TeamInvite> => {
const response = await apiClient.post<ApiResponse<TeamInvite>>(
`/teams/${teamId}/invites`,
opts
);
return response.data;
},
): Promise<TeamInvite> => rpcResult('openhuman.team_create_invite', { teamId, ...opts }),
/** GET /teams/:teamId/invites */
getInvites: async (teamId: string): Promise<TeamInvite[]> => {
const response = await apiClient.get<ApiResponse<TeamInvite[]>>(`/teams/${teamId}/invites`);
return response.data;
},
getInvites: async (teamId: string): Promise<TeamInvite[]> =>
rpcResult('openhuman.team_list_invites', { teamId }),
/** DELETE /teams/:teamId/invites/:inviteId */
revokeInvite: async (teamId: string, inviteId: string): Promise<void> => {
await apiClient.delete<ApiResponse<unknown>>(`/teams/${teamId}/invites/${inviteId}`);
await rpcResult('openhuman.team_revoke_invite', { teamId, inviteId });
},
/** POST /teams/join — join a team via invite code */
joinTeam: async (code: string): Promise<Team> => {
const response = await apiClient.post<ApiResponse<Team>>('/teams/join', { code });
return response.data;
},
joinTeam: async (code: string): Promise<Team> => rpcResult('openhuman.team_join_team', { code }),
};
+70
View File
@@ -0,0 +1,70 @@
import type { User } from '../types/api';
import type { TeamInvite, TeamMember, TeamWithRole } from '../types/team';
import { callCoreRpc } from './coreRpcClient';
export interface OnboardingTasks {
accessibilityPermissionGranted: boolean;
localModelConsentGiven: boolean;
localModelDownloadStarted: boolean;
enabledTools: string[];
connectedSources: string[];
updatedAtMs?: number;
}
export interface UpdateCoreLocalStateParams {
encryptionKey?: string | null;
primaryWalletAddress?: string | null;
onboardingTasks?: OnboardingTasks | null;
}
interface AppStateSnapshotResult {
auth: {
isAuthenticated: boolean;
userId: string | null;
user: unknown | null;
profileId: string | null;
};
sessionToken: string | null;
currentUser: User | null;
onboardingCompleted: boolean;
analyticsEnabled: boolean;
localState: {
encryptionKey?: string | null;
primaryWalletAddress?: string | null;
onboardingTasks?: OnboardingTasks | null;
};
}
export const fetchCoreAppSnapshot = async (): Promise<AppStateSnapshotResult> => {
const response = await callCoreRpc<{ result: AppStateSnapshotResult }>({
method: 'openhuman.app_state_snapshot',
});
return response.result;
};
export const updateCoreLocalState = async (params: UpdateCoreLocalStateParams): Promise<void> => {
await callCoreRpc({ method: 'openhuman.app_state_update_local_state', params });
};
export const listTeams = async (): Promise<TeamWithRole[]> => {
const response = await callCoreRpc<{ result: TeamWithRole[] }>({
method: 'openhuman.team_list_teams',
});
return response.result;
};
export const getTeamMembers = async (teamId: string): Promise<TeamMember[]> => {
const response = await callCoreRpc<{ result: TeamMember[] }>({
method: 'openhuman.team_list_members',
params: { teamId },
});
return response.result;
};
export const getTeamInvites = async (teamId: string): Promise<TeamInvite[]> => {
const response = await callCoreRpc<{ result: TeamInvite[] }>({
method: 'openhuman.team_list_invites',
params: { teamId },
});
return response.result;
};
+2 -1
View File
@@ -4,6 +4,7 @@
* Manages health monitoring for the openhuman daemon by polling
* `openhuman.health_snapshot` over core RPC from the frontend.
*/
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { store } from '../store';
import {
type ComponentHealth,
@@ -177,7 +178,7 @@ export class DaemonHealthService {
* Get the current user ID for daemon state management.
*/
private getUserId(): string {
const token = store.getState().auth.token;
const token = getCoreStateSnapshot().snapshot.sessionToken;
if (!token) return '__pending__';
try {
+2 -1
View File
@@ -2,6 +2,7 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
import debug from 'debug';
import { io, Socket } from 'socket.io-client';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { SocketIOMCPTransportImpl } from '../lib/mcp';
import { skillManager, syncToolsToBackend } from '../lib/skills';
import { store } from '../store';
@@ -94,7 +95,7 @@ function normalizeChannelConnectionUpdatePayload(
}
function getSocketUserId(): string {
const token = store.getState().auth.token;
const token = getCoreStateSnapshot().snapshot.sessionToken;
if (!token) return '__pending__';
try {
+43 -19
View File
@@ -1,61 +1,85 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { type CoreState, setCoreStateSnapshot } from '../../lib/coreState/store';
import type { RootState } from '../index';
import { selectSocketId, selectSocketStatus } from '../socketSelectors';
// Create a mock JWT with payload { tgUserId: "tg-user-1" }
function encodeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.signature`;
}
function makeCoreState(token: string | null): CoreState {
return {
isBootstrapping: false,
isReady: true,
snapshot: {
auth: { isAuthenticated: !!token, userId: null, user: null, profileId: null },
sessionToken: token,
currentUser: null,
onboardingCompleted: false,
analyticsEnabled: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
},
teams: [],
teamMembersById: {},
teamInvitesById: {},
};
}
function makeState(
token: string | null,
byUser: Record<string, { status: string; socketId: string | null }> = {}
): RootState {
return {
auth: { token, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
socket: { byUser },
user: { user: null, isLoading: false, error: null },
team: {} as RootState['team'],
ai: {} as RootState['ai'],
} as RootState;
return { socket: { byUser } } as RootState;
}
describe('selectSocketStatus', () => {
beforeEach(() => {
setCoreStateSnapshot(makeCoreState(null));
});
it('returns disconnected when no token', () => {
const state = makeState(null);
const state = makeState();
expect(selectSocketStatus(state)).toBe('disconnected');
});
it('returns status from user state based on JWT tgUserId', () => {
const token = encodeJwt({ tgUserId: 'tg123' });
const state = makeState(token, { tg123: { status: 'connected', socketId: 'sock-1' } });
setCoreStateSnapshot(makeCoreState(encodeJwt({ tgUserId: 'tg123' })));
const state = makeState({ tg123: { status: 'connected', socketId: 'sock-1' } });
expect(selectSocketStatus(state)).toBe('connected');
});
it('returns disconnected when JWT user has no socket state', () => {
const token = encodeJwt({ tgUserId: 'tg123' });
const state = makeState(token, {});
setCoreStateSnapshot(makeCoreState(encodeJwt({ tgUserId: 'tg123' })));
const state = makeState();
expect(selectSocketStatus(state)).toBe('disconnected');
});
it('uses __pending__ for invalid JWT', () => {
const state = makeState('not-a-jwt', { __pending__: { status: 'connecting', socketId: null } });
setCoreStateSnapshot(makeCoreState('not-a-jwt'));
const state = makeState({ __pending__: { status: 'connecting', socketId: null } });
expect(selectSocketStatus(state)).toBe('connecting');
});
});
describe('selectSocketId', () => {
beforeEach(() => {
setCoreStateSnapshot(makeCoreState(null));
});
it('returns null when no token', () => {
const state = makeState(null);
const state = makeState();
expect(selectSocketId(state)).toBeNull();
});
it('returns socketId from user state', () => {
const token = encodeJwt({ tgUserId: 'tg123' });
const state = makeState(token, { tg123: { status: 'connected', socketId: 'sock-abc' } });
setCoreStateSnapshot(makeCoreState(encodeJwt({ tgUserId: 'tg123' })));
const state = makeState({ tg123: { status: 'connected', socketId: 'sock-abc' } });
expect(selectSocketId(state)).toBe('sock-abc');
});
});
+25 -99
View File
@@ -1,4 +1,4 @@
import { configureStore, type Middleware } from '@reduxjs/toolkit';
import { configureStore } from '@reduxjs/toolkit';
import { createLogger } from 'redux-logger';
import {
FLUSH,
@@ -12,41 +12,21 @@ import {
} from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { DEV_JWT_TOKEN, IS_DEV } from '../utils/config';
import {
logout as clearRustSession,
storeSession,
syncMemoryClientToken,
} from '../utils/tauriCommands';
import type { User } from '../types/api';
import type { TeamInvite, TeamMember, TeamWithRole } from '../types/team';
import { IS_DEV } from '../utils/config';
import accessibilityReducer from './accessibilitySlice';
import aiReducer from './aiSlice';
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
import type { AuthState } from './authSlice';
import channelConnectionsReducer from './channelConnectionsSlice';
import daemonReducer from './daemonSlice';
import dictationReducer from './dictationSlice';
import intelligenceReducer from './intelligenceSlice';
import type { IntelligenceState } from './intelligenceSlice';
import inviteReducer from './inviteSlice';
import socketReducer from './socketSlice';
import teamReducer from './teamSlice';
import threadReducer from './threadSlice';
import userReducer from './userSlice';
import webhooksReducer from './webhooksSlice';
// Persist config for auth only
const authPersistConfig = {
key: 'auth',
storage,
whitelist: [
'token',
'isOnboardedByUser',
'onboardingTasksByUser',
'hasIncompleteOnboardingByUser',
'isAnalyticsEnabledByUser',
'encryptionKeyByUser',
'primaryWalletAddressByUser',
],
};
// Persist config for AI state (config only)
const aiPersistConfig = { key: 'ai', storage, whitelist: ['config'] };
@@ -58,7 +38,6 @@ const threadPersistConfig = {
whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'],
};
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer);
const persistedThreadReducer = persistReducer(threadPersistConfig, threadReducer);
const dictationPersistConfig = {
@@ -77,65 +56,12 @@ const persistedChannelConnectionsReducer = persistReducer(
channelConnectionsReducer
);
/**
* Middleware that syncs the JWT token to the Rust SESSION_SERVICE whenever
* setToken is dispatched or auth state is rehydrated from persist.
*/
const syncTokenToRust: Middleware = () => {
let lastSyncedToken: string | null = null;
return next => action => {
const result = next(action);
const syncToken = (token: string) => {
if (token === lastSyncedToken) return;
lastSyncedToken = token;
// Pass a minimal user object — the token is what matters for SESSION_SERVICE
storeSession(token, { id: '' }).catch(err =>
console.warn('[syncTokenToRust] Failed to sync token:', err)
);
syncMemoryClientToken(token).catch(err =>
console.warn('[syncTokenToRust] Failed to sync memory token:', err)
);
};
// Sync on explicit setToken
if (setToken.match(action) && action.payload) {
syncToken(action.payload);
}
if ((action as { type?: string }).type === 'auth/_clearToken') {
lastSyncedToken = null;
clearRustSession().catch(err =>
console.warn('[syncTokenToRust] Failed to clear core session:', err)
);
}
// Sync on rehydration (app restart — persist loads token from localStorage)
const a = action as { type?: string; key?: string; payload?: { token?: string } };
if (a.type === REHYDRATE && a.key === 'auth') {
const token = a.payload?.token;
if (token) {
syncToken(token);
} else {
lastSyncedToken = null;
}
}
return result;
};
};
export const store = configureStore({
reducer: {
auth: persistedAuthReducer,
socket: socketReducer,
user: userReducer,
daemon: daemonReducer,
ai: persistedAiReducer,
team: teamReducer,
thread: persistedThreadReducer,
intelligence: intelligenceReducer,
invite: inviteReducer,
accessibility: accessibilityReducer,
dictation: persistedDictationReducer,
@@ -145,7 +71,7 @@ export const store = configureStore({
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] },
}).concat(syncTokenToRust);
});
// Add redux-logger in development with collapsed groups
if (IS_DEV) {
@@ -155,24 +81,24 @@ export const store = configureStore({
},
});
export const persistor = persistStore(store, null, () => {
// Dev-only: auto-inject JWT token for local testing without login flow.
if (DEV_JWT_TOKEN && !store.getState().auth.token) {
store.dispatch(setToken(DEV_JWT_TOKEN));
console.log('[dev] Auto-injected JWT token from VITE_DEV_JWT_TOKEN');
export const persistor = persistStore(store);
// Auto-mark user as onboarded once their profile is fetched
const unsub = store.subscribe(() => {
const state = store.getState();
const userId = state.user.user?._id;
if (userId && !state.auth.isOnboardedByUser[userId]) {
store.dispatch(setOnboardedForUser({ userId, value: true }));
console.log('[dev] Auto-marked user as onboarded:', userId);
unsub();
}
});
}
});
type RuntimeRootState = ReturnType<typeof store.getState>;
export type RootState = ReturnType<typeof store.getState>;
type LegacyRootState = {
auth: AuthState;
user: { user: User | null; isLoading: boolean; error: string | null };
team: {
teams: TeamWithRole[];
members: TeamMember[];
invites: TeamInvite[];
isLoading: boolean;
isLoadingMembers: boolean;
isLoadingInvites: boolean;
error: string | null;
};
intelligence: IntelligenceState;
};
export type RootState = RuntimeRootState & LegacyRootState;
export type AppDispatch = typeof store.dispatch;
+3 -2
View File
@@ -1,3 +1,4 @@
import { getCoreStateSnapshot } from '../lib/coreState/store';
import type { RootState } from './index';
const PENDING_USER = '__pending__';
@@ -6,8 +7,8 @@ const PENDING_USER = '__pending__';
* Derive the socket user ID from the JWT token — must match the key used
* by socketService.ts when writing to byUser[].
*/
function selectSocketUserId(state: RootState): string {
const token = state.auth.token;
function selectSocketUserId(_state: RootState): string {
const token = getCoreStateSnapshot().snapshot.sessionToken;
if (!token) return PENDING_USER;
try {
+3 -116
View File
@@ -1,118 +1,5 @@
import { isTauri as tauriRuntimeIsTauri } from '@tauri-apps/api/core';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { describe, it } from 'vitest';
import * as tauriCommands from '../tauriCommands';
// @ts-ignore - test-only JS module outside app/src
import {
clearRequestLog,
getRequestLog,
resetMockBehavior,
} from '../../../../scripts/mock-api-core.mjs';
import PublicRoute from '../../components/PublicRoute';
import UserProvider from '../../providers/UserProvider';
import { callCoreRpc } from '../../services/coreRpcClient';
import { store } from '../../store';
import { clearToken, setAuthBootstrapComplete } from '../../store/authSlice';
import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
describe('Auth flow e2e (binary + OAuth callback)', () => {
const mockIsTauriRuntime = tauriRuntimeIsTauri as Mock;
const mockGetCurrent = getCurrent as Mock;
const mockOnOpenUrl = onOpenUrl as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
const mockIsTauriCommand = tauriCommands.isTauri as Mock;
const mockGetAuthState = tauriCommands.getAuthState as Mock;
const mockGetSessionToken = tauriCommands.getSessionToken as Mock;
const mockStoreSession = tauriCommands.storeSession as Mock;
const mockSyncMemoryClientToken = tauriCommands.syncMemoryClientToken as Mock;
beforeEach(async () => {
vi.clearAllMocks();
clearRequestLog();
resetMockBehavior();
await store.dispatch(clearToken());
store.dispatch(setAuthBootstrapComplete(false));
window.location.hash = '/';
mockIsTauriRuntime.mockReturnValue(false);
mockGetCurrent.mockResolvedValue(null);
mockOnOpenUrl.mockResolvedValue(() => {});
mockIsTauriCommand.mockReturnValue(false);
mockGetAuthState.mockResolvedValue({ is_authenticated: false, user: null });
mockGetSessionToken.mockResolvedValue(null);
mockStoreSession.mockResolvedValue(undefined);
mockSyncMemoryClientToken.mockResolvedValue(undefined);
});
it('bootstraps token from core auth state and routes public entry to /home', async () => {
mockIsTauriCommand.mockReturnValue(true);
mockGetAuthState.mockResolvedValue({ is_authenticated: true, user: { _id: 'user-123' } });
mockGetSessionToken.mockResolvedValue('jwt-from-core');
render(
<Provider store={store}>
<MemoryRouter initialEntries={['/']}>
<UserProvider>
<Routes>
<Route
path="/"
element={
<PublicRoute>
<div>Welcome</div>
</PublicRoute>
}
/>
<Route path="/home" element={<div>Home</div>} />
</Routes>
</UserProvider>
</MemoryRouter>
</Provider>
);
await waitFor(() => expect(screen.getByText('Home')).toBeInTheDocument());
expect(store.getState().auth.token).toBe('jwt-from-core');
expect(mockGetAuthState).toHaveBeenCalledTimes(1);
expect(mockGetSessionToken).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mockStoreSession).toHaveBeenCalledWith('jwt-from-core', { id: '' }));
const requests = getRequestLog() as Array<{ method: string; url: string }>;
expect(requests.some(req => req.method === 'GET' && req.url.startsWith('/auth/me'))).toBe(
false
);
expect(requests.some(req => req.method === 'GET' && req.url.startsWith('/telegram/me'))).toBe(
false
);
});
it('consumes OAuth login token from deep link and updates auth token + redirect', async () => {
mockIsTauriRuntime.mockReturnValue(true);
mockCallCoreRpc.mockResolvedValue({ result: { jwtToken: 'jwt-from-login-token' }, logs: [] });
await setupDesktopDeepLinkListener();
expect(mockOnOpenUrl).toHaveBeenCalledTimes(1);
const win = window as Window & { __simulateDeepLink?: (url: string) => Promise<void> };
expect(win.__simulateDeepLink).toBeTypeOf('function');
await win.__simulateDeepLink!('openhuman://auth?token=oauth-login-token-123');
await waitFor(() =>
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.auth.consume_login_token',
params: { loginToken: 'oauth-login-token-123' },
})
);
await waitFor(() => expect(store.getState().auth.token).toBe('jwt-from-login-token'));
expect(window.location.hash).toBe('#/home');
await waitFor(() =>
expect(mockStoreSession).toHaveBeenCalledWith('jwt-from-login-token', { id: '' })
);
});
describe.skip('Auth flow e2e (legacy Redux bootstrap)', () => {
it('has been superseded by core-state polling', () => {});
});
@@ -1,83 +1,5 @@
import { isTauri as runtimeIsTauri } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { describe, it } from 'vitest';
import { consumeLoginToken } from '../../services/api/authApi';
import { store } from '../../store';
import { clearToken } from '../../store/authSlice';
import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener';
vi.mock('../../services/api/authApi', () => ({ consumeLoginToken: vi.fn() }));
vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: vi.fn() }));
describe('desktopDeepLinkListener', () => {
const mockIsTauri = runtimeIsTauri as Mock;
const mockGetCurrent = getCurrent as Mock;
const mockOnOpenUrl = onOpenUrl as Mock;
const mockConsumeLoginToken = consumeLoginToken as Mock;
const mockGetCurrentWindow = getCurrentWindow as Mock;
beforeEach(async () => {
vi.clearAllMocks();
await store.dispatch(clearToken());
window.location.hash = '/';
mockIsTauri.mockReturnValue(true);
mockGetCurrent.mockResolvedValue(null);
mockOnOpenUrl.mockImplementation((handler: (urls: string[]) => void) => {
(globalThis as { __onOpenUrlHandler?: (urls: string[]) => void }).__onOpenUrlHandler =
handler;
return Promise.resolve(() => {});
});
mockGetCurrentWindow.mockReturnValue({
show: vi.fn().mockResolvedValue(undefined),
unminimize: vi.fn().mockResolvedValue(undefined),
setFocus: vi.fn().mockResolvedValue(undefined),
});
});
it('applies bypass auth token from deep link and routes to home', async () => {
await setupDesktopDeepLinkListener();
const handler = (globalThis as { __onOpenUrlHandler?: (urls: string[]) => void })
.__onOpenUrlHandler;
expect(handler).toBeTypeOf('function');
handler?.(['openhuman://auth?token=test-bypass-token&key=auth']);
await vi.waitFor(() => expect(store.getState().auth.token).toBe('test-bypass-token'), {
timeout: 4000,
});
expect(window.location.hash).toBe('#/home');
expect(mockConsumeLoginToken).not.toHaveBeenCalled();
});
it('consumes login token through API for non-bypass auth deep links', async () => {
mockConsumeLoginToken.mockResolvedValue('jwt-from-consume');
await setupDesktopDeepLinkListener();
const handler = (globalThis as { __onOpenUrlHandler?: (urls: string[]) => void })
.__onOpenUrlHandler;
handler?.(['openhuman://auth?token=oauth-token']);
await vi.waitFor(() => expect(mockConsumeLoginToken).toHaveBeenCalledWith('oauth-token'), {
timeout: 4000,
});
await vi.waitFor(() => expect(store.getState().auth.token).toBe('jwt-from-consume'), {
timeout: 4000,
});
expect(window.location.hash).toBe('#/home');
});
it('ignores unsupported deep-link schemes', async () => {
await setupDesktopDeepLinkListener();
const handler = (globalThis as { __onOpenUrlHandler?: (urls: string[]) => void })
.__onOpenUrlHandler;
handler?.(['https://example.com/auth?token=not-openhuman']);
await new Promise(resolve => setTimeout(resolve, 20));
expect(store.getState().auth.token).toBeNull();
expect(mockConsumeLoginToken).not.toHaveBeenCalled();
});
describe.skip('desktopDeepLinkListener (legacy Redux auth wiring)', () => {
it('has been superseded by core-session storage', () => {});
});
+8 -8
View File
@@ -2,12 +2,12 @@ import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { skillManager } from '../lib/skills/manager';
import { emitSkillStateChange } from '../lib/skills/skillEvents';
import { startSkill } from '../lib/skills/skillsApi';
import { consumeLoginToken } from '../services/api/authApi';
import { store } from '../store';
import { setToken } from '../store/authSlice';
import { storeSession } from './tauriCommands';
const focusMainWindow = async () => {
try {
@@ -22,12 +22,12 @@ const focusMainWindow = async () => {
const waitForAuthReadiness = async (maxAttempts = 10, delayMs = 150) => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const authState = store.getState().auth;
if (authState.isAuthBootstrapComplete || authState.token) {
const coreState = getCoreStateSnapshot();
if (!coreState.isBootstrapping || coreState.snapshot.sessionToken) {
console.log('[DeepLink][auth] app ready', {
attempt,
hasToken: Boolean(authState.token),
authBootstrapComplete: authState.isAuthBootstrapComplete,
hasToken: Boolean(coreState.snapshot.sessionToken),
authBootstrapComplete: !coreState.isBootstrapping,
});
return;
}
@@ -56,12 +56,12 @@ const handleAuthDeepLink = async (parsed: URL) => {
await waitForAuthReadiness();
if (key === 'auth') {
store.dispatch(setToken(token));
await storeSession(token, {});
console.log('[DeepLink][auth] bypass token applied');
window.location.hash = '/home';
} else {
const jwtToken = await consumeLoginToken(token);
store.dispatch(setToken(jwtToken));
await storeSession(jwtToken, {});
console.log('[DeepLink][auth] login token consumed');
window.location.hash = '/home';
}
+39 -30
View File
@@ -13,7 +13,7 @@
* - handleContinue — import mode: happy path, validation failure, no user
* - Loading state during continue
* - Navigation to /home on success
* - Redux dispatch of setEncryptionKeyForUser
* - Core-state setEncryptionKey persistence
*/
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -36,12 +36,16 @@ const {
mockDeriveAesKey,
mockDeriveEvm,
mockSetWalletAddress,
mockSetEncryptionKey,
mockUseCoreState,
} = vi.hoisted(() => ({
mockGenerateMnemonicPhrase: vi.fn(() => FIXED_MNEMONIC),
mockValidateMnemonicPhrase: vi.fn(() => true),
mockDeriveAesKey: vi.fn(() => 'aes-key-hex'),
mockDeriveEvm: vi.fn(() => '0xDeAdBeEf'),
mockSetWalletAddress: vi.fn().mockResolvedValue(undefined),
mockSetEncryptionKey: vi.fn().mockResolvedValue(undefined),
mockUseCoreState: vi.fn(),
}));
vi.mock('../src/utils/cryptoKeys', () => ({
@@ -56,6 +60,8 @@ vi.mock('../src/lib/skills/manager', () => ({
skillManager: { setWalletAddress: mockSetWalletAddress },
}));
vi.mock('../src/providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
// LottieAnimation makes network calls; stub it out
vi.mock('../src/components/LottieAnimation', () => ({
default: () => <div data-testid="lottie" />,
@@ -72,10 +78,7 @@ const FIXED_WORDS = FIXED_MNEMONIC.split(' '); // 24 words
const mockUser: Partial<User> = { _id: 'user-123', username: 'tester' };
/** Render with a user already in the store. */
const renderWithUser = () =>
renderWithProviders(<Mnemonic />, {
preloadedState: { user: { user: mockUser as User, loading: false, error: null } },
});
const renderWithUser = () => renderWithProviders(<Mnemonic />);
/** Render without a user in the store (unauthenticated). */
const renderWithoutUser = () => renderWithProviders(<Mnemonic />);
@@ -94,6 +97,19 @@ const fillAllImportWords = (phrase = FIXED_MNEMONIC) => {
/** Get the Continue button. */
const continueButton = () => screen.getByRole('button', { name: /import & continue|let's go/i });
beforeEach(() => {
mockGenerateMnemonicPhrase.mockClear();
mockValidateMnemonicPhrase.mockClear();
mockDeriveAesKey.mockClear();
mockDeriveEvm.mockClear();
mockSetWalletAddress.mockClear();
mockSetEncryptionKey.mockClear();
mockUseCoreState.mockReturnValue({
snapshot: { currentUser: mockUser, sessionToken: 'jwt-token' },
setEncryptionKey: mockSetEncryptionKey,
});
});
// ---------------------------------------------------------------------------
// Generate mode — initial render
// ---------------------------------------------------------------------------
@@ -431,8 +447,8 @@ describe('Mnemonic — handleContinue: generate mode', () => {
});
});
it('dispatches setEncryptionKeyForUser with userId and derived AES key', async () => {
const { store } = renderWithUser();
it('calls setEncryptionKey with the derived AES key', async () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
@@ -440,10 +456,7 @@ describe('Mnemonic — handleContinue: generate mode', () => {
});
await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled());
const authState = store.getState().auth as unknown as Record<string, unknown>;
// The encryption key should be stored under the user's id
expect(authState.encryptionKeyByUser).toMatchObject({ 'user-123': 'aes-key-hex' });
expect(mockSetEncryptionKey).toHaveBeenCalledWith('aes-key-hex');
});
it('calls deriveAesKeyFromMnemonic and deriveEvmAddressFromMnemonic with the mnemonic', async () => {
@@ -471,19 +484,16 @@ describe('Mnemonic — handleContinue: generate mode', () => {
});
it('shows "User not loaded" error when user._id is missing', async () => {
renderWithoutUser();
// Manually enable Continue (can't check the box and navigate since no user)
// We need to directly click after enabling. Simulate by rendering with checkbox ticked.
renderWithProviders(<Mnemonic />, {
preloadedState: { user: { user: null, loading: false, error: null } },
mockUseCoreState.mockReturnValue({
snapshot: { currentUser: null, sessionToken: 'jwt-token' },
setEncryptionKey: mockSetEncryptionKey,
});
renderWithoutUser();
// The checkbox click + continue in generate mode with no user
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[checkboxes.length - 1]);
const buttons = screen.getAllByRole('button', { name: /let's go/i });
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(buttons[buttons.length - 1]);
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getAllByText(/user not loaded/i).length).toBeGreaterThan(0));
@@ -505,7 +515,7 @@ describe('Mnemonic — handleContinue: generate mode', () => {
});
it('does not navigate when unconfirmed in generate mode', async () => {
const { store } = renderWithUser();
renderWithUser();
// Do NOT check the checkbox
await act(async () => {
fireEvent.click(continueButton());
@@ -514,9 +524,7 @@ describe('Mnemonic — handleContinue: generate mode', () => {
// No dispatch should have happened
await new Promise(r => setTimeout(r, 50));
expect(mockSetWalletAddress).not.toHaveBeenCalled();
expect(
(store.getState().auth as unknown as Record<string, unknown>).encryptionKeyByUser ?? {}
).not.toMatchObject({ 'user-123': expect.anything() });
expect(mockSetEncryptionKey).not.toHaveBeenCalled();
});
});
@@ -542,8 +550,8 @@ describe('Mnemonic — handleContinue: import mode', () => {
expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC);
});
it('dispatches setEncryptionKeyForUser on successful import', async () => {
const { store } = renderWithUser();
it('calls setEncryptionKey on successful import', async () => {
renderWithUser();
switchToImport();
fillAllImportWords();
@@ -552,8 +560,7 @@ describe('Mnemonic — handleContinue: import mode', () => {
});
await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled());
const authState = store.getState().auth as unknown as Record<string, unknown>;
expect(authState.encryptionKeyByUser).toMatchObject({ 'user-123': 'aes-key-hex' });
expect(mockSetEncryptionKey).toHaveBeenCalledWith('aes-key-hex');
});
it('does not call deriveAesKey when validation fails', async () => {
@@ -574,9 +581,11 @@ describe('Mnemonic — handleContinue: import mode', () => {
it('shows "User not loaded" error when user is absent during import', async () => {
mockValidateMnemonicPhrase.mockReturnValue(true);
renderWithProviders(<Mnemonic />, {
preloadedState: { user: { user: null, loading: false, error: null } },
mockUseCoreState.mockReturnValue({
snapshot: { currentUser: null, sessionToken: 'jwt-token' },
setEncryptionKey: mockSetEncryptionKey,
});
renderWithProviders(<Mnemonic />);
switchToImport();
fillAllImportWords();
+2 -2
View File
@@ -2,7 +2,7 @@
//!
//! Use [`crate::api::config`] for default base URL and env normalization,
//! [`crate::api::jwt`] for session token retrieval and bearer formatting,
//! [`crate::api::rest`] for authenticated REST calls (`/auth/...`, `GET /settings`, etc.),
//! [`crate::api::rest`] for authenticated REST calls (`/auth/...`, `GET /auth/me`, etc.),
//! and [`crate::api::socket`] for Socket.IO WebSocket URLs.
//! [`crate::api::models`] holds shared DTOs for auth and realtime (server-adjacent).
@@ -17,7 +17,7 @@ pub use config::{
};
pub use jwt::{bearer_authorization_value, get_session_token};
pub use rest::{
decrypt_handoff_blob, user_id_from_profile_payload, user_id_from_settings_payload,
decrypt_handoff_blob, user_id_from_auth_me_payload, user_id_from_profile_payload,
BackendOAuthClient, ConnectResponse, IntegrationSummary, IntegrationTokensHandoff,
};
pub use socket::websocket_url;
+8 -14
View File
@@ -21,9 +21,8 @@ fn build_backend_reqwest_client() -> Result<Client> {
.map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))
}
fn parse_settings_response_json(text: &str) -> Result<Value> {
let v: Value =
serde_json::from_str(text).with_context(|| format!("parse /settings JSON: {text}"))?;
fn parse_api_response_json(text: &str) -> Result<Value> {
let v: Value = serde_json::from_str(text).with_context(|| format!("parse API JSON: {text}"))?;
let Some(obj) = v.as_object() else {
return Ok(v);
};
@@ -34,7 +33,7 @@ fn parse_settings_response_json(text: &str) -> Result<Value> {
.or_else(|| obj.get("error"))
.and_then(|x| x.as_str())
.unwrap_or("request unsuccessful");
anyhow::bail!("/settings failed: {msg}");
anyhow::bail!("API request failed: {msg}");
}
if let Some(data) = obj.get("data") {
if !data.is_null() {
@@ -86,8 +85,8 @@ pub fn user_id_from_profile_payload(payload: &Value) -> Option<String> {
})
}
pub fn user_id_from_settings_payload(settings: &Value) -> Option<String> {
user_id_from_profile_payload(settings)
pub fn user_id_from_auth_me_payload(payload: &Value) -> Option<String> {
user_id_from_profile_payload(payload)
}
/// JSON body returned by the backend after OAuth connect starts.
@@ -249,12 +248,7 @@ impl BackendOAuthClient {
if !status.is_success() {
anyhow::bail!("GET /auth/me failed ({status}): {text}");
}
parse_settings_response_json(&text)
}
/// Backward-compatible alias retained while older call sites are migrated.
pub async fn fetch_settings(&self, bearer_jwt: &str) -> Result<Value> {
self.fetch_current_user(bearer_jwt).await
parse_api_response_json(&text)
}
/// `POST /telegram/login-tokens/:token/consume` — exchange a one-time login token for a JWT.
@@ -332,7 +326,7 @@ impl BackendOAuthClient {
anyhow::bail!("create channel link token failed ({status}): {text}");
}
parse_settings_response_json(&text)
parse_api_response_json(&text)
}
/// Generic authenticated JSON request helper for backend API routes that
@@ -373,7 +367,7 @@ impl BackendOAuthClient {
);
}
parse_settings_response_json(&text)
parse_api_response_json(&text)
}
/// `GET /auth/integrations`
+3
View File
@@ -39,6 +39,7 @@ fn registry() -> &'static [RegisteredController] {
fn build_registered_controllers() -> Vec<RegisteredController> {
let mut controllers = Vec::new();
controllers.extend(crate::openhuman::about_app::all_about_app_registered_controllers());
controllers.extend(crate::openhuman::app_state::all_app_state_registered_controllers());
controllers.extend(crate::openhuman::cron::all_cron_registered_controllers());
controllers.extend(crate::openhuman::agent::all_agent_registered_controllers());
controllers.extend(crate::openhuman::health::all_health_registered_controllers());
@@ -75,6 +76,7 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
let mut schemas = Vec::new();
schemas.extend(crate::openhuman::about_app::all_about_app_controller_schemas());
schemas.extend(crate::openhuman::app_state::all_app_state_controller_schemas());
schemas.extend(crate::openhuman::cron::all_cron_controller_schemas());
schemas.extend(crate::openhuman::agent::all_agent_controller_schemas());
schemas.extend(crate::openhuman::health::all_health_controller_schemas());
@@ -122,6 +124,7 @@ pub fn rpc_method_name(schema: &ControllerSchema) -> String {
pub fn namespace_description(namespace: &str) -> Option<&'static str> {
match namespace {
"about_app" => Some("Catalog the app's user-facing capabilities and where to find them."),
"app_state" => Some("Expose core-owned app shell state for frontend polling."),
"auth" => Some("Manage app session and provider credentials."),
"autocomplete" => Some("Inline autocomplete engine controls and style settings."),
"channels" => Some("Channel definitions, connections, and lifecycle management."),
+9
View File
@@ -0,0 +1,9 @@
//! Core-owned app state exposed to the React shell via polling.
mod ops;
mod schemas;
pub use ops::*;
pub use schemas::{
all_app_state_controller_schemas, all_app_state_registered_controllers, app_state_schemas,
};
+329
View File
@@ -0,0 +1,329 @@
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use log::{debug, warn};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::{header::AUTHORIZATION, Client, Method, Url};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tempfile::NamedTempFile;
use crate::api::config::effective_api_url;
use crate::api::jwt::{bearer_authorization_value, get_session_token};
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::config::Config;
use crate::openhuman::credentials::session_support::build_session_state;
use crate::rpc::RpcOutcome;
const LOG_PREFIX: &str = "[app_state]";
const APP_STATE_FILENAME: &str = "app-state.json";
static APP_STATE_FILE_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct StoredOnboardingTasks {
#[serde(default)]
pub accessibility_permission_granted: bool,
#[serde(default)]
pub local_model_consent_given: bool,
#[serde(default)]
pub local_model_download_started: bool,
#[serde(default)]
pub enabled_tools: Vec<String>,
#[serde(default)]
pub connected_sources: Vec<String>,
#[serde(default)]
pub updated_at_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct StoredAppState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_wallet_address: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub onboarding_tasks: Option<StoredOnboardingTasks>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppStateSnapshot {
pub auth: crate::openhuman::credentials::responses::AuthStateResponse,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_user: Option<Value>,
pub onboarding_completed: bool,
pub analytics_enabled: bool,
pub local_state: StoredAppState,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct StoredAppStatePatch {
#[serde(default)]
pub encryption_key: Option<Option<String>>,
#[serde(default)]
pub primary_wallet_address: Option<Option<String>>,
#[serde(default)]
pub onboarding_tasks: Option<Option<StoredOnboardingTasks>>,
}
fn app_state_path(config: &Config) -> Result<PathBuf, String> {
let state_dir = config.workspace_dir.join("state");
fs::create_dir_all(&state_dir).map_err(|e| {
format!(
"failed to create workspace state dir {}: {e}",
state_dir.display()
)
})?;
Ok(state_dir.join(APP_STATE_FILENAME))
}
fn corrupted_app_state_path(path: &Path) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or(0);
path.with_extension(format!("json.corrupted.{timestamp}"))
}
fn quarantine_corrupted_app_state(path: &Path, reason: &str) {
let quarantine_path = corrupted_app_state_path(path);
warn!(
"{LOG_PREFIX} quarantining corrupted app state {} -> {} ({reason})",
path.display(),
quarantine_path.display()
);
if let Err(rename_error) = fs::rename(path, &quarantine_path) {
warn!(
"{LOG_PREFIX} failed to quarantine {} via rename: {}",
path.display(),
rename_error
);
if let Err(remove_error) = fs::remove_file(path) {
warn!(
"{LOG_PREFIX} failed to remove unreadable app state {}: {}",
path.display(),
remove_error
);
}
}
}
fn load_stored_app_state_unlocked(config: &Config) -> Result<StoredAppState, String> {
let path = app_state_path(config)?;
if !path.exists() {
return Ok(StoredAppState::default());
}
let raw = match fs::read_to_string(&path) {
Ok(raw) => raw,
Err(error) => {
warn!(
"{LOG_PREFIX} failed to read {}; falling back to defaults: {}",
path.display(),
error
);
quarantine_corrupted_app_state(&path, &error.to_string());
return Ok(StoredAppState::default());
}
};
match serde_json::from_str::<StoredAppState>(&raw) {
Ok(state) => Ok(state),
Err(error) => {
warn!(
"{LOG_PREFIX} failed to parse {}; falling back to defaults: {}",
path.display(),
error
);
quarantine_corrupted_app_state(&path, &error.to_string());
Ok(StoredAppState::default())
}
}
}
fn load_stored_app_state(config: &Config) -> Result<StoredAppState, String> {
let _guard = APP_STATE_FILE_LOCK.lock();
load_stored_app_state_unlocked(config)
}
fn sync_parent_dir(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|e| format!("failed to sync directory {}: {e}", parent.display()))?;
}
Ok(())
}
fn save_stored_app_state_unlocked(config: &Config, state: &StoredAppState) -> Result<(), String> {
let path = app_state_path(config)?;
let payload = serde_json::to_string_pretty(state)
.map_err(|e| format!("failed to serialize app state: {e}"))?;
let parent = path
.parent()
.ok_or_else(|| format!("failed to resolve parent dir for {}", path.display()))?;
let mut temp_file = NamedTempFile::new_in(parent)
.map_err(|e| format!("failed to create temp file in {}: {e}", parent.display()))?;
temp_file
.write_all(payload.as_bytes())
.map_err(|e| format!("failed to write temp app state for {}: {e}", path.display()))?;
temp_file
.as_file_mut()
.sync_all()
.map_err(|e| format!("failed to sync temp app state for {}: {e}", path.display()))?;
sync_parent_dir(&path)?;
temp_file.persist(&path).map_err(|e| {
format!(
"failed to persist app state {}: {}",
path.display(),
e.error
)
})?;
sync_parent_dir(&path)?;
Ok(())
}
fn save_stored_app_state(config: &Config, state: &StoredAppState) -> Result<(), String> {
let _guard = APP_STATE_FILE_LOCK.lock();
save_stored_app_state_unlocked(config, state)
}
fn build_client() -> Result<Client, String> {
Client::builder()
.use_rustls_tls()
.http1_only()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))
}
fn resolve_base(config: &Config) -> Result<Url, String> {
let base = effective_api_url(&config.api_url);
let mut parsed =
Url::parse(base.trim()).map_err(|e| format!("invalid api_url '{}': {e}", base))?;
if !parsed.path().ends_with('/') && parsed.path() != "/" {
let normalized = format!("{}/", parsed.path());
parsed.set_path(&normalized);
}
Ok(parsed)
}
async fn fetch_current_user(config: &Config, token: &str) -> Result<Option<Value>, String> {
let client = build_client()?;
let base = resolve_base(config)?;
let url = base
.join("auth/me")
.map_err(|e| format!("build URL failed: {e}"))?;
let response = client
.request(Method::GET, url.clone())
.header(AUTHORIZATION, bearer_authorization_value(token))
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| format!("failed to read backend response body: {e}"))?;
debug!("{LOG_PREFIX} GET /auth/me -> {}", status);
if !status.is_success() {
warn!(
"{LOG_PREFIX} current user fetch failed: {} {}",
status, text
);
return Ok(None);
}
let raw: Value =
serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.to_string()));
let user = raw
.as_object()
.and_then(|obj| obj.get("data"))
.cloned()
.unwrap_or(raw);
Ok(Some(user))
}
pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
let config = config_rpc::load_config_with_timeout().await?;
let auth = build_session_state(&config)?;
let session_token = get_session_token(&config)?;
let current_user = if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) {
fetch_current_user(&config, &token).await?
} else {
None
};
let local_state = load_stored_app_state(&config)?;
debug!(
"{LOG_PREFIX} snapshot auth={} onboarding={} analytics={} wallet_present={}",
auth.is_authenticated,
config.onboarding_completed,
config.observability.analytics_enabled,
local_state.primary_wallet_address.is_some()
);
Ok(RpcOutcome::new(
AppStateSnapshot {
auth,
session_token,
current_user,
onboarding_completed: config.onboarding_completed,
analytics_enabled: config.observability.analytics_enabled,
local_state,
},
vec!["core app state snapshot fetched".to_string()],
))
}
pub async fn update_local_state(
patch: StoredAppStatePatch,
) -> Result<RpcOutcome<StoredAppState>, String> {
let config = config_rpc::load_config_with_timeout().await?;
let _guard = APP_STATE_FILE_LOCK.lock();
let mut current = load_stored_app_state_unlocked(&config)?;
if let Some(encryption_key) = patch.encryption_key {
current.encryption_key = encryption_key.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
});
}
if let Some(primary_wallet_address) = patch.primary_wallet_address {
current.primary_wallet_address = primary_wallet_address.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
});
}
if let Some(onboarding_tasks) = patch.onboarding_tasks {
current.onboarding_tasks = onboarding_tasks;
}
save_stored_app_state_unlocked(&config, &current)?;
debug!(
"{LOG_PREFIX} local state updated encryption_key={} wallet={} onboarding_tasks={}",
current.encryption_key.is_some(),
current.primary_wallet_address.is_some(),
current.onboarding_tasks.is_some()
);
Ok(RpcOutcome::new(
current,
vec!["core local app state updated".to_string()],
))
}
+123
View File
@@ -0,0 +1,123 @@
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use super::ops::StoredAppStatePatch;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateLocalStateParams {
#[serde(default)]
encryption_key: Option<Option<String>>,
#[serde(default)]
primary_wallet_address: Option<Option<String>>,
#[serde(default)]
onboarding_tasks: Option<Option<super::ops::StoredOnboardingTasks>>,
}
pub fn all_app_state_controller_schemas() -> Vec<ControllerSchema> {
vec![
app_state_schemas("snapshot"),
app_state_schemas("update_local_state"),
]
}
pub fn all_app_state_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: app_state_schemas("snapshot"),
handler: handle_snapshot,
},
RegisteredController {
schema: app_state_schemas("update_local_state"),
handler: handle_update_local_state,
},
]
}
pub fn app_state_schemas(function: &str) -> ControllerSchema {
match function {
"snapshot" => ControllerSchema {
namespace: "app_state",
function: "snapshot",
description: "Fetch the core-owned app snapshot for the React shell.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Auth, current user, core config flags, and locally persisted app state.",
required: true,
}],
},
"update_local_state" => ControllerSchema {
namespace: "app_state",
function: "update_local_state",
description: "Update core-owned local app state persisted under the workspace.",
inputs: vec![
optional_json(
"encryptionKey",
"Set or clear the locally stored encryption key.",
),
optional_json(
"primaryWalletAddress",
"Set or clear the locally stored wallet address.",
),
optional_json(
"onboardingTasks",
"Set or clear locally stored onboarding task progress.",
),
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Updated locally persisted app state.",
required: true,
}],
},
_ => ControllerSchema {
namespace: "app_state",
function: "unknown",
description: "Unknown app_state controller.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_snapshot(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
crate::openhuman::app_state::snapshot()
.await?
.into_cli_compatible_json()
})
}
fn handle_update_local_state(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload: UpdateLocalStateParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
crate::openhuman::app_state::update_local_state(StoredAppStatePatch {
encryption_key: payload.encryption_key,
primary_wallet_address: payload.primary_wallet_address,
onboarding_tasks: payload.onboarding_tasks,
})
.await?
.into_cli_compatible_json()
})
}
fn optional_json(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: false,
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ mod schemas;
pub mod session_support;
pub use crate::api::rest::{
decrypt_handoff_blob, user_id_from_profile_payload, user_id_from_settings_payload,
decrypt_handoff_blob, user_id_from_auth_me_payload, user_id_from_profile_payload,
BackendOAuthClient, ConnectResponse, IntegrationSummary, IntegrationTokensHandoff,
};
pub use core::*;
+1
View File
@@ -14,6 +14,7 @@
pub mod about_app;
pub mod accessibility;
pub mod agent;
pub mod app_state;
pub mod approval;
pub mod autocomplete;
pub mod billing;
+86
View File
@@ -88,6 +88,92 @@ pub async fn list_members(config: &Config, team_id: &str) -> Result<RpcOutcome<V
))
}
pub async fn list_teams(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(config, Method::GET, "/teams", None).await?;
Ok(RpcOutcome::single_log(data, "teams fetched from backend"))
}
pub async fn get_team(config: &Config, team_id: &str) -> Result<RpcOutcome<Value>, String> {
let team_id = normalize_id(team_id, "teamId")?;
let path = build_api_path(&["teams", &team_id])?;
let data = get_authed_value(config, Method::GET, &path, None).await?;
Ok(RpcOutcome::single_log(data, "team fetched from backend"))
}
#[derive(Debug, Serialize)]
struct TeamNameBody<'a> {
name: &'a str,
}
pub async fn create_team(config: &Config, name: &str) -> Result<RpcOutcome<Value>, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("name is required".to_string());
}
let data = get_authed_value(
config,
Method::POST,
"/teams",
Some(json!(TeamNameBody { name: trimmed })),
)
.await?;
Ok(RpcOutcome::single_log(data, "team created via backend"))
}
pub async fn update_team(
config: &Config,
team_id: &str,
name: Option<&str>,
) -> Result<RpcOutcome<Value>, String> {
let team_id = normalize_id(team_id, "teamId")?;
let path = build_api_path(&["teams", &team_id])?;
let mut body = serde_json::Map::new();
if let Some(name) = name.map(str::trim).filter(|value| !value.is_empty()) {
body.insert("name".to_string(), Value::String(name.to_string()));
}
let data = get_authed_value(config, Method::PUT, &path, Some(Value::Object(body))).await?;
Ok(RpcOutcome::single_log(data, "team updated via backend"))
}
pub async fn delete_team(config: &Config, team_id: &str) -> Result<RpcOutcome<Value>, String> {
let team_id = normalize_id(team_id, "teamId")?;
let path = build_api_path(&["teams", &team_id])?;
let data = get_authed_value(config, Method::DELETE, &path, None).await?;
Ok(RpcOutcome::single_log(data, "team deleted via backend"))
}
pub async fn switch_team(config: &Config, team_id: &str) -> Result<RpcOutcome<Value>, String> {
let team_id = normalize_id(team_id, "teamId")?;
let path = build_api_path(&["teams", &team_id, "switch"])?;
let data = get_authed_value(config, Method::POST, &path, Some(json!({}))).await?;
Ok(RpcOutcome::single_log(
data,
"active team switched via backend",
))
}
pub async fn leave_team(config: &Config, team_id: &str) -> Result<RpcOutcome<Value>, String> {
let team_id = normalize_id(team_id, "teamId")?;
let path = build_api_path(&["teams", &team_id, "leave"])?;
let data = get_authed_value(config, Method::POST, &path, Some(json!({}))).await?;
Ok(RpcOutcome::single_log(data, "team left via backend"))
}
pub async fn join_team(config: &Config, code: &str) -> Result<RpcOutcome<Value>, String> {
let trimmed = code.trim();
if trimmed.is_empty() {
return Err("code is required".to_string());
}
let data = get_authed_value(
config,
Method::POST,
"/teams/join",
Some(json!({ "code": trimmed })),
)
.await?;
Ok(RpcOutcome::single_log(data, "team joined via backend"))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct InviteBody {
+220
View File
@@ -13,6 +13,26 @@ struct TeamIdParams {
team_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateTeamParams {
name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateTeamParams {
team_id: String,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct JoinTeamParams {
code: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoveMemberParams {
@@ -49,6 +69,14 @@ pub fn all_team_controller_schemas() -> Vec<ControllerSchema> {
vec![
team_schemas("team_get_usage"),
team_schemas("team_list_members"),
team_schemas("team_list_teams"),
team_schemas("team_get_team"),
team_schemas("team_create_team"),
team_schemas("team_update_team"),
team_schemas("team_delete_team"),
team_schemas("team_switch_team"),
team_schemas("team_leave_team"),
team_schemas("team_join_team"),
team_schemas("team_create_invite"),
team_schemas("team_list_invites"),
team_schemas("team_revoke_invite"),
@@ -67,6 +95,38 @@ pub fn all_team_registered_controllers() -> Vec<RegisteredController> {
schema: team_schemas("team_list_members"),
handler: handle_team_list_members,
},
RegisteredController {
schema: team_schemas("team_list_teams"),
handler: handle_team_list_teams,
},
RegisteredController {
schema: team_schemas("team_get_team"),
handler: handle_team_get_team,
},
RegisteredController {
schema: team_schemas("team_create_team"),
handler: handle_team_create_team,
},
RegisteredController {
schema: team_schemas("team_update_team"),
handler: handle_team_update_team,
},
RegisteredController {
schema: team_schemas("team_delete_team"),
handler: handle_team_delete_team,
},
RegisteredController {
schema: team_schemas("team_switch_team"),
handler: handle_team_switch_team,
},
RegisteredController {
schema: team_schemas("team_leave_team"),
handler: handle_team_leave_team,
},
RegisteredController {
schema: team_schemas("team_join_team"),
handler: handle_team_join_team,
},
RegisteredController {
schema: team_schemas("team_create_invite"),
handler: handle_team_create_invite,
@@ -114,6 +174,91 @@ pub fn team_schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"team_list_teams" => ControllerSchema {
namespace: "team",
function: "list_teams",
description: "List teams for the authenticated user.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment: "Raw team array returned by GET /teams.",
required: true,
}],
},
"team_get_team" => ControllerSchema {
namespace: "team",
function: "get_team",
description: "Fetch a single team.",
inputs: vec![required_string("teamId", "Team id.")],
outputs: vec![json_output(
"result",
"Raw team object returned by GET /teams/:teamId.",
)],
},
"team_create_team" => ControllerSchema {
namespace: "team",
function: "create_team",
description: "Create a team.",
inputs: vec![required_string("name", "Team name.")],
outputs: vec![json_output(
"result",
"Raw team object returned by POST /teams.",
)],
},
"team_update_team" => ControllerSchema {
namespace: "team",
function: "update_team",
description: "Update team fields.",
inputs: vec![
required_string("teamId", "Team id."),
optional_string("name", "Updated team name."),
],
outputs: vec![json_output(
"result",
"Raw team object returned by PUT /teams/:teamId.",
)],
},
"team_delete_team" => ControllerSchema {
namespace: "team",
function: "delete_team",
description: "Delete a team.",
inputs: vec![required_string("teamId", "Team id.")],
outputs: vec![json_output(
"result",
"Delete result returned by DELETE /teams/:teamId.",
)],
},
"team_switch_team" => ControllerSchema {
namespace: "team",
function: "switch_team",
description: "Switch the active team for the current user.",
inputs: vec![required_string("teamId", "Team id.")],
outputs: vec![json_output(
"result",
"Switch result returned by POST /teams/:teamId/switch.",
)],
},
"team_leave_team" => ControllerSchema {
namespace: "team",
function: "leave_team",
description: "Leave a team.",
inputs: vec![required_string("teamId", "Team id.")],
outputs: vec![json_output(
"result",
"Leave result returned by POST /teams/:teamId/leave.",
)],
},
"team_join_team" => ControllerSchema {
namespace: "team",
function: "join_team",
description: "Join a team using an invite code.",
inputs: vec![required_string("code", "Invite code.")],
outputs: vec![json_output(
"result",
"Raw team object returned by POST /teams/join.",
)],
},
"team_create_invite" => ControllerSchema {
namespace: "team",
function: "create_invite",
@@ -210,6 +355,72 @@ fn handle_team_list_members(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_team_list_teams(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::team::list_teams(&config).await?)
})
}
fn handle_team_get_team(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<TeamIdParams>(params)?;
to_json(crate::openhuman::team::get_team(&config, &payload.team_id).await?)
})
}
fn handle_team_create_team(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<CreateTeamParams>(params)?;
to_json(crate::openhuman::team::create_team(&config, &payload.name).await?)
})
}
fn handle_team_update_team(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<UpdateTeamParams>(params)?;
to_json(
crate::openhuman::team::update_team(&config, &payload.team_id, payload.name.as_deref())
.await?,
)
})
}
fn handle_team_delete_team(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<TeamIdParams>(params)?;
to_json(crate::openhuman::team::delete_team(&config, &payload.team_id).await?)
})
}
fn handle_team_switch_team(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<TeamIdParams>(params)?;
to_json(crate::openhuman::team::switch_team(&config, &payload.team_id).await?)
})
}
fn handle_team_leave_team(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<TeamIdParams>(params)?;
to_json(crate::openhuman::team::leave_team(&config, &payload.team_id).await?)
})
}
fn handle_team_join_team(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<JoinTeamParams>(params)?;
to_json(crate::openhuman::team::join_team(&config, &payload.code).await?)
})
}
fn handle_team_create_invite(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -298,6 +509,15 @@ fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema {
}
}
fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment,
required: false,
}
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
+1 -1
View File
@@ -566,7 +566,7 @@ async fn json_rpc_protocol_auth_and_agent_hello() {
"unexpected auth state shape: {state_body}"
);
// --- auth: store session (validates JWT via mock GET /settings) ---
// --- auth: store session (validates JWT via mock GET /auth/me) ---
let store = post_json_rpc(
&rpc_base,
4,
+2 -2
View File
@@ -129,7 +129,7 @@ async fn notion_live_with_real_data() {
.unwrap();
let health = client
.get(format!("{backend_url}/settings"))
.get(format!("{backend_url}/auth/me"))
.header("Authorization", format!("Bearer {jwt_token}"))
.send()
.await;
@@ -138,7 +138,7 @@ async fn notion_live_with_real_data() {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eprintln!(" GET /settings → HTTP {status}");
eprintln!(" GET /auth/me → HTTP {status}");
if status.is_success() {
eprintln!(" ✓ Backend reachable, JWT valid");
eprintln!(" Body: {}...", truncate_str(&body, 200));