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.
This commit is contained in:
Jessica Chowdhury
2025-04-14 09:47:08 +01:00
committed by GitHub
parent 39462bc6b9
commit 6b349378e0
21 changed files with 922 additions and 5 deletions

View File

@@ -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<string>('')
const [password, setPassword] = useState<string>('')
return (
<form onSubmit={() => loginFunction({ email, password })}>
<label htmlFor="email">Email</label>
<input
id="email"
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
placeholder="Email"
required
type="email"
value={email}
/>
<label htmlFor="password">Password</label>
<input
id="password"
onChange={(e: ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
placeholder="Password"
required
type="password"
value={password}
/>
<button type="submit">Custom Login</button>
</form>
)
}
export default LoginForm

View File

@@ -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'}`)
}
}

View File

@@ -0,0 +1,8 @@
'use client'
import { logoutFunction } from './logoutFunction.js'
const LogoutButton = () => {
return <button onClick={() => logoutFunction()}>Custom Logout</button>
}
export default LogoutButton

View File

@@ -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'}`)
}
}

View File

@@ -0,0 +1,7 @@
'use client'
import { refreshFunction } from './refreshFunction.js'
const RefreshToken = () => {
return <button onClick={() => refreshFunction()}>Custom Refresh</button>
}
export default RefreshToken

View File

@@ -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'}`)
}
}

View File

@@ -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'),
},
})

View File

@@ -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<Config>
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<Config>({ 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()
})
})
})

View File

@@ -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

View File

@@ -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<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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 {}
}

View File

@@ -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"
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"@payload-config": ["./config.js"]
}
}
}