diff --git a/Cargo.lock b/Cargo.lock index 96bd1e7513..304e142b58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4307,7 +4307,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.52.15" +version = "0.52.16" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 4411635306..aa1c9d649b 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.52.14" +version = "0.52.16" dependencies = [ "env_logger", "log", diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 4cc8df29dc..f261275d4c 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -9,23 +9,18 @@ import { useSettingsNavigation } from './hooks/useSettingsNavigation'; const SettingsHome = () => { const { navigateToSettings } = useSettingsNavigation(); - const { clearSession, setOnboardingCompletedFlag } = useCoreState(); + const { clearSession } = useCoreState(); const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const handleLogout = async () => { - try { - await setOnboardingCompletedFlag(false); - } catch (err) { - console.warn('[Settings] Failed to clear onboarding_completed in config:', err); - } try { await clearSession(); } catch (err) { console.warn('[Settings] Rust logout failed:', err); + setError('Failed to log out. Please try again.'); } - window.location.hash = '/'; }; const clearAllAppData = async () => { @@ -45,9 +40,6 @@ const SettingsHome = () => { await persistor.purge(); window.localStorage.clear(); window.sessionStorage.clear(); - - // Complete reset - redirect to login for fresh start - window.location.hash = '/'; }; const handleLogoutAndClearData = async () => { diff --git a/app/src/pages/Welcome.tsx b/app/src/pages/Welcome.tsx index 5e1be5e349..7ab9e29adf 100644 --- a/app/src/pages/Welcome.tsx +++ b/app/src/pages/Welcome.tsx @@ -1,8 +1,11 @@ import OAuthProviderButton from '../components/oauth/OAuthProviderButton'; import { oauthProviderConfigs } from '../components/oauth/providerConfigs'; import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas'; +import { useDeepLinkAuthState } from '../store/deepLinkAuthState'; const Welcome = () => { + const { isProcessing, errorMessage } = useDeepLinkAuthState(); + return (
@@ -25,18 +28,31 @@ const Welcome = () => { AI Super Intelligence. Private, Simple and extremely powerful.

- {/* OAuth buttons — horizontal row */} -
- {oauthProviderConfigs - .filter(p => ['google', 'github', 'twitter'].includes(p.id)) - .map(provider => ( - - ))} -
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {isProcessing ? ( +
+
+

Signing you in...

+
+ ) : ( + /* OAuth buttons — horizontal row */ +
+ {oauthProviderConfigs + .filter(p => ['google', 'github', 'twitter'].includes(p.id)) + .map(provider => ( + + ))} +
+ )} {/* Email login — disabled until backend auth flow is implemented
diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 38c58221d8..1220d1da99 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -97,11 +97,22 @@ function normalizeSnapshot( }; } +function toSignedOutSnapshot(snapshot: CoreAppSnapshot): CoreAppSnapshot { + return { + ...snapshot, + auth: { isAuthenticated: false, userId: null, user: null, profileId: null }, + sessionToken: null, + currentUser: null, + onboardingCompleted: false, + }; +} + export default function CoreStateProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(() => getCoreStateSnapshot()); const snapshotRequestIdRef = useRef(0); const teamsRequestIdRef = useRef(0); const memoryTokenRef = useRef(state.snapshot.sessionToken); + const logoutGuardUntilRef = useRef(0); const bootstrapFailCountRef = useRef(0); const refreshInFlightRef = useRef | null>(null); @@ -116,22 +127,31 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const refreshCore = useCallback(async () => { const requestId = ++snapshotRequestIdRef.current; const snapshot = normalizeSnapshot(await fetchCoreAppSnapshot()); + if (!snapshot.sessionToken) { + logoutGuardUntilRef.current = 0; + } commitState(previous => { if (requestId !== snapshotRequestIdRef.current) { return previous; } + const shouldIgnoreTokenDuringLogout = + Date.now() < logoutGuardUntilRef.current && + !previous.snapshot.sessionToken && + Boolean(snapshot.sessionToken); + const nextSnapshot = shouldIgnoreTokenDuringLogout ? toSignedOutSnapshot(snapshot) : snapshot; + const previousIdentity = snapshotIdentity(previous.snapshot); - const nextIdentity = snapshotIdentity(snapshot); + const nextIdentity = snapshotIdentity(nextSnapshot); const shouldClearScopedCaches = previousIdentity !== nextIdentity || - (previous.snapshot.auth.isAuthenticated && !snapshot.auth.isAuthenticated); + (previous.snapshot.auth.isAuthenticated && !nextSnapshot.auth.isAuthenticated); return { ...previous, isBootstrapping: false, isReady: true, - snapshot, + snapshot: nextSnapshot, teams: shouldClearScopedCaches ? [] : previous.teams, teamMembersById: shouldClearScopedCaches ? {} : previous.teamMembersById, teamInvitesById: shouldClearScopedCaches ? {} : previous.teamInvitesById, @@ -269,6 +289,46 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }; }, [commitState, refresh, refreshTeams]); + useEffect(() => { + const onSessionTokenUpdated = (event: Event) => { + const customEvent = event as CustomEvent<{ sessionToken?: string | null }>; + const token = customEvent.detail?.sessionToken; + if (!token) { + return; + } + + snapshotRequestIdRef.current += 1; + logoutGuardUntilRef.current = 0; + + memoryTokenRef.current = token; + commitState(previous => ({ + ...previous, + isBootstrapping: false, + isReady: true, + snapshot: { + ...previous.snapshot, + auth: { ...previous.snapshot.auth, isAuthenticated: true }, + sessionToken: token, + }, + })); + + void refresh().catch(err => { + log('refresh failed after deep-link session update: %O', sanitizeError(err)); + }); + }; + + window.addEventListener( + 'core-state:session-token-updated', + onSessionTokenUpdated as EventListener + ); + return () => { + window.removeEventListener( + 'core-state:session-token-updated', + onSessionTokenUpdated as EventListener + ); + }; + }, [commitState, refresh]); + const setAnalyticsEnabled = useCallback( async (enabled: boolean) => { await openhumanUpdateAnalyticsSettings({ enabled }); @@ -312,6 +372,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const storeSessionToken = useCallback( async (token: string, user?: object) => { + logoutGuardUntilRef.current = 0; await storeSession(token, user ?? {}); try { await syncMemoryClientToken(token); @@ -328,31 +389,17 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) ); const clearSession = useCallback(async () => { - // Bump the snapshot request counter before doing anything else so that - // any snapshot poll already in flight when the user clicked logout is - // invalidated on return — otherwise its stale "authenticated" result - // would clobber our cleared state a few hundred ms after logout. + logoutGuardUntilRef.current = Date.now() + 5_000; snapshotRequestIdRef.current += 1; - await tauriLogout(); - // Optimistic local clear for instant UI response. commitState(previous => ({ ...previous, teams: [], teamMembersById: {}, teamInvitesById: {}, - snapshot: { - ...previous.snapshot, - auth: { isAuthenticated: false, userId: null, user: null, profileId: null }, - sessionToken: null, - currentUser: null, - onboardingCompleted: false, - }, + snapshot: toSignedOutSnapshot(previous.snapshot), })); memoryTokenRef.current = null; - // Re-pull the authoritative snapshot from the core so the frontend - // cache matches whatever the core now reports. This mirrors the pattern - // used by storeSessionToken and ensures any downstream consumer reading - // from the snapshot sees the post-logout state immediately. + await tauriLogout(); await refresh().catch(err => { log('refresh failed after clearSession: %O', sanitizeError(err)); }); diff --git a/app/src/store/deepLinkAuthState.ts b/app/src/store/deepLinkAuthState.ts new file mode 100644 index 0000000000..cab3e8cb11 --- /dev/null +++ b/app/src/store/deepLinkAuthState.ts @@ -0,0 +1,46 @@ +import { useSyncExternalStore } from 'react'; + +export interface DeepLinkAuthState { + isProcessing: boolean; + errorMessage: string | null; +} + +const initialState: DeepLinkAuthState = { isProcessing: false, errorMessage: null }; + +let deepLinkAuthState: DeepLinkAuthState = initialState; +const listeners = new Set<() => void>(); + +const emitChange = (): void => { + for (const listener of listeners) { + listener(); + } +}; + +const setDeepLinkAuthState = (next: DeepLinkAuthState): void => { + deepLinkAuthState = next; + emitChange(); +}; + +export const getDeepLinkAuthState = (): DeepLinkAuthState => deepLinkAuthState; + +export const subscribeDeepLinkAuthState = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const beginDeepLinkAuthProcessing = (): void => { + setDeepLinkAuthState({ isProcessing: true, errorMessage: null }); +}; + +export const completeDeepLinkAuthProcessing = (): void => { + setDeepLinkAuthState({ isProcessing: false, errorMessage: null }); +}; + +export const failDeepLinkAuthProcessing = (message: string): void => { + setDeepLinkAuthState({ isProcessing: false, errorMessage: message }); +}; + +export const useDeepLinkAuthState = (): DeepLinkAuthState => + useSyncExternalStore(subscribeDeepLinkAuthState, getDeepLinkAuthState, getDeepLinkAuthState); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 1cf42c3bb9..88fa878813 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -2,13 +2,20 @@ 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 { getCoreStateSnapshot, patchCoreStateSnapshot } from '../lib/coreState/store'; import { consumeLoginToken } from '../services/api/authApi'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; +import { + beginDeepLinkAuthProcessing, + completeDeepLinkAuthProcessing, + failDeepLinkAuthProcessing, +} from '../store/deepLinkAuthState'; import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate'; import { openUrl } from './openUrl'; import { storeSession } from './tauriCommands'; +const SESSION_TOKEN_UPDATED_EVENT = 'core-state:session-token-updated'; + const focusMainWindow = async () => { try { const window = getCurrentWindow(); @@ -36,6 +43,12 @@ const waitForAuthReadiness = async (maxAttempts = 10, delayMs = 150) => { console.warn('[DeepLink][auth] readiness timeout; continuing'); }; +const applySessionToken = async (sessionToken: string): Promise => { + await storeSession(sessionToken, {}); + patchCoreStateSnapshot({ snapshot: { sessionToken } }); + window.dispatchEvent(new CustomEvent(SESSION_TOKEN_UPDATED_EVENT, { detail: { sessionToken } })); +}; + /** * Handle an `openhuman://auth?token=...` deep link for login. */ @@ -44,26 +57,24 @@ const handleAuthDeepLink = async (parsed: URL) => { const key = parsed.searchParams.get('key'); if (!token) { console.warn('[DeepLink] URL did not contain a token query parameter'); + failDeepLinkAuthProcessing('Sign-in callback was missing a token. Please try again.'); return; } - console.log('[DeepLink][auth] received', { - tokenLength: token.length, - keyMode: parsed.searchParams.get('key') ?? 'consume', - }); + beginDeepLinkAuthProcessing(); - await focusMainWindow(); - await waitForAuthReadiness(); + try { + await focusMainWindow(); + await waitForAuthReadiness(); + + const sessionToken = key === 'auth' ? token : await consumeLoginToken(token); + await applySessionToken(sessionToken); - if (key === 'auth') { - await storeSession(token, {}); - console.log('[DeepLink][auth] bypass token applied'); - window.location.hash = '/home'; - } else { - const jwtToken = await consumeLoginToken(token); - await storeSession(jwtToken, {}); - console.log('[DeepLink][auth] login token consumed'); window.location.hash = '/home'; + completeDeepLinkAuthProcessing(); + } catch (error) { + console.error('[DeepLink][auth] failed to complete login:', error); + failDeepLinkAuthProcessing('Sign-in failed. Please try again.'); } };