diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 2d2d4744cc4..6f09d5a3a76 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -4,7 +4,6 @@ import type { DeepPartial } from 'ts-essentials' import type { ImportMap } from '../bin/generateImportMap/index.js' import type { ClientBlock } from '../fields/config/types.js' import type { BlockSlug, TypedUser } from '../index.js' -import type { PayloadRequest } from '../types/index.js' import type { RootLivePreviewConfig, SanitizedConfig, @@ -164,6 +163,7 @@ export const createClientConfig = ({ case 'admin': clientConfig.admin = { autoLogin: config.admin.autoLogin, + autoRefresh: config.admin.autoRefresh, avatar: config.admin.avatar, custom: config.admin.custom, dateFormat: config.admin.dateFormat, diff --git a/packages/ui/src/providers/Auth/index.tsx b/packages/ui/src/providers/Auth/index.tsx index 08ef7350a78..e6d5a9f5c68 100644 --- a/packages/ui/src/providers/Auth/index.tsx +++ b/packages/ui/src/providers/Auth/index.tsx @@ -139,6 +139,15 @@ export function AuthProvider({ clearTimeout(refreshTokenTimeoutRef.current) }, []) + // Handler for reminder timeout - uses useEffectEvent to capture latest autoRefresh value + const handleReminderTimeout = useEffectEvent(() => { + if (autoRefresh) { + refreshCookieEvent() + } else { + openModal(stayLoggedInModalSlug) + } + }) + const setNewUser = useCallback( (userResponse: null | UserWithToken) => { clearTimeout(reminderTimeoutRef.current) @@ -159,13 +168,7 @@ export function AuthProvider({ setForceLogoutBufferMs(nextForceLogoutBufferMs) reminderTimeoutRef.current = setTimeout( - () => { - if (autoRefresh) { - refreshCookieEvent() - } else { - openModal(stayLoggedInModalSlug) - } - }, + handleReminderTimeout, Math.max(expiresInMs - nextForceLogoutBufferMs, 0), ) @@ -178,7 +181,7 @@ export function AuthProvider({ revokeTokenAndExpire() } }, - [autoRefresh, redirectToInactivityRoute, revokeTokenAndExpire, openModal], + [redirectToInactivityRoute, revokeTokenAndExpire], ) const refreshCookie = useCallback( diff --git a/test/auth/config.ts b/test/auth/config.ts index 75ada5f8ded..b7fb9581000 100644 --- a/test/auth/config.ts +++ b/test/auth/config.ts @@ -21,6 +21,7 @@ export default buildConfigWithDefaults({ password: devUser.password, prefillOnly: true, }, + autoRefresh: true, components: { beforeDashboard: ['./BeforeDashboard.js#BeforeDashboard'], beforeLogin: ['./BeforeLogin.js#BeforeLogin'], diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 83753ce585d..bb873cbb8bc 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -499,4 +499,37 @@ describe('Auth', () => { }) }) }) + + describe('autoRefresh', () => { + beforeAll(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'auth', + deleteOnly: false, + }) + + await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true }) + + url = new AdminUrlUtil(serverURL, slug) + + // Install clock before login so token expiration and clock are in sync + await page.clock.install({ time: Date.now() }) + + await login({ page, serverURL }) + }) + + test('should automatically refresh token without showing modal', async () => { + await expect(page.locator('.nav')).toBeVisible() + + // Fast forward time to just past the reminder timeout + await page.clock.fastForward(7141000) // 1 hour 59 minutes + 1 second + + // Resume clock so timers can execute + await page.clock.resume() + + await expect(page.locator('.confirmation-modal')).toBeHidden() + + await expect(page.locator('.nav')).toBeVisible() + }) + }) }) diff --git a/test/auth/payload-types.ts b/test/auth/payload-types.ts index f75bf9f4a45..dc78e4c10fd 100644 --- a/test/auth/payload-types.ts +++ b/test/auth/payload-types.ts @@ -79,6 +79,7 @@ export interface Config { 'public-users': PublicUser; relationsCollection: RelationsCollection; 'api-keys-with-field-read-access': ApiKeysWithFieldReadAccess; + 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -92,6 +93,7 @@ export interface Config { 'public-users': PublicUsersSelect | PublicUsersSelect; relationsCollection: RelationsCollectionSelect | RelationsCollectionSelect; 'api-keys-with-field-read-access': ApiKeysWithFieldReadAccessSelect | ApiKeysWithFieldReadAccessSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -99,6 +101,7 @@ export interface Config { db: { defaultIDType: string; }; + fallbackLocale: null; globals: {}; globalsSelect: {}; locale: null; @@ -392,6 +395,23 @@ export interface ApiKeysWithFieldReadAccess { apiKey?: string | null; apiKeyIndex?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: string; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -653,6 +673,14 @@ export interface ApiKeysWithFieldReadAccessSelect { apiKey?: T; apiKeyIndex?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select".