import { QueryClient, useMutation } from '@tanstack/react-query' import React, { createContext, PropsWithChildren, useCallback, useEffect, useMemo, useState, } from 'react' import { Alert } from 'react-native' import { currentPersonQueryKey } from '../hooks/use_current_person' import { organizationRequestArgs } from '../hooks/use_organization' import { useStorage } from '../hooks/use_storage' import { getRequestQueryKey } from '../hooks/use_suspense_api' import { CurrentPersonResource, FailedResponse, OAuthToken, OrganizationResource } from '../types' import { ApiResource } from '../types/api_primitives' import { ENV, PartialToken, Session, UNAUTHENTICATED_SESSION_ID } from '../utils' import { StorageAdapter } from '../utils/native_adapters/storage_adapter' import { SessionManager } from '../utils/session_manager' import { chatQueryClient } from './api_provider' type Sessions = Record // Key is session ID export type SessionContextValue = { env: ENV setEnv: (_env: ENV) => void session: Session sessions: Sessions token: OAuthToken | undefined handleUnauthorizedResponse: (_response: FailedResponse) => void logout: () => void setToken: (_token: OAuthToken) => void switchSession: (_sessionId: string) => void createSession: (_session: Session) => Promise } export const SessionContext = createContext({ env: 'production', setEnv: () => null, session: new Session(), sessions: {}, token: undefined, handleUnauthorizedResponse: () => null, logout: () => null, setToken: () => null, switchSession: () => null, createSession: () => Promise.resolve(), }) export type SessionContextConfig = { // Storage adapters secureStorage: StorageAdapter // For sensitive data (tokens/sessions) // Functional callbacks refreshTokenFn: (params: { refresh_token: string; env: ENV }) => Promise onLogout: () => void | Promise // Optional configuration storageKeys?: { sessions?: string currentSessionId?: string } alertConfig?: { title?: string message?: string retryText?: string logoutText?: string } defaultEnv?: ENV } const decorateSession = async (session: Session) => { if (!session.isAuthenticated) return session const { data: personData } = await chatQueryClient.fetchQuery>( { queryKey: currentPersonQueryKey, } ) const { data: organizationData } = await chatQueryClient.fetchQuery< ApiResource >({ queryKey: getRequestQueryKey(organizationRequestArgs), }) if (personData) { const { name, avatar, id } = personData || {} session.setPerson({ id, type: 'Person', name, avatar }) } if (organizationData) { session.setOrganization({ id: organizationData.id, type: 'Organization', name: organizationData.name, }) } return session } export function SessionContextProvider({ children, queryClient, config, }: PropsWithChildren<{ queryClient: QueryClient; config: SessionContextConfig }>) { const { secureStorage, refreshTokenFn, onLogout, storageKeys = {}, alertConfig = {} } = config const sessionsKey = storageKeys.sessions || 'sessions-storage' const [managerString, setManagerString] = useStorage(secureStorage, sessionsKey, '') const sessionManager = useMemo(() => SessionManager.hydrate(managerString), [managerString]) const sessions = sessionManager.sessions const currentSession = sessionManager.currentSession const [alertShown, setAlertShown] = useState(false) const { token } = currentSession const { mutate: refreshToken, isPending: isRefreshingToken, isError: isRefreshError, } = useMutation({ mutationKey: ['refresh-token', token?.refresh_token], mutationFn: () => { if (!token?.refresh_token) { return Promise.reject(new Error('Refresh token is required')) } return refreshTokenFn({ refresh_token: token.refresh_token, env: currentSession.env }).then( handleTokenUpdate ) }, onError: (t: Partial) => { handleRefreshFailed(t) }, }) const handleClearQueryClient = useCallback(() => { chatQueryClient.clear() queryClient.clear() }, [queryClient]) const handleSetEnv = useCallback( async (_env: ENV) => { const updatedManager = SessionManager.hydrate(managerString) const session = updatedManager.currentSession if (_env === session.env) return const productionEnvironments = ['production', 'staging'] const switchingToDevelopment = _env === 'development' && productionEnvironments.includes(session.env) const switchingToProduction = _env === 'production' && session.env === 'development' if (switchingToDevelopment || switchingToProduction) { const newSession = new Session({ env: _env }) updatedManager.upsertSession(newSession) } updatedManager.setEnv(_env) await setManagerString(updatedManager.toString()) }, [managerString, setManagerString] ) const handleCreateSession = useCallback( async (session: Session) => { const updatedManager = SessionManager.hydrate(managerString) updatedManager.sessions[session.id] = session.toString() updatedManager.currentSessionId = session.id await setManagerString(updatedManager.toString()) }, [managerString, setManagerString] ) const handleSwitchSession = useCallback( async (sessionId: string) => { const updatedManager = SessionManager.hydrate(managerString) try { updatedManager.switchSession(sessionId) } catch (_error) { Alert.alert('Error', 'Session is not authenticated') } await setManagerString(updatedManager.toString()) }, [managerString, setManagerString] ) const handleLogout = useCallback(async () => { handleClearQueryClient() const manager = SessionManager.hydrate(managerString) // Clear the current session ID if (manager.currentSessionId) { manager.removeSession(manager.currentSessionId) await setManagerString(manager.toString()) } return onLogout() }, [managerString, setManagerString, handleClearQueryClient, onLogout]) const handleTokenUpdate = useCallback( async (t: OAuthToken) => { const updatedManager = SessionManager.hydrate(managerString) updatedManager.updateCurrentToken(t) await setManagerString(updatedManager.toString()) }, [managerString, setManagerString] ) const handleRefreshFailed = useCallback( (t: Partial) => { if (t.status !== 401) return // If we didn't fail because of an unauthorized response ( 401 ), // it could be because we were unable to store the token. // Do not log out. const { errors = [] } = t // The code for a forced logout is "capuchin" const isForcedLogout = errors.some((e: { detail?: string }) => /capuchin/i.test(e.detail || '') ) if (isForcedLogout) { handleLogout() } }, [handleLogout] ) const handleUnauthorizedResponse = useCallback( async (response: FailedResponse) => { const { errors = [] } = response const isUnauthorized = errors.some((e: { detail?: string }) => /TRASH_PANDA/i.test(e.detail || '') ) if (isUnauthorized) return const isForcedLogout = errors.some((e: { detail?: string }) => /capuchin/i.test(e.detail || '') ) const isExpiredToken = errors.some((e: { detail?: string }) => /baboon/i.test(e.detail || '')) if (!isRefreshingToken || isExpiredToken || isForcedLogout) { refreshToken() } }, [refreshToken, isRefreshingToken] ) const sessionContextValue: SessionContextValue = { env: currentSession.env, token: currentSession.token as OAuthToken | undefined, session: currentSession, sessions, logout: handleLogout, setToken: handleTokenUpdate, handleUnauthorizedResponse, setEnv: handleSetEnv, switchSession: handleSwitchSession, createSession: handleCreateSession, } const alertTitle = alertConfig.title || 'Oops' const alertMessage = alertConfig.message || 'Something went wrong with your login!' const retryText = alertConfig.retryText || 'Keep trying' const logoutText = alertConfig.logoutText || 'Logout' useEffect(() => { if (isRefreshError && !alertShown && token?.refresh_token) { setAlertShown(true) Alert.alert(alertTitle, alertMessage, [ { text: retryText, onPress: () => { refreshToken() setAlertShown(false) }, }, { text: logoutText, onPress: () => handleLogout() }, ]) } }, [ isRefreshError, handleLogout, alertShown, refreshToken, token?.refresh_token, alertTitle, alertMessage, retryText, logoutText, ]) useEffect(() => { const manager = SessionManager.hydrate(managerString) if (manager.currentSessionId === UNAUTHENTICATED_SESSION_ID) return decorateSession(manager.currentSession).then(async decoratedSession => { manager.upsertSession(decoratedSession) await setManagerString(manager.toString()) }) }, [managerString, setManagerString]) return {children} }