mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: () => () => {},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, ¤t)?;
|
||||
|
||||
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()],
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user