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:
committed by
GitHub
parent
39462bc6b9
commit
6b349378e0
@@ -158,7 +158,7 @@ mutation {
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const result = await payload.login({
|
const result = await payload.login({
|
||||||
collection: '[collection-slug]',
|
collection: 'collection-slug',
|
||||||
data: {
|
data: {
|
||||||
email: 'dev@payloadcms.com',
|
email: 'dev@payloadcms.com',
|
||||||
password: 'get-out',
|
password: 'get-out',
|
||||||
@@ -166,6 +166,13 @@ const result = await payload.login({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<Banner type="success">
|
||||||
|
**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).
|
||||||
|
</Banner>
|
||||||
|
|
||||||
## Logout
|
## 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.
|
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 {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<Banner type="success">
|
||||||
|
**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).
|
||||||
|
</Banner>
|
||||||
|
|
||||||
## Refresh
|
## 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.
|
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 {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<Banner type="success">
|
||||||
|
**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).
|
||||||
|
</Banner>
|
||||||
|
|
||||||
## Verify by Email
|
## 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.
|
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
|
```ts
|
||||||
const result = await payload.verifyEmail({
|
const result = await payload.verifyEmail({
|
||||||
collection: '[collection-slug]',
|
collection: 'collection-slug',
|
||||||
token: 'TOKEN_HERE',
|
token: 'TOKEN_HERE',
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -308,7 +329,7 @@ mutation {
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const result = await payload.unlock({
|
const result = await payload.unlock({
|
||||||
collection: '[collection-slug]',
|
collection: 'collection-slug',
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -349,7 +370,7 @@ mutation {
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const token = await payload.forgotPassword({
|
const token = await payload.forgotPassword({
|
||||||
collection: '[collection-slug]',
|
collection: 'collection-slug',
|
||||||
data: {
|
data: {
|
||||||
email: 'dev@payloadcms.com',
|
email: 'dev@payloadcms.com',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -310,7 +310,168 @@ export const PostForm: React.FC = () => {
|
|||||||
|
|
||||||
## Reusable Payload Server Functions
|
## 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<string>('')
|
||||||
|
const [password, setPassword] = useState<string>('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={() => loginAction({ email, password })}>
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setEmail(e.target.value)
|
||||||
|
}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
/>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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 <button onClick={() => logoutFunction()}>Logout</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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 <button onClick={() => refreshFunction()}>Refresh</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Error Handling in Server Functions
|
## Error Handling in Server Functions
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
"types": "./src/exports/routes.ts",
|
"types": "./src/exports/routes.ts",
|
||||||
"default": "./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": {
|
"./templates": {
|
||||||
"import": "./src/exports/templates.ts",
|
"import": "./src/exports/templates.ts",
|
||||||
"types": "./src/exports/templates.ts",
|
"types": "./src/exports/templates.ts",
|
||||||
|
|||||||
87
packages/next/src/auth/login.ts
Normal file
87
packages/next/src/auth/login.ts
Normal file
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/next/src/auth/logout.ts
Normal file
29
packages/next/src/auth/logout.ts
Normal file
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/next/src/auth/refresh.ts
Normal file
42
packages/next/src/auth/refresh.ts
Normal file
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/next/src/exports/auth.ts
Normal file
3
packages/next/src/exports/auth.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { login } from '../auth/login.js'
|
||||||
|
export { logout } from '../auth/logout.js'
|
||||||
|
export { refresh } from '../auth/refresh.js'
|
||||||
10
packages/next/src/utilities/getExistingAuthToken.ts
Normal file
10
packages/next/src/utilities/getExistingAuthToken.ts
Normal file
@@ -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<Cookie | undefined> {
|
||||||
|
const cookies = await getCookies()
|
||||||
|
return cookies.getAll().find((cookie) => cookie.name.startsWith(cookiePrefix))
|
||||||
|
}
|
||||||
42
packages/next/src/utilities/setPayloadAuthCookie.ts
Normal file
42
packages/next/src/utilities/setPayloadAuthCookie.ts
Normal file
@@ -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<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
36
test/server-functions/components/login.tsx
Normal file
36
test/server-functions/components/login.tsx
Normal 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
|
||||||
23
test/server-functions/components/loginFunction.tsx
Normal file
23
test/server-functions/components/loginFunction.tsx
Normal 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'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
test/server-functions/components/logout.tsx
Normal file
8
test/server-functions/components/logout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { logoutFunction } from './logoutFunction.js'
|
||||||
|
|
||||||
|
const LogoutButton = () => {
|
||||||
|
return <button onClick={() => logoutFunction()}>Custom Logout</button>
|
||||||
|
}
|
||||||
|
export default LogoutButton
|
||||||
14
test/server-functions/components/logoutFunction.tsx
Normal file
14
test/server-functions/components/logoutFunction.tsx
Normal 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'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
test/server-functions/components/refresh.tsx
Normal file
7
test/server-functions/components/refresh.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
import { refreshFunction } from './refreshFunction.js'
|
||||||
|
|
||||||
|
const RefreshToken = () => {
|
||||||
|
return <button onClick={() => refreshFunction()}>Custom Refresh</button>
|
||||||
|
}
|
||||||
|
export default RefreshToken
|
||||||
16
test/server-functions/components/refreshFunction.tsx
Normal file
16
test/server-functions/components/refreshFunction.tsx
Normal 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'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
test/server-functions/config.ts
Normal file
34
test/server-functions/config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
})
|
||||||
97
test/server-functions/e2e.spec.ts
Normal file
97
test/server-functions/e2e.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
19
test/server-functions/eslint.config.js
Normal file
19
test/server-functions/eslint.config.js
Normal 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
|
||||||
242
test/server-functions/payload-types.ts
Normal file
242
test/server-functions/payload-types.ts
Normal 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 {}
|
||||||
|
}
|
||||||
13
test/server-functions/tsconfig.eslint.json
Normal file
13
test/server-functions/tsconfig.eslint.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
8
test/server-functions/tsconfig.json
Normal file
8
test/server-functions/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@payload-config": ["./config.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user