mirror of
https://github.com/enso-org/enso.git
synced 2024-12-19 22:41:49 +03:00
Fix referesh Interval (#11732)
This PR fixes issue when refetch didn't happen because the session either already expired or very close to expire This PR fixes the reset interval when it's less that 5 mins or already expired Based on https://github.com/enso-org/enso/pull/11725 Closes: https://github.com/enso-org/cloud-v2/issues/1603
This commit is contained in:
parent
4d13065d00
commit
a6d040ecf5
@ -38,7 +38,6 @@
|
||||
"dependencies": {
|
||||
"@tanstack/query-persist-client-core": "^5.54.0",
|
||||
"@tanstack/vue-query": ">= 5.54.0 < 5.56.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lib0": "^0.2.85",
|
||||
"react": "^18.3.1",
|
||||
"vitest": "^1.3.1",
|
||||
|
@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
import * as queryCore from '@tanstack/query-core'
|
||||
import * as persistClientCore from '@tanstack/query-persist-client-core'
|
||||
import type { AsyncStorage, StoragePersisterOptions } from '@tanstack/query-persist-client-core'
|
||||
import { experimental_createPersister as createPersister } from '@tanstack/query-persist-client-core'
|
||||
import * as vueQuery from '@tanstack/vue-query'
|
||||
import * as idbKeyval from 'idb-keyval'
|
||||
|
||||
declare module '@tanstack/query-core' {
|
||||
/** Query client with additional methods. */
|
||||
@ -61,26 +61,38 @@ const DEFAULT_QUERY_PERSIST_TIME_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
|
||||
const DEFAULT_BUSTER = 'v1.1'
|
||||
|
||||
export interface QueryClientOptions<TStorageValue = string> {
|
||||
readonly persisterStorage?: AsyncStorage<TStorageValue> & {
|
||||
readonly clear: () => Promise<void>
|
||||
readonly serialize?: StoragePersisterOptions<TStorageValue>['serialize']
|
||||
readonly deserialize?: StoragePersisterOptions<TStorageValue>['deserialize']
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new Tanstack Query client. */
|
||||
export function createQueryClient(): QueryClient {
|
||||
const store = idbKeyval.createStore('enso', 'query-persist-cache')
|
||||
export function createQueryClient<TStorageValue = string>(
|
||||
options: QueryClientOptions<TStorageValue> = {},
|
||||
): QueryClient {
|
||||
const { persisterStorage } = options
|
||||
|
||||
queryCore.onlineManager.setOnline(navigator.onLine)
|
||||
|
||||
const persister = persistClientCore.experimental_createPersister({
|
||||
storage: {
|
||||
getItem: key => idbKeyval.get<persistClientCore.PersistedQuery>(key, store),
|
||||
setItem: (key, value) => idbKeyval.set(key, value, store),
|
||||
removeItem: key => idbKeyval.del(key, store),
|
||||
},
|
||||
// Prefer online first and don't rely on the local cache if user is online
|
||||
// fallback to the local cache only if the user is offline
|
||||
maxAge: queryCore.onlineManager.isOnline() ? -1 : DEFAULT_QUERY_PERSIST_TIME_MS,
|
||||
buster: DEFAULT_BUSTER,
|
||||
filters: { predicate: query => query.meta?.persist !== false },
|
||||
prefix: 'enso:query-persist:',
|
||||
serialize: persistedQuery => persistedQuery,
|
||||
deserialize: persistedQuery => persistedQuery,
|
||||
})
|
||||
let persister: ReturnType<typeof createPersister<TStorageValue>> | null = null
|
||||
if (persisterStorage) {
|
||||
persister = createPersister<TStorageValue>({
|
||||
storage: persisterStorage,
|
||||
// Prefer online first and don't rely on the local cache if user is online
|
||||
// fallback to the local cache only if the user is offline
|
||||
maxAge: queryCore.onlineManager.isOnline() ? -1 : DEFAULT_QUERY_PERSIST_TIME_MS,
|
||||
buster: DEFAULT_BUSTER,
|
||||
filters: { predicate: query => query.meta?.persist !== false },
|
||||
prefix: 'enso:query-persist:',
|
||||
...(persisterStorage.serialize != null ? { serialize: persisterStorage.serialize } : {}),
|
||||
...(persisterStorage.deserialize != null ?
|
||||
{ deserialize: persisterStorage.deserialize }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
const queryClient: QueryClient = new vueQuery.QueryClient({
|
||||
mutationCache: new queryCore.MutationCache({
|
||||
@ -117,11 +129,11 @@ export function createQueryClient(): QueryClient {
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
persister,
|
||||
...(persister != null ? { persister } : {}),
|
||||
refetchOnReconnect: 'always',
|
||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||
retry: (failureCount, error: unknown) => {
|
||||
const statusesToIgnore = [401, 403, 404]
|
||||
const statusesToIgnore = [403, 404]
|
||||
const errorStatus =
|
||||
(
|
||||
typeof error === 'object' &&
|
||||
@ -132,18 +144,22 @@ export function createQueryClient(): QueryClient {
|
||||
error.status
|
||||
: -1
|
||||
|
||||
if (errorStatus === 401) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (statusesToIgnore.includes(errorStatus)) {
|
||||
return false
|
||||
} else {
|
||||
return failureCount < 3
|
||||
}
|
||||
|
||||
return failureCount < 3
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Object.defineProperty(queryClient, 'nukePersister', {
|
||||
value: () => idbKeyval.clear(store),
|
||||
value: () => persisterStorage?.clear(),
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
|
@ -67,6 +67,8 @@
|
||||
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols ( ^$*.[]{}()?\"!@#%&/,><':;|_~`=+-).",
|
||||
"passwordValidationError": "Your password does not meet the security requirements.",
|
||||
|
||||
"sessionExpiredError": "Your session has expired. Please sign in again.",
|
||||
|
||||
"confirmSignUpError": "Incorrect email or confirmation code.",
|
||||
"confirmRegistration": "Sign Up Successful!",
|
||||
"confirmRegistrationSubtitle": "Please confirm your email to complete registration",
|
||||
|
@ -57,6 +57,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"enso-common": "workspace:*",
|
||||
"framer-motion": "11.3.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"input-otp": "1.2.4",
|
||||
"is-network-error": "^1.0.1",
|
||||
"monaco-editor": "0.48.0",
|
||||
|
@ -51,7 +51,7 @@ import * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
|
||||
import DriveProvider from '#/providers/DriveProvider'
|
||||
import { useHttpClient } from '#/providers/HttpClientProvider'
|
||||
import { useHttpClientStrict } from '#/providers/HttpClientProvider'
|
||||
import InputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import { useLogger } from '#/providers/LoggerProvider'
|
||||
@ -285,7 +285,7 @@ export interface AppRouterProps extends AppProps {
|
||||
function AppRouter(props: AppRouterProps) {
|
||||
const { isAuthenticationDisabled, shouldShowDashboard } = props
|
||||
const { onAuthenticated, projectManagerInstance } = props
|
||||
const httpClient = useHttpClient()
|
||||
const httpClient = useHttpClientStrict()
|
||||
const logger = useLogger()
|
||||
const navigate = router.useNavigate()
|
||||
|
||||
|
@ -334,6 +334,10 @@ export class Cognito {
|
||||
const currentUser = await currentAuthenticatedUser()
|
||||
const refreshToken = (await amplify.Auth.currentSession()).getRefreshToken()
|
||||
|
||||
if (refreshToken.getToken() === '') {
|
||||
throw new Error('Refresh token is empty, cannot refresh session, Please sign in again.')
|
||||
}
|
||||
|
||||
return await new Promise<cognito.CognitoUserSession>((resolve, reject) => {
|
||||
currentUser
|
||||
.unwrap()
|
||||
|
@ -1,31 +1,36 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Component that passes the value of a field to its children.
|
||||
*/
|
||||
import { useDeferredValue, type ReactNode } from 'react'
|
||||
import { useWatch } from 'react-hook-form'
|
||||
import { useFormContext } from './FormProvider'
|
||||
import type { FieldPath, FieldValues, FormInstanceValidated, TSchema } from './types'
|
||||
|
||||
/**
|
||||
*
|
||||
* Props for the {@link FieldValue} component.
|
||||
*/
|
||||
export interface FieldValueProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>> {
|
||||
readonly form?: FormInstanceValidated<Schema>
|
||||
readonly name: TFieldName
|
||||
readonly children: (value: FieldValues<Schema>[TFieldName]) => React.ReactNode
|
||||
readonly children: (value: FieldValues<Schema>[TFieldName]) => ReactNode
|
||||
readonly disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that passes the value of a field to its children.
|
||||
* Component that subscribes to the value of a field.
|
||||
*/
|
||||
export function FieldValue<Schema extends TSchema, TFieldName extends FieldPath<Schema>>(
|
||||
props: FieldValueProps<Schema, TFieldName>,
|
||||
) {
|
||||
const { form, name, children } = props
|
||||
const { form, name, children, disabled = false } = props
|
||||
|
||||
const formInstance = useFormContext(form)
|
||||
const value = useWatch({ control: formInstance.control, name })
|
||||
const watchValue = useWatch({ control: formInstance.control, name, disabled })
|
||||
|
||||
return <>{children(value)}</>
|
||||
// We use deferred value here to rate limit the re-renders of the children.
|
||||
// This is useful when the children are expensive to render, such as a component tree.
|
||||
const deferredValue = useDeferredValue(watchValue)
|
||||
|
||||
return children(deferredValue)
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ export interface ChatPlaceholderProps {
|
||||
}
|
||||
|
||||
/** A placeholder component replacing `Chat` when a user is not logged in. */
|
||||
export default function ChatPlaceholder(props: ChatPlaceholderProps) {
|
||||
function ChatPlaceholder(props: ChatPlaceholderProps) {
|
||||
const { hideLoginButtons = false, isOpen, doClose } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const logger = loggerProvider.useLogger()
|
||||
@ -93,3 +93,5 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo(ChatPlaceholder)
|
||||
|
@ -6,6 +6,7 @@ import FocusArea from '#/components/styled/FocusArea'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import InfoMenu from '#/layouts/InfoMenu'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { memo } from 'react'
|
||||
|
||||
// ===============
|
||||
// === InfoBar ===
|
||||
@ -18,7 +19,7 @@ export interface InfoBarProps {
|
||||
}
|
||||
|
||||
/** A toolbar containing chat and the user menu. */
|
||||
export default function InfoBar(props: InfoBarProps) {
|
||||
function InfoBar(props: InfoBarProps) {
|
||||
const { isHelpChatOpen, setIsHelpChatOpen } = props
|
||||
const { getText } = useText()
|
||||
|
||||
@ -66,3 +67,5 @@ export default function InfoBar(props: InfoBarProps) {
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(InfoBar)
|
||||
|
@ -64,13 +64,16 @@ export default function SettingsTab(props: SettingsTabProps) {
|
||||
} else {
|
||||
const content =
|
||||
columns.length === 1 ?
|
||||
<div className={twMerge('flex grow flex-col gap-8', classes[0])} {...contentProps}>
|
||||
<div
|
||||
className={twMerge('flex max-w-[512px] grow flex-col gap-8', classes[0])}
|
||||
{...contentProps}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<SettingsSection key={section.nameId} context={context} data={section} />
|
||||
))}
|
||||
</div>
|
||||
: <div
|
||||
className="grid min-h-full grow grid-cols-1 gap-8 lg:h-auto lg:grid-cols-2"
|
||||
className="grid min-h-full max-w-[1024px] grow grid-cols-1 gap-8 lg:h-auto lg:grid-cols-2"
|
||||
{...contentProps}
|
||||
>
|
||||
{columns.map((sectionsInColumn, i) => (
|
||||
|
@ -2,6 +2,7 @@
|
||||
import * as router from 'react-router-dom'
|
||||
|
||||
import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common'
|
||||
import { isOnElectron } from 'enso-common/src/detect'
|
||||
|
||||
import { DASHBOARD_PATH, FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils'
|
||||
import ArrowRightIcon from '#/assets/arrow_right.svg'
|
||||
@ -18,7 +19,6 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
|
||||
import { passwordSchema } from '#/pages/authentication/schemas'
|
||||
import { useAuth } from '#/providers/AuthProvider'
|
||||
import { useLocalBackend } from '#/providers/BackendProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { useState } from 'react'
|
||||
|
||||
@ -69,11 +69,10 @@ export default function Login() {
|
||||
},
|
||||
})
|
||||
|
||||
const [emailInput, setEmailInput] = useState(initialEmail)
|
||||
|
||||
const [user, setUser] = useState<CognitoUser | null>(null)
|
||||
const localBackend = useLocalBackend()
|
||||
const supportsOffline = localBackend != null
|
||||
|
||||
const isElectron = isOnElectron()
|
||||
const supportsOffline = isElectron
|
||||
|
||||
const { nextStep, stepperState, previousStep } = Stepper.useStepperState({
|
||||
steps: 2,
|
||||
@ -93,17 +92,21 @@ export default function Login() {
|
||||
title={getText('loginToYourAccount')}
|
||||
supportsOffline={supportsOffline}
|
||||
footer={
|
||||
<Link
|
||||
openInBrowser={localBackend != null}
|
||||
to={(() => {
|
||||
const newQuery = new URLSearchParams({ email: emailInput }).toString()
|
||||
return localBackend != null ?
|
||||
`https://${CLOUD_DASHBOARD_DOMAIN}${REGISTRATION_PATH}?${newQuery}`
|
||||
: `${REGISTRATION_PATH}?${newQuery}`
|
||||
})()}
|
||||
icon={CreateAccountIcon}
|
||||
text={getText('dontHaveAnAccount')}
|
||||
/>
|
||||
<Form.FieldValue form={form} name="email">
|
||||
{(email) => (
|
||||
<Link
|
||||
openInBrowser={isElectron}
|
||||
to={(() => {
|
||||
const newQuery = new URLSearchParams({ email }).toString()
|
||||
return isElectron ?
|
||||
`https://${CLOUD_DASHBOARD_DOMAIN}${REGISTRATION_PATH}?${newQuery}`
|
||||
: `${REGISTRATION_PATH}?${newQuery}`
|
||||
})()}
|
||||
icon={CreateAccountIcon}
|
||||
text={getText('dontHaveAnAccount')}
|
||||
/>
|
||||
)}
|
||||
</Form.FieldValue>
|
||||
}
|
||||
>
|
||||
<Stepper state={stepperState} renderStep={() => null}>
|
||||
@ -129,9 +132,6 @@ export default function Login() {
|
||||
autoComplete="email"
|
||||
icon={AtIcon}
|
||||
placeholder={getText('emailPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setEmailInput(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
@ -146,14 +146,18 @@ export default function Login() {
|
||||
placeholder={getText('passwordPlaceholder')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
|
||||
size="small"
|
||||
className="self-end"
|
||||
>
|
||||
{getText('forgotYourPassword')}
|
||||
</Button>
|
||||
<Form.FieldValue form={form} name="email">
|
||||
{(email) => (
|
||||
<Button
|
||||
variant="link"
|
||||
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email }).toString()}`}
|
||||
size="small"
|
||||
className="self-end"
|
||||
>
|
||||
{getText('forgotYourPassword')}
|
||||
</Button>
|
||||
)}
|
||||
</Form.FieldValue>
|
||||
</div>
|
||||
|
||||
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
|
||||
|
@ -27,7 +27,15 @@ export function HttpClientProvider(props: HttpClientProviderProps) {
|
||||
|
||||
/** Returns the HTTP client. */
|
||||
export function useHttpClient() {
|
||||
const httpClient = React.useContext(HTTPClientContext)
|
||||
return React.useContext(HTTPClientContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTTP client.
|
||||
* @throws If the HTTP client is not found in context.
|
||||
*/
|
||||
export function useHttpClientStrict() {
|
||||
const httpClient = useHttpClient()
|
||||
|
||||
invariant(httpClient, 'HTTP client not found in context')
|
||||
|
||||
|
@ -15,6 +15,7 @@ import * as errorModule from '#/utilities/error'
|
||||
|
||||
import type * as cognito from '#/authentication/cognito'
|
||||
import * as listen from '#/authentication/listen'
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
|
||||
// ======================
|
||||
// === SessionContext ===
|
||||
@ -52,19 +53,20 @@ export interface SessionProviderProps {
|
||||
readonly userSession: (() => Promise<cognito.UserSession | null>) | null
|
||||
readonly saveAccessToken?: ((accessToken: cognito.UserSession) => void) | null
|
||||
readonly refreshUserSession: (() => Promise<cognito.UserSession | null>) | null
|
||||
readonly children: React.ReactNode
|
||||
readonly children: React.ReactNode | ((props: SessionContextType) => React.ReactNode)
|
||||
}
|
||||
|
||||
const FIVE_MINUTES_MS = 300_000
|
||||
const SIX_HOURS_MS = 21_600_000
|
||||
|
||||
/** Create a query for the user session. */
|
||||
function createSessionQuery(userSession: (() => Promise<cognito.UserSession | null>) | null) {
|
||||
return reactQuery.queryOptions({
|
||||
queryKey: ['userSession'],
|
||||
queryFn: () => userSession?.().catch(() => null) ?? null,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchIntervalInBackground: true,
|
||||
queryFn: async () => {
|
||||
const session = (await userSession?.().catch(() => null)) ?? null
|
||||
return session
|
||||
},
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchOnMount: 'always',
|
||||
refetchOnReconnect: 'always',
|
||||
})
|
||||
}
|
||||
|
||||
@ -86,40 +88,31 @@ export default function SessionProvider(props: SessionProviderProps) {
|
||||
|
||||
const httpClient = httpClientProvider.useHttpClient()
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
const toastAndLog = useToastAndLog()
|
||||
|
||||
const sessionQuery = createSessionQuery(userSession)
|
||||
|
||||
const session = reactQuery.useSuspenseQuery(sessionQuery)
|
||||
|
||||
if (session.data) {
|
||||
httpClient.setSessionToken(session.data.accessToken)
|
||||
}
|
||||
|
||||
const timeUntilRefresh =
|
||||
session.data ?
|
||||
// If the session has not expired, we should refresh it when it is 5 minutes from expiring.
|
||||
new Date(session.data.expireAt).getTime() - Date.now() - FIVE_MINUTES_MS
|
||||
: Infinity
|
||||
|
||||
const refreshUserSessionMutation = reactQuery.useMutation({
|
||||
mutationKey: ['refreshUserSession', session.data?.expireAt],
|
||||
mutationFn: async () => refreshUserSession?.(),
|
||||
meta: { invalidates: [sessionQuery.queryKey] },
|
||||
mutationKey: ['refreshUserSession', { expireAt: session.data?.expireAt }],
|
||||
mutationFn: async () => refreshUserSession?.() ?? null,
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
httpClient?.setSessionToken(data.accessToken)
|
||||
}
|
||||
return queryClient.invalidateQueries({ queryKey: sessionQuery.queryKey })
|
||||
},
|
||||
onError: (error) => {
|
||||
// Something went wrong with the refresh token, so we need to sign the user out.
|
||||
toastAndLog('sessionExpiredError', error)
|
||||
queryClient.setQueryData(sessionQuery.queryKey, null)
|
||||
},
|
||||
})
|
||||
|
||||
reactQuery.useQuery({
|
||||
queryKey: ['refreshUserSession'],
|
||||
queryFn: () => refreshUserSessionMutation.mutateAsync(),
|
||||
meta: { persist: false },
|
||||
networkMode: 'online',
|
||||
initialData: null,
|
||||
initialDataUpdatedAt: Date.now(),
|
||||
refetchOnWindowFocus: true,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchInterval: timeUntilRefresh < SIX_HOURS_MS ? timeUntilRefresh : SIX_HOURS_MS,
|
||||
// We don't want to refetch the session if the user is not authenticated
|
||||
enabled: userSession != null && refreshUserSession != null && session.data != null,
|
||||
})
|
||||
if (session.data) {
|
||||
httpClient?.setSessionToken(session.data.accessToken)
|
||||
}
|
||||
|
||||
// Register an effect that will listen for authentication events. When the event occurs, we
|
||||
// will refresh or clear the user's session, forcing a re-render of the page with the new
|
||||
@ -161,15 +154,67 @@ export default function SessionProvider(props: SessionProviderProps) {
|
||||
}
|
||||
}, [session.data, saveAccessTokenEventCallback])
|
||||
|
||||
const sessionContextValue = {
|
||||
session: session.data,
|
||||
sessionQueryKey: sessionQuery.queryKey,
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{ session: session.data, sessionQueryKey: sessionQuery.queryKey }}
|
||||
>
|
||||
{children}
|
||||
<SessionContext.Provider value={sessionContextValue}>
|
||||
{session.data && (
|
||||
<SessionRefresher
|
||||
session={session.data}
|
||||
refreshUserSession={refreshUserSessionMutation.mutateAsync}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof children === 'function' ? children(sessionContextValue) : children}
|
||||
</SessionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Props for a {@link SessionRefresher}. */
|
||||
interface SessionRefresherProps {
|
||||
readonly session: cognito.UserSession
|
||||
readonly refreshUserSession: () => Promise<cognito.UserSession | null>
|
||||
}
|
||||
|
||||
const TEN_SECONDS_MS = 10_000
|
||||
const SIX_HOURS_MS = 21_600_000
|
||||
|
||||
/**
|
||||
* A component that will refresh the user's session at a given interval.
|
||||
*/
|
||||
function SessionRefresher(props: SessionRefresherProps) {
|
||||
const { refreshUserSession, session } = props
|
||||
|
||||
reactQuery.useQuery({
|
||||
queryKey: ['refreshUserSession', { refreshToken: session.refreshToken }] as const,
|
||||
queryFn: () => refreshUserSession(),
|
||||
meta: { persist: false },
|
||||
networkMode: 'online',
|
||||
initialData: session,
|
||||
initialDataUpdatedAt: Date.now(),
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: 'always',
|
||||
refetchOnReconnect: 'always',
|
||||
refetchOnMount: 'always',
|
||||
refetchInterval: () => {
|
||||
const expireAt = session.expireAt
|
||||
|
||||
const timeUntilRefresh =
|
||||
// If the session has not expired, we should refresh it when it is 5 minutes from expiring.
|
||||
// We use 1 second to ensure that we refresh even if the time is very close to expiring
|
||||
// and value won't be less than 0.
|
||||
Math.max(new Date(expireAt).getTime() - Date.now() - TEN_SECONDS_MS, TEN_SECONDS_MS)
|
||||
|
||||
return timeUntilRefresh < SIX_HOURS_MS ? timeUntilRefresh : SIX_HOURS_MS
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ==================
|
||||
// === useSession ===
|
||||
// ==================
|
||||
@ -195,8 +240,5 @@ export function useSessionStrict() {
|
||||
|
||||
invariant(session != null, 'Session must be defined')
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionQueryKey,
|
||||
} as const
|
||||
return { session, sessionQueryKey } as const
|
||||
}
|
||||
|
@ -0,0 +1,168 @@
|
||||
import type { UserSession } from '#/authentication/cognito'
|
||||
import { render, screen, waitFor } from '#/test'
|
||||
import { Rfc3339DateTime } from '#/utilities/dateTime'
|
||||
import HttpClient from '#/utilities/HttpClient'
|
||||
import { Suspense } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HttpClientProvider } from '../HttpClientProvider'
|
||||
import SessionProvider from '../SessionProvider'
|
||||
|
||||
describe('SessionProvider', () => {
|
||||
const mainPageUrl = new URL('https://enso.dev')
|
||||
const userSession = vi.fn<[], Promise<UserSession>>(() =>
|
||||
Promise.resolve({
|
||||
email: 'test@test.com',
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'refreshToken',
|
||||
refreshUrl: 'https://enso.dev',
|
||||
expireAt: Rfc3339DateTime(new Date(Date.now() + 5_000).toJSON()),
|
||||
clientId: 'clientId',
|
||||
}),
|
||||
)
|
||||
const refreshUserSession = vi.fn(() => Promise.resolve(null))
|
||||
const registerAuthEventListener = vi.fn()
|
||||
const saveAccessToken = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('Should retrieve the user session', async () => {
|
||||
const { getByText } = render(
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SessionProvider
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
refreshUserSession={refreshUserSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
saveAccessToken={saveAccessToken}
|
||||
>
|
||||
<div>Hello</div>
|
||||
</SessionProvider>
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
expect(userSession).toBeCalled()
|
||||
expect(getByText(/Loading/)).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(/Hello/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('Should set the access token on the HTTP client', async () => {
|
||||
const httpClient = new HttpClient()
|
||||
|
||||
httpClient.setSessionToken = vi.fn()
|
||||
|
||||
render(
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<HttpClientProvider httpClient={httpClient}>
|
||||
<SessionProvider
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
refreshUserSession={refreshUserSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
saveAccessToken={saveAccessToken}
|
||||
>
|
||||
<div>Hello</div>
|
||||
</SessionProvider>
|
||||
</HttpClientProvider>
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(httpClient.setSessionToken).toBeCalledWith('accessToken')
|
||||
})
|
||||
})
|
||||
|
||||
it('Should refresh the expired user session', async () => {
|
||||
userSession.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
...(await userSession()),
|
||||
// 24 hours from now
|
||||
expireAt: Rfc3339DateTime(new Date(Date.now() - 1).toJSON()),
|
||||
}),
|
||||
)
|
||||
|
||||
render(
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SessionProvider
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
refreshUserSession={refreshUserSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
saveAccessToken={saveAccessToken}
|
||||
>
|
||||
<div>Hello</div>
|
||||
</SessionProvider>
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshUserSession).toBeCalledTimes(1)
|
||||
expect(screen.getByText(/Hello/)).toBeInTheDocument()
|
||||
|
||||
// 2 initial calls(fetching session and refreshing session), 1 mutation call, 1 re-fetch call
|
||||
expect(userSession).toBeCalledTimes(4)
|
||||
})
|
||||
})
|
||||
|
||||
it('Should refresh not stale user session', { timeout: 5_000 }, async () => {
|
||||
userSession.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
...(await userSession()),
|
||||
expireAt: Rfc3339DateTime(new Date(Date.now() + 1_500).toJSON()),
|
||||
}),
|
||||
)
|
||||
|
||||
let session: UserSession | null = null
|
||||
|
||||
render(
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SessionProvider
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
refreshUserSession={refreshUserSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
saveAccessToken={saveAccessToken}
|
||||
>
|
||||
{({ session: sessionFromContext }) => {
|
||||
session = sessionFromContext
|
||||
return null
|
||||
}}
|
||||
</SessionProvider>
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(refreshUserSession).toBeCalledTimes(1)
|
||||
expect(session).not.toBeNull()
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expect(new Date(session!.expireAt).getTime()).toBeGreaterThan(Date.now())
|
||||
},
|
||||
{ timeout: 2_000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('Should call registerAuthEventListener when the session is updated', async () => {
|
||||
render(
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SessionProvider
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
refreshUserSession={refreshUserSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
saveAccessToken={saveAccessToken}
|
||||
>
|
||||
<div>Hello</div>
|
||||
</SessionProvider>
|
||||
</Suspense>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerAuthEventListener).toBeCalled()
|
||||
})
|
||||
})
|
||||
})
|
@ -4,9 +4,11 @@
|
||||
* **IMPORTANT**: This file is supposed to be used instead of `@testing-library/react`
|
||||
* It is used to provide a portal root and locale to all tests.
|
||||
*/
|
||||
/// <reference types="@testing-library/jest-dom" />
|
||||
|
||||
import { Form, type FormProps, type TSchema } from '#/components/AriaComponents'
|
||||
import { Form, type FormInstance, type FormProps, type TSchema } from '#/components/AriaComponents'
|
||||
import UIProviders from '#/components/UIProviders'
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
render,
|
||||
renderHook,
|
||||
@ -15,7 +17,8 @@ import {
|
||||
type RenderOptions,
|
||||
type RenderResult,
|
||||
} from '@testing-library/react'
|
||||
import { type PropsWithChildren, type ReactElement } from 'react'
|
||||
import { createQueryClient } from 'enso-common/src/queryClient'
|
||||
import { useState, type PropsWithChildren, type ReactElement, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* A wrapper that passes through its children.
|
||||
@ -27,11 +30,19 @@ function PassThroughWrapper({ children }: PropsWithChildren) {
|
||||
/**
|
||||
* A wrapper that provides the {@link UIProviders} context.
|
||||
*/
|
||||
function UIProvidersWrapper({ children }: PropsWithChildren) {
|
||||
function UIProvidersWrapper({
|
||||
children,
|
||||
}: {
|
||||
children?: ReactNode | ((props: { queryClient: QueryClient }) => ReactNode)
|
||||
}) {
|
||||
const [queryClient] = useState(() => createQueryClient())
|
||||
|
||||
return (
|
||||
<UIProviders portalRoot={document.body} locale="en">
|
||||
{children}
|
||||
</UIProviders>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UIProviders portalRoot={document.body} locale="en">
|
||||
{typeof children === 'function' ? children({ queryClient }) : children}
|
||||
</UIProviders>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -44,20 +55,49 @@ function FormWrapper<Schema extends TSchema, SubmitResult = void>(
|
||||
return <Form {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for {@link renderWithRoot}.
|
||||
*/
|
||||
interface RenderWithRootResult extends RenderResult {
|
||||
readonly queryClient: QueryClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom render function for tests.
|
||||
*/
|
||||
function renderWithRoot(ui: ReactElement, options?: Omit<RenderOptions, 'queries'>): RenderResult {
|
||||
function renderWithRoot(
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'queries'>,
|
||||
): RenderWithRootResult {
|
||||
const { wrapper: Wrapper = PassThroughWrapper, ...rest } = options ?? {}
|
||||
|
||||
return render(ui, {
|
||||
let queryClient: QueryClient
|
||||
|
||||
const result = render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<UIProvidersWrapper>
|
||||
<Wrapper>{children}</Wrapper>
|
||||
{({ queryClient: queryClientFromWrapper }) => {
|
||||
queryClient = queryClientFromWrapper
|
||||
return <Wrapper>{children}</Wrapper>
|
||||
}}
|
||||
</UIProvidersWrapper>
|
||||
),
|
||||
...rest,
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
// @ts-expect-error - This is safe because we render before returning the result,
|
||||
// so the queryClient is guaranteed to be set.
|
||||
queryClient,
|
||||
} as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for {@link renderWithForm}.
|
||||
*/
|
||||
interface RenderWithFormResult<Schema extends TSchema> extends RenderWithRootResult {
|
||||
readonly form: FormInstance<Schema>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -68,13 +108,36 @@ function renderWithForm<Schema extends TSchema, SubmitResult = void>(
|
||||
options: Omit<RenderOptions, 'queries' | 'wrapper'> & {
|
||||
formProps: FormProps<Schema, SubmitResult>
|
||||
},
|
||||
): RenderResult {
|
||||
): RenderWithFormResult<Schema> {
|
||||
const { formProps, ...rest } = options
|
||||
|
||||
return renderWithRoot(ui, {
|
||||
wrapper: ({ children }) => <FormWrapper {...formProps}>{children}</FormWrapper>,
|
||||
let form: FormInstance<Schema>
|
||||
|
||||
const result = renderWithRoot(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<FormWrapper {...formProps}>
|
||||
{({ form: formFromWrapper }) => {
|
||||
form = formFromWrapper
|
||||
return <>{children}</>
|
||||
}}
|
||||
</FormWrapper>
|
||||
),
|
||||
...rest,
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
// @ts-expect-error - This is safe because we render before returning the result,
|
||||
// so the form is guaranteed to be set.
|
||||
form,
|
||||
} as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for {@link renderHookWithRoot}.
|
||||
*/
|
||||
interface RenderHookWithRootResult<Result, Props> extends RenderHookResult<Result, Props> {
|
||||
readonly queryClient: QueryClient
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,8 +146,35 @@ function renderWithForm<Schema extends TSchema, SubmitResult = void>(
|
||||
function renderHookWithRoot<Result, Props>(
|
||||
hook: (props: Props) => Result,
|
||||
options?: Omit<RenderHookOptions<Props>, 'queries'>,
|
||||
): RenderHookResult<Result, Props> {
|
||||
return renderHook(hook, { wrapper: UIProvidersWrapper, ...options })
|
||||
): RenderHookWithRootResult<Result, Props> {
|
||||
let queryClient: QueryClient
|
||||
|
||||
const result = renderHook(hook, {
|
||||
wrapper: ({ children }) => (
|
||||
<UIProvidersWrapper>
|
||||
{({ queryClient: queryClientFromWrapper }) => {
|
||||
queryClient = queryClientFromWrapper
|
||||
return <>{children}</>
|
||||
}}
|
||||
</UIProvidersWrapper>
|
||||
),
|
||||
...options,
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
// @ts-expect-error - This is safe because we render before returning the result,
|
||||
// so the queryClient is guaranteed to be set.
|
||||
queryClient,
|
||||
} as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for {@link renderHookWithForm}.
|
||||
*/
|
||||
interface RenderHookWithFormResult<Result, Props, Schema extends TSchema>
|
||||
extends RenderHookWithRootResult<Result, Props> {
|
||||
readonly form: FormInstance<Schema>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -95,13 +185,28 @@ function renderHookWithForm<Result, Props, Schema extends TSchema, SubmitResult
|
||||
options: Omit<RenderHookOptions<Props>, 'queries' | 'wrapper'> & {
|
||||
formProps: FormProps<Schema, SubmitResult>
|
||||
},
|
||||
): RenderHookResult<Result, Props> {
|
||||
): RenderHookWithFormResult<Result, Props, Schema> {
|
||||
const { formProps, ...rest } = options
|
||||
|
||||
return renderHookWithRoot(hook, {
|
||||
wrapper: ({ children }) => <FormWrapper {...formProps}>{children}</FormWrapper>,
|
||||
let form: FormInstance<Schema>
|
||||
const result = renderHookWithRoot(hook, {
|
||||
wrapper: ({ children }) => (
|
||||
<FormWrapper {...formProps}>
|
||||
{({ form: formFromWrapper }) => {
|
||||
form = formFromWrapper
|
||||
return <>{children}</>
|
||||
}}
|
||||
</FormWrapper>
|
||||
),
|
||||
...rest,
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
// @ts-expect-error - This is safe because we render before returning the result,
|
||||
// so the form is guaranteed to be set.
|
||||
form,
|
||||
} as const
|
||||
}
|
||||
|
||||
export * from '@testing-library/react'
|
||||
|
@ -7,6 +7,7 @@ import { urlParams } from '@/util/urlParams'
|
||||
import * as vueQuery from '@tanstack/vue-query'
|
||||
import { isOnLinux } from 'enso-common/src/detect'
|
||||
import * as commonQuery from 'enso-common/src/queryClient'
|
||||
import * as idbKeyval from 'idb-keyval'
|
||||
import { lazyVueInReact } from 'veaury'
|
||||
import { type App } from 'vue'
|
||||
|
||||
@ -85,7 +86,16 @@ function main() {
|
||||
const urlWithoutStartupProject = new URL(location.toString())
|
||||
urlWithoutStartupProject.searchParams.delete('startup.project')
|
||||
history.replaceState(null, '', urlWithoutStartupProject)
|
||||
const queryClient = commonQuery.createQueryClient()
|
||||
|
||||
const store = idbKeyval.createStore('enso', 'query-persist-cache')
|
||||
const queryClient = commonQuery.createQueryClient({
|
||||
persisterStorage: {
|
||||
getItem: async (key) => idbKeyval.get(key, store),
|
||||
setItem: async (key, value) => idbKeyval.set(key, value, store),
|
||||
removeItem: async (key) => idbKeyval.del(key, store),
|
||||
clear: async () => idbKeyval.clear(store),
|
||||
},
|
||||
})
|
||||
|
||||
const registerPlugins = (app: App) => {
|
||||
app.use(vueQuery.VueQueryPlugin, { queryClient })
|
||||
|
@ -2,6 +2,6 @@
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jsdom", "vitest/importMeta"]
|
||||
"types": ["node", "jsdom", "vitest/importMeta", "@testing-library/jest-dom"]
|
||||
}
|
||||
}
|
||||
|
@ -85,9 +85,6 @@ importers:
|
||||
'@tanstack/vue-query':
|
||||
specifier: '>= 5.54.0 < 5.56.0'
|
||||
version: 5.54.2(vue@3.5.2(typescript@5.5.3))
|
||||
idb-keyval:
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
lib0:
|
||||
specifier: ^0.2.85
|
||||
version: 0.2.94
|
||||
@ -235,6 +232,9 @@ importers:
|
||||
hash-sum:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
idb-keyval:
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
input-otp:
|
||||
specifier: 1.2.4
|
||||
version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
Loading…
Reference in New Issue
Block a user