From 6b349378e082d174bfa0e1c804e1f3a85e9e8885 Mon Sep 17 00:00:00 2001
From: Jessica Chowdhury <67977755+JessChowdhury@users.noreply.github.com>
Date: Mon, 14 Apr 2025 09:47:08 +0100
Subject: [PATCH] feat: adds and exports reusable auth server functions
(#11900)
### What
Adds exportable server functions for `login`, `logout` and `refresh`
that are fully typed and ready to use.
### Why
Creating server functions for these auth operations require the
developer to manually set and handle the cookies / auth JWT. This can be
a complex and involved process - instead we want to provide an option
that will handle the cookies internally and simplify the process for the
user.
### How
Three re-usable functions can be exported from
`@payload/next/server-functions`:
- login
- logout
- refresh
Examples of how to use these functions will be added to the docs
shortly, along with more in-depth info on server functions.
---
docs/authentication/operations.mdx | 29 ++-
docs/local-api/server-functions.mdx | 163 +++++++++++-
packages/next/package.json | 5 +
packages/next/src/auth/login.ts | 87 +++++++
packages/next/src/auth/logout.ts | 29 +++
packages/next/src/auth/refresh.ts | 42 +++
packages/next/src/exports/auth.ts | 3 +
.../src/utilities/getExistingAuthToken.ts | 10 +
.../src/utilities/setPayloadAuthCookie.ts | 42 +++
test/server-functions/components/login.tsx | 36 +++
.../components/loginFunction.tsx | 23 ++
test/server-functions/components/logout.tsx | 8 +
.../components/logoutFunction.tsx | 14 +
test/server-functions/components/refresh.tsx | 7 +
.../components/refreshFunction.tsx | 16 ++
test/server-functions/config.ts | 34 +++
test/server-functions/e2e.spec.ts | 97 +++++++
test/server-functions/eslint.config.js | 19 ++
test/server-functions/payload-types.ts | 242 ++++++++++++++++++
test/server-functions/tsconfig.eslint.json | 13 +
test/server-functions/tsconfig.json | 8 +
21 files changed, 922 insertions(+), 5 deletions(-)
create mode 100644 packages/next/src/auth/login.ts
create mode 100644 packages/next/src/auth/logout.ts
create mode 100644 packages/next/src/auth/refresh.ts
create mode 100644 packages/next/src/exports/auth.ts
create mode 100644 packages/next/src/utilities/getExistingAuthToken.ts
create mode 100644 packages/next/src/utilities/setPayloadAuthCookie.ts
create mode 100644 test/server-functions/components/login.tsx
create mode 100644 test/server-functions/components/loginFunction.tsx
create mode 100644 test/server-functions/components/logout.tsx
create mode 100644 test/server-functions/components/logoutFunction.tsx
create mode 100644 test/server-functions/components/refresh.tsx
create mode 100644 test/server-functions/components/refreshFunction.tsx
create mode 100644 test/server-functions/config.ts
create mode 100644 test/server-functions/e2e.spec.ts
create mode 100644 test/server-functions/eslint.config.js
create mode 100644 test/server-functions/payload-types.ts
create mode 100644 test/server-functions/tsconfig.eslint.json
create mode 100644 test/server-functions/tsconfig.json
diff --git a/docs/authentication/operations.mdx b/docs/authentication/operations.mdx
index 026b4221dc..88c81baaf9 100644
--- a/docs/authentication/operations.mdx
+++ b/docs/authentication/operations.mdx
@@ -158,7 +158,7 @@ mutation {
```ts
const result = await payload.login({
- collection: '[collection-slug]',
+ collection: 'collection-slug',
data: {
email: 'dev@payloadcms.com',
password: 'get-out',
@@ -166,6 +166,13 @@ const result = await payload.login({
})
```
+
+ **Server Functions:** Payload offers a ready-to-use `login` server function
+ that utilizes the Local API. For integration details and examples, check out
+ the [Server Function
+ docs](../local-api/server-functions#reusable-payload-server-functions).
+
+
## Logout
As Payload sets HTTP-only cookies, logging out cannot be done by just removing a cookie in JavaScript, as HTTP-only cookies are inaccessible by JS within the browser. So, Payload exposes a `logout` operation to delete the token in a safe way.
@@ -189,6 +196,13 @@ mutation {
}
```
+
+ **Server Functions:** Payload provides a ready-to-use `logout` server function
+ that manages the user's cookie for a seamless logout. For integration details
+ and examples, check out the [Server Function
+ docs](../local-api/server-functions#reusable-payload-server-functions).
+
+
## Refresh
Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user.
@@ -240,6 +254,13 @@ mutation {
}
```
+
+ **Server Functions:** Payload exports a ready-to-use `refresh` server function
+ that automatically renews the user's token and updates the associated cookie.
+ For integration details and examples, check out the [Server Function
+ docs](../local-api/server-functions#reusable-payload-server-functions).
+
+
## Verify by Email
If your collection supports email verification, the Verify operation will be exposed which accepts a verification token and sets the user's `_verified` property to `true`, thereby allowing the user to authenticate with the Payload API.
@@ -270,7 +291,7 @@ mutation {
```ts
const result = await payload.verifyEmail({
- collection: '[collection-slug]',
+ collection: 'collection-slug',
token: 'TOKEN_HERE',
})
```
@@ -308,7 +329,7 @@ mutation {
```ts
const result = await payload.unlock({
- collection: '[collection-slug]',
+ collection: 'collection-slug',
})
```
@@ -349,7 +370,7 @@ mutation {
```ts
const token = await payload.forgotPassword({
- collection: '[collection-slug]',
+ collection: 'collection-slug',
data: {
email: 'dev@payloadcms.com',
},
diff --git a/docs/local-api/server-functions.mdx b/docs/local-api/server-functions.mdx
index dcd6708c77..b8e4950df4 100644
--- a/docs/local-api/server-functions.mdx
+++ b/docs/local-api/server-functions.mdx
@@ -310,7 +310,168 @@ export const PostForm: React.FC = () => {
## Reusable Payload Server Functions
-Coming soon…
+Managing authentication with the Local API can be tricky as you have to handle cookies and tokens yourself, and there aren't built-in logout or refresh functions since these only modify cookies. To make this easier, we provide `login`, `logout`, and `refresh` as ready-to-use server functions. They take care of the underlying complexity so you don't have to.
+
+### `login`
+
+Logs in a user by verifying credentials and setting the authentication cookie. This function allows login via username or email, depending on the collection auth configuration.
+
+#### Importing the `login` function
+
+```ts
+import { login } from '@payloadcms/next/auth'
+```
+
+The login function needs your Payload config, which cannot be imported in a client component. To work around this, create a simple server function like the one below, and call it from your client.
+
+```ts
+'use server'
+
+import { login } from '@payloadcms/next/auth'
+import config from '@payload-config'
+
+export async function loginAction({
+ email,
+ password,
+}: {
+ email: string
+ password: string
+}) {
+ try {
+ const result = await login({
+ collection: 'users',
+ config,
+ email,
+ password,
+ })
+ return result
+ } catch (error) {
+ throw new Error(
+ `Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ )
+ }
+}
+```
+
+#### Login from the React Client Component
+
+```tsx
+'use client'
+import { useState } from 'react'
+import { loginAction } from '../loginAction'
+
+export default function LoginForm() {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+
+ return (
+
+ )
+}
+```
+
+### `logout`
+
+Logs out the current user by clearing the authentication cookie.
+
+#### Importing the `logout` function
+
+```ts
+import { logout } from '@payloadcms/next/auth'
+```
+
+Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below.
+
+```ts
+'use server'
+
+import { logout } from '@payloadcms/next/auth'
+import config from '@payload-config'
+
+export async function logoutAction() {
+ try {
+ return await logout({ config })
+ } catch (error) {
+ throw new Error(
+ `Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ )
+ }
+}
+```
+
+#### Logout from the React Client Component
+
+```tsx
+'use client'
+import { logoutAction } from '../logoutAction'
+
+export default function LogoutButton() {
+ return
+}
+```
+
+### `refresh`
+
+Refreshes the authentication token for the logged-in user.
+
+#### Importing the `refresh` function
+
+```ts
+import { refresh } from '@payloadcms/next/auth'
+```
+
+As with login and logout, you need to pass your Payload config to this function. Create a helper server function like the one below. Passing the config directly to the client is not possible and will throw errors.
+
+```ts
+'use server'
+
+import { refresh } from '@payloadcms/next/auth'
+import config from '@payload-config'
+
+export async function refreshAction() {
+ try {
+ return await refresh({
+ collection: 'users', // update this to your collection slug
+ config,
+ })
+ } catch (error) {
+ throw new Error(
+ `Refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ )
+ }
+}
+```
+
+#### Using Refresh from the React Client Component
+
+```tsx
+'use client'
+import { refreshAction } from '../actions/refreshAction'
+
+export default function RefreshTokenButton() {
+ return
+}
+```
## Error Handling in Server Functions
diff --git a/packages/next/package.json b/packages/next/package.json
index 73d085cef5..b93b208a9a 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -37,6 +37,11 @@
"types": "./src/exports/routes.ts",
"default": "./src/exports/routes.ts"
},
+ "./auth": {
+ "import": "./src/exports/auth.ts",
+ "types": "./src/exports/auth.ts",
+ "default": "./src/exports/auth.ts"
+ },
"./templates": {
"import": "./src/exports/templates.ts",
"types": "./src/exports/templates.ts",
diff --git a/packages/next/src/auth/login.ts b/packages/next/src/auth/login.ts
new file mode 100644
index 0000000000..c85cd31dd4
--- /dev/null
+++ b/packages/next/src/auth/login.ts
@@ -0,0 +1,87 @@
+'use server'
+
+import type { CollectionSlug } from 'payload'
+
+import { cookies as getCookies } from 'next/headers.js'
+import { generatePayloadCookie, getPayload } from 'payload'
+
+import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
+
+type LoginWithEmail = {
+ collection: CollectionSlug
+ config: any
+ email: string
+ password: string
+ username?: never
+}
+
+type LoginWithUsername = {
+ collection: CollectionSlug
+ config: any
+ email?: never
+ password: string
+ username: string
+}
+type LoginArgs = LoginWithEmail | LoginWithUsername
+
+export async function login({ collection, config, email, password, username }: LoginArgs): Promise<{
+ token?: string
+ user: any
+}> {
+ const payload = await getPayload({ config })
+
+ const authConfig = payload.collections[collection]?.config.auth
+ if (!authConfig) {
+ throw new Error(`No auth config found for collection: ${collection}`)
+ }
+
+ const loginWithUsername = authConfig?.loginWithUsername ?? false
+
+ if (loginWithUsername) {
+ if (loginWithUsername.allowEmailLogin) {
+ if (!email && !username) {
+ throw new Error('Email or username is required.')
+ }
+ } else {
+ if (!username) {
+ throw new Error('Username is required.')
+ }
+ }
+ } else {
+ if (!email) {
+ throw new Error('Email is required.')
+ }
+ }
+
+ let loginData
+
+ if (loginWithUsername) {
+ loginData = username ? { password, username } : { email, password }
+ } else {
+ loginData = { email, password }
+ }
+
+ try {
+ const result = await payload.login({
+ collection,
+ data: loginData,
+ })
+
+ if (result.token) {
+ await setPayloadAuthCookie({
+ authConfig,
+ cookiePrefix: payload.config.cookiePrefix,
+ token: result.token,
+ })
+ }
+
+ if ('removeTokenFromResponses' in config && config.removeTokenFromResponses) {
+ delete result.token
+ }
+
+ return result
+ } catch (e) {
+ console.error('Login error:', e)
+ throw new Error(`${e}`)
+ }
+}
diff --git a/packages/next/src/auth/logout.ts b/packages/next/src/auth/logout.ts
new file mode 100644
index 0000000000..fc220fb88e
--- /dev/null
+++ b/packages/next/src/auth/logout.ts
@@ -0,0 +1,29 @@
+'use server'
+
+import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
+import { getPayload } from 'payload'
+
+import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
+
+export async function logout({ config }: { config: any }) {
+ try {
+ const payload = await getPayload({ config })
+ const headers = await nextHeaders()
+ const result = await payload.auth({ headers })
+
+ if (!result.user) {
+ return { message: 'User already logged out', success: true }
+ }
+
+ const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
+
+ if (existingCookie) {
+ const cookies = await getCookies()
+ cookies.delete(existingCookie.name)
+ return { message: 'User logged out successfully', success: true }
+ }
+ } catch (e) {
+ console.error('Logout error:', e)
+ throw new Error(`${e}`)
+ }
+}
diff --git a/packages/next/src/auth/refresh.ts b/packages/next/src/auth/refresh.ts
new file mode 100644
index 0000000000..920a4c558d
--- /dev/null
+++ b/packages/next/src/auth/refresh.ts
@@ -0,0 +1,42 @@
+'use server'
+
+import type { CollectionSlug } from 'payload'
+
+import { headers as nextHeaders } from 'next/headers.js'
+import { getPayload } from 'payload'
+
+import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
+import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
+
+export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) {
+ try {
+ const payload = await getPayload({ config })
+ const authConfig = payload.collections[collection]?.config.auth
+
+ if (!authConfig) {
+ throw new Error(`No auth config found for collection: ${collection}`)
+ }
+
+ const { user } = await payload.auth({ headers: await nextHeaders() })
+ if (!user) {
+ throw new Error('User not authenticated')
+ }
+
+ const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
+
+ if (!existingCookie) {
+ return { message: 'No valid token found', success: false }
+ }
+
+ await setPayloadAuthCookie({
+ authConfig,
+ cookiePrefix: payload.config.cookiePrefix,
+ token: existingCookie.value,
+ })
+
+ return { message: 'Token refreshed successfully', success: true }
+ } catch (e) {
+ console.error('Refresh error:', e)
+ throw new Error(`${e}`)
+ }
+}
diff --git a/packages/next/src/exports/auth.ts b/packages/next/src/exports/auth.ts
new file mode 100644
index 0000000000..6f6d9f8b1d
--- /dev/null
+++ b/packages/next/src/exports/auth.ts
@@ -0,0 +1,3 @@
+export { login } from '../auth/login.js'
+export { logout } from '../auth/logout.js'
+export { refresh } from '../auth/refresh.js'
diff --git a/packages/next/src/utilities/getExistingAuthToken.ts b/packages/next/src/utilities/getExistingAuthToken.ts
new file mode 100644
index 0000000000..a88db01bf0
--- /dev/null
+++ b/packages/next/src/utilities/getExistingAuthToken.ts
@@ -0,0 +1,10 @@
+import { cookies as getCookies } from 'next/headers.js'
+
+type Cookie = {
+ name: string
+ value: string
+}
+export async function getExistingAuthToken(cookiePrefix: string): Promise {
+ const cookies = await getCookies()
+ return cookies.getAll().find((cookie) => cookie.name.startsWith(cookiePrefix))
+}
diff --git a/packages/next/src/utilities/setPayloadAuthCookie.ts b/packages/next/src/utilities/setPayloadAuthCookie.ts
new file mode 100644
index 0000000000..4a3a9e0621
--- /dev/null
+++ b/packages/next/src/utilities/setPayloadAuthCookie.ts
@@ -0,0 +1,42 @@
+import type { Auth } from 'payload'
+
+import { cookies as getCookies } from 'next/headers.js'
+import { generatePayloadCookie } from 'payload'
+
+type SetPayloadAuthCookieArgs = {
+ authConfig: Auth
+ cookiePrefix: string
+ token: string
+}
+
+export async function setPayloadAuthCookie({
+ authConfig,
+ cookiePrefix,
+ token,
+}: SetPayloadAuthCookieArgs): Promise {
+ const cookies = await getCookies()
+
+ const cookieExpiration = authConfig.tokenExpiration
+ ? new Date(Date.now() + authConfig.tokenExpiration)
+ : undefined
+
+ const payloadCookie = generatePayloadCookie({
+ collectionAuthConfig: authConfig,
+ cookiePrefix,
+ expires: cookieExpiration,
+ returnCookieAsObject: true,
+ token,
+ })
+
+ if (payloadCookie.value) {
+ cookies.set(payloadCookie.name, payloadCookie.value, {
+ domain: authConfig.cookies.domain,
+ expires: payloadCookie.expires ? new Date(payloadCookie.expires) : undefined,
+ httpOnly: true,
+ sameSite: (typeof authConfig.cookies.sameSite === 'string'
+ ? authConfig.cookies.sameSite.toLowerCase()
+ : 'lax') as 'lax' | 'none' | 'strict',
+ secure: authConfig.cookies.secure || false,
+ })
+ }
+}
diff --git a/test/server-functions/components/login.tsx b/test/server-functions/components/login.tsx
new file mode 100644
index 0000000000..bb2663d2e2
--- /dev/null
+++ b/test/server-functions/components/login.tsx
@@ -0,0 +1,36 @@
+'use client'
+
+import { type ChangeEvent, type FormEvent, useState } from 'react'
+
+import { loginFunction } from './loginFunction.js'
+
+const LoginForm = () => {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+
+ return (
+
+ )
+}
+
+export default LoginForm
diff --git a/test/server-functions/components/loginFunction.tsx b/test/server-functions/components/loginFunction.tsx
new file mode 100644
index 0000000000..6c439c1c9e
--- /dev/null
+++ b/test/server-functions/components/loginFunction.tsx
@@ -0,0 +1,23 @@
+'use server'
+import { login } from '@payloadcms/next/auth'
+
+import config from '../config.js'
+
+type LoginArgs = {
+ email: string
+ password: string
+}
+
+export async function loginFunction({ email, password }: LoginArgs) {
+ try {
+ const result = await login({
+ collection: 'users',
+ config,
+ email,
+ password,
+ })
+ return result
+ } catch (error) {
+ throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+}
diff --git a/test/server-functions/components/logout.tsx b/test/server-functions/components/logout.tsx
new file mode 100644
index 0000000000..9af29bf0f6
--- /dev/null
+++ b/test/server-functions/components/logout.tsx
@@ -0,0 +1,8 @@
+'use client'
+
+import { logoutFunction } from './logoutFunction.js'
+
+const LogoutButton = () => {
+ return
+}
+export default LogoutButton
diff --git a/test/server-functions/components/logoutFunction.tsx b/test/server-functions/components/logoutFunction.tsx
new file mode 100644
index 0000000000..85e8ae3c96
--- /dev/null
+++ b/test/server-functions/components/logoutFunction.tsx
@@ -0,0 +1,14 @@
+'use server'
+import { logout } from '@payloadcms/next/auth'
+
+import config from '../config.js'
+
+export async function logoutFunction() {
+ try {
+ return await logout({
+ config,
+ })
+ } catch (error) {
+ throw new Error(`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+}
diff --git a/test/server-functions/components/refresh.tsx b/test/server-functions/components/refresh.tsx
new file mode 100644
index 0000000000..08286b08cb
--- /dev/null
+++ b/test/server-functions/components/refresh.tsx
@@ -0,0 +1,7 @@
+'use client'
+import { refreshFunction } from './refreshFunction.js'
+
+const RefreshToken = () => {
+ return
+}
+export default RefreshToken
diff --git a/test/server-functions/components/refreshFunction.tsx b/test/server-functions/components/refreshFunction.tsx
new file mode 100644
index 0000000000..ed830bc5c1
--- /dev/null
+++ b/test/server-functions/components/refreshFunction.tsx
@@ -0,0 +1,16 @@
+'use server'
+
+import { refresh } from '@payloadcms/next/auth'
+
+import config from '../config.js'
+
+export async function refreshFunction() {
+ try {
+ return await refresh({
+ collection: 'users', // update this to your collection slug
+ config,
+ })
+ } catch (error) {
+ throw new Error(`Refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+}
diff --git a/test/server-functions/config.ts b/test/server-functions/config.ts
new file mode 100644
index 0000000000..f5e5c1c0d3
--- /dev/null
+++ b/test/server-functions/config.ts
@@ -0,0 +1,34 @@
+import { fileURLToPath } from 'node:url'
+import path from 'path'
+
+import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
+import { devUser } from '../credentials.js'
+
+const filename = fileURLToPath(import.meta.url)
+const dirname = path.dirname(filename)
+
+export default buildConfigWithDefaults({
+ collections: [],
+ admin: {
+ autoLogin: false,
+ components: {
+ beforeLogin: ['/components/login.js'],
+ header: ['/components/refresh.js', '/components/logout.js'],
+ },
+ importMap: {
+ baseDir: path.resolve(dirname),
+ },
+ },
+ onInit: async (payload) => {
+ await payload.create({
+ collection: 'users',
+ data: {
+ email: devUser.email,
+ password: devUser.password,
+ },
+ })
+ },
+ typescript: {
+ outputFile: path.resolve(dirname, 'payload-types.ts'),
+ },
+})
diff --git a/test/server-functions/e2e.spec.ts b/test/server-functions/e2e.spec.ts
new file mode 100644
index 0000000000..1c05657c94
--- /dev/null
+++ b/test/server-functions/e2e.spec.ts
@@ -0,0 +1,97 @@
+import type { Page } from '@playwright/test'
+
+import { expect, test } from '@playwright/test'
+import { devUser } from 'credentials.js'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+import type { PayloadTestSDK } from '../helpers/sdk/index.js'
+import type { Config } from './payload-types.js'
+
+import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
+import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
+import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
+import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
+
+const filename = fileURLToPath(import.meta.url)
+const dirname = path.dirname(filename)
+
+let payload: PayloadTestSDK
+
+const { beforeAll, describe } = test
+
+describe('Server Functions', () => {
+ let page: Page
+ let url: AdminUrlUtil
+ let serverURL: string
+
+ beforeAll(async ({ browser }, testInfo) => {
+ testInfo.setTimeout(TEST_TIMEOUT_LONG)
+ ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
+ url = new AdminUrlUtil(serverURL, 'users')
+
+ const context = await browser.newContext()
+ page = await context.newPage()
+ initPageConsoleErrorCatch(page)
+
+ await ensureCompilationIsDone({
+ page,
+ serverURL,
+ noAutoLogin: true,
+ })
+ })
+
+ describe('Auth functions', () => {
+ test('should log user in from login server function', async () => {
+ await page.goto(`${serverURL}/admin`)
+
+ // Expect email and password fields to be visible
+ await expect(page.locator('#email')).toBeVisible()
+ await expect(page.locator('#password')).toBeVisible()
+
+ await page.fill('#email', devUser.email)
+ await page.fill('#password', devUser.password)
+
+ const loginButton = page.locator('text=Custom Login')
+ await expect(loginButton).toBeVisible()
+ await loginButton.click()
+ await page.waitForTimeout(1000)
+
+ await page.reload()
+ await page.goto(`${serverURL}/admin/account`)
+ await expect(page.locator('h1[title="dev@payloadcms.com"]')).toBeVisible()
+ })
+
+ test('should refresh user from refresh server function', async () => {
+ await page.goto(`${serverURL}/admin`)
+
+ const initialCookie = await page.context().cookies()
+ const payloadToken = initialCookie.find((cookie) => cookie.name === 'payload-token')
+ expect(payloadToken).toBeDefined()
+ const initialExpiry = payloadToken?.expires
+
+ const refreshButton = page.locator('text=Custom Refresh')
+ await expect(refreshButton).toBeVisible()
+ await refreshButton.click()
+ await page.waitForTimeout(1000)
+
+ const updatedCookie = await page.context().cookies()
+ const updatedPayloadToken = updatedCookie.find((cookie) => cookie.name === 'payload-token')
+ expect(updatedPayloadToken).toBeDefined()
+ expect(updatedPayloadToken?.expires).not.toBe(initialExpiry)
+ })
+
+ test('should log user out from logout server function', async () => {
+ await page.goto(`${serverURL}/admin`)
+ const logoutButton = page.locator('text=Custom Logout')
+ await expect(logoutButton).toBeVisible()
+ await logoutButton.click()
+ await page.waitForTimeout(1000)
+
+ await page.reload()
+ await page.goto(`${serverURL}/admin`)
+ await expect(page.locator('#email')).toBeVisible()
+ await expect(page.locator('#password')).toBeVisible()
+ })
+ })
+})
diff --git a/test/server-functions/eslint.config.js b/test/server-functions/eslint.config.js
new file mode 100644
index 0000000000..f295df083f
--- /dev/null
+++ b/test/server-functions/eslint.config.js
@@ -0,0 +1,19 @@
+import { rootParserOptions } from '../../eslint.config.js'
+import { testEslintConfig } from '../eslint.config.js'
+
+/** @typedef {import('eslint').Linter.Config} Config */
+
+/** @type {Config[]} */
+export const index = [
+ ...testEslintConfig,
+ {
+ languageOptions: {
+ parserOptions: {
+ ...rootParserOptions,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ },
+]
+
+export default index
diff --git a/test/server-functions/payload-types.ts b/test/server-functions/payload-types.ts
new file mode 100644
index 0000000000..60c5bb7ab4
--- /dev/null
+++ b/test/server-functions/payload-types.ts
@@ -0,0 +1,242 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * This file was automatically generated by Payload.
+ * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
+ * and re-run `payload generate:types` to regenerate this file.
+ */
+
+/**
+ * Supported timezones in IANA format.
+ *
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "supportedTimezones".
+ */
+export type SupportedTimezones =
+ | 'Pacific/Midway'
+ | 'Pacific/Niue'
+ | 'Pacific/Honolulu'
+ | 'Pacific/Rarotonga'
+ | 'America/Anchorage'
+ | 'Pacific/Gambier'
+ | 'America/Los_Angeles'
+ | 'America/Tijuana'
+ | 'America/Denver'
+ | 'America/Phoenix'
+ | 'America/Chicago'
+ | 'America/Guatemala'
+ | 'America/New_York'
+ | 'America/Bogota'
+ | 'America/Caracas'
+ | 'America/Santiago'
+ | 'America/Buenos_Aires'
+ | 'America/Sao_Paulo'
+ | 'Atlantic/South_Georgia'
+ | 'Atlantic/Azores'
+ | 'Atlantic/Cape_Verde'
+ | 'Europe/London'
+ | 'Europe/Berlin'
+ | 'Africa/Lagos'
+ | 'Europe/Athens'
+ | 'Africa/Cairo'
+ | 'Europe/Moscow'
+ | 'Asia/Riyadh'
+ | 'Asia/Dubai'
+ | 'Asia/Baku'
+ | 'Asia/Karachi'
+ | 'Asia/Tashkent'
+ | 'Asia/Calcutta'
+ | 'Asia/Dhaka'
+ | 'Asia/Almaty'
+ | 'Asia/Jakarta'
+ | 'Asia/Bangkok'
+ | 'Asia/Shanghai'
+ | 'Asia/Singapore'
+ | 'Asia/Tokyo'
+ | 'Asia/Seoul'
+ | 'Australia/Brisbane'
+ | 'Australia/Sydney'
+ | 'Pacific/Guam'
+ | 'Pacific/Noumea'
+ | 'Pacific/Auckland'
+ | 'Pacific/Fiji';
+
+export interface Config {
+ auth: {
+ users: UserAuthOperations;
+ };
+ blocks: {};
+ collections: {
+ users: User;
+ 'payload-locked-documents': PayloadLockedDocument;
+ 'payload-preferences': PayloadPreference;
+ 'payload-migrations': PayloadMigration;
+ };
+ collectionsJoins: {};
+ collectionsSelect: {
+ users: UsersSelect | UsersSelect;
+ 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect;
+ 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect;
+ 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect;
+ };
+ db: {
+ defaultIDType: string;
+ };
+ globals: {};
+ globalsSelect: {};
+ locale: null;
+ user: User & {
+ collection: 'users';
+ };
+ jobs: {
+ tasks: unknown;
+ workflows: unknown;
+ };
+}
+export interface UserAuthOperations {
+ forgotPassword: {
+ email: string;
+ password: string;
+ };
+ login: {
+ email: string;
+ password: string;
+ };
+ registerFirstUser: {
+ email: string;
+ password: string;
+ };
+ unlock: {
+ email: string;
+ password: string;
+ };
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "users".
+ */
+export interface User {
+ id: string;
+ updatedAt: string;
+ createdAt: string;
+ email: string;
+ resetPasswordToken?: string | null;
+ resetPasswordExpiration?: string | null;
+ salt?: string | null;
+ hash?: string | null;
+ loginAttempts?: number | null;
+ lockUntil?: string | null;
+ password?: string | null;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "payload-locked-documents".
+ */
+export interface PayloadLockedDocument {
+ id: string;
+ document?: {
+ relationTo: 'users';
+ value: string | User;
+ } | null;
+ globalSlug?: string | null;
+ user: {
+ relationTo: 'users';
+ value: string | User;
+ };
+ updatedAt: string;
+ createdAt: string;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "payload-preferences".
+ */
+export interface PayloadPreference {
+ id: string;
+ user: {
+ relationTo: 'users';
+ value: string | User;
+ };
+ key?: string | null;
+ value?:
+ | {
+ [k: string]: unknown;
+ }
+ | unknown[]
+ | string
+ | number
+ | boolean
+ | null;
+ updatedAt: string;
+ createdAt: string;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "payload-migrations".
+ */
+export interface PayloadMigration {
+ id: string;
+ name?: string | null;
+ batch?: number | null;
+ updatedAt: string;
+ createdAt: string;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "users_select".
+ */
+export interface UsersSelect {
+ updatedAt?: T;
+ createdAt?: T;
+ email?: T;
+ resetPasswordToken?: T;
+ resetPasswordExpiration?: T;
+ salt?: T;
+ hash?: T;
+ loginAttempts?: T;
+ lockUntil?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "payload-locked-documents_select".
+ */
+export interface PayloadLockedDocumentsSelect {
+ document?: T;
+ globalSlug?: T;
+ user?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "payload-preferences_select".
+ */
+export interface PayloadPreferencesSelect {
+ user?: T;
+ key?: T;
+ value?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "payload-migrations_select".
+ */
+export interface PayloadMigrationsSelect {
+ name?: T;
+ batch?: T;
+ updatedAt?: T;
+ createdAt?: T;
+}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "auth".
+ */
+export interface Auth {
+ [k: string]: unknown;
+}
+
+
+declare module 'payload' {
+ // @ts-ignore
+ export interface GeneratedTypes extends Config {}
+}
\ No newline at end of file
diff --git a/test/server-functions/tsconfig.eslint.json b/test/server-functions/tsconfig.eslint.json
new file mode 100644
index 0000000000..b34cc7afbb
--- /dev/null
+++ b/test/server-functions/tsconfig.eslint.json
@@ -0,0 +1,13 @@
+{
+ // extend your base config to share compilerOptions, etc
+ //"extends": "./tsconfig.json",
+ "compilerOptions": {
+ // ensure that nobody can accidentally use this config for a build
+ "noEmit": true
+ },
+ "include": [
+ // whatever paths you intend to lint
+ "./**/*.ts",
+ "./**/*.tsx"
+ ]
+}
diff --git a/test/server-functions/tsconfig.json b/test/server-functions/tsconfig.json
new file mode 100644
index 0000000000..e05dc9ac67
--- /dev/null
+++ b/test/server-functions/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@payload-config": ["./config.js"]
+ }
+ }
+}