feat: auth sessions (#12483)
Adds full session functionality into Payload's existing local authentication strategy. It's enabled by default, because this is a more secure pattern that we should enforce. However, we have provided an opt-out pattern for those that want to stick to stateless JWT authentication by passing `collectionConfig.auth.useSessions: false`. Todo: - [x] @jessrynkar to update the Next.js server functions for refresh and logout to support these new features - [x] @jessrynkar resolve build errors --------- Co-authored-by: Elliot DeNolf <denolfe@gmail.com> Co-authored-by: Jessica Chowdhury <jessica@trbl.design> Co-authored-by: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
This commit is contained in:
@@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a
|
||||
**Example REST API logout**:
|
||||
|
||||
```ts
|
||||
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', {
|
||||
const res = await fetch(
|
||||
'http://localhost:3000/api/[collection-slug]/logout?allSessions=false',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Example GraphQL Mutation**:
|
||||
|
||||
```
|
||||
mutation {
|
||||
logout[collection-singular-label]
|
||||
logoutUser(allSessions: false)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -203,6 +206,10 @@ mutation {
|
||||
docs](../local-api/server-functions#reusable-payload-server-functions).
|
||||
</Banner>
|
||||
|
||||
#### Logging out with sessions enabled
|
||||
|
||||
By default, logging out will only end the session pertaining to the JWT that was used to log out with. However, you can pass `allSessions: true` to the logout operation in order to end all sessions for the user logging out.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -91,6 +91,7 @@ The following options are available:
|
||||
| **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). |
|
||||
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
|
||||
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |
|
||||
| **`useSessions`** | True by default. Set to `false` to use stateless JWTs for authentication instead of sessions. |
|
||||
| **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). |
|
||||
|
||||
### Login With Username
|
||||
|
||||
@@ -393,7 +393,7 @@ export default function LoginForm() {
|
||||
|
||||
### Logout
|
||||
|
||||
Logs out the current user by clearing the authentication cookie.
|
||||
Logs out the current user by clearing the authentication cookie and current sessions.
|
||||
|
||||
#### Importing the `logout` function
|
||||
|
||||
@@ -401,7 +401,7 @@ Logs out the current user by clearing the authentication cookie.
|
||||
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.
|
||||
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. To ensure all sessions are cleared, set `allSessions: true` in the options, if you wish to logout but keep current sessions active, you can set this to `false` or leave it `undefined`.
|
||||
|
||||
```ts
|
||||
'use server'
|
||||
@@ -411,7 +411,7 @@ import config from '@payload-config'
|
||||
|
||||
export async function logoutAction() {
|
||||
try {
|
||||
return await logout({ config })
|
||||
return await logout({ allSessions: true, config })
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
@@ -434,7 +434,7 @@ export default function LogoutButton() {
|
||||
|
||||
### Refresh
|
||||
|
||||
Refreshes the authentication token for the logged-in user.
|
||||
Refreshes the authentication token and current session for the logged-in user.
|
||||
|
||||
#### Importing the `refresh` function
|
||||
|
||||
@@ -453,7 +453,6 @@ import config from '@payload-config'
|
||||
export async function refreshAction() {
|
||||
try {
|
||||
return await refresh({
|
||||
collection: 'users', // pass your collection slug
|
||||
config,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -74,9 +74,7 @@ import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const config = buildConfig({
|
||||
collections: [Pages, Media],
|
||||
plugins: [
|
||||
sentryPlugin({ Sentry })
|
||||
],
|
||||
plugins: [sentryPlugin({ Sentry })],
|
||||
})
|
||||
|
||||
export default config
|
||||
@@ -98,9 +96,7 @@ export default buildConfig({
|
||||
pool: { connectionString: process.env.DATABASE_URL },
|
||||
pg, // Inject the patched pg driver for Sentry instrumentation
|
||||
}),
|
||||
plugins: [
|
||||
sentryPlugin({ Sentry })
|
||||
],
|
||||
plugins: [sentryPlugin({ Sentry })],
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Context } from '../types.js'
|
||||
export function logout(collection: Collection): any {
|
||||
async function resolver(_, args, context: Context) {
|
||||
const options = {
|
||||
allSessions: args.allSessions,
|
||||
collection,
|
||||
req: isolateObjectProperty(context.req, 'transactionID'),
|
||||
}
|
||||
|
||||
@@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
|
||||
graphqlResult.Mutation.fields[`logout${singularName}`] = {
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
allSessions: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: logout(collection),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,46 @@
|
||||
'use server'
|
||||
|
||||
import type { SanitizedConfig } from 'payload'
|
||||
|
||||
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
|
||||
import { getPayload } from 'payload'
|
||||
import { createLocalReq, getPayload, logoutOperation } from 'payload'
|
||||
|
||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||
|
||||
export async function logout({ config }: { config: any }) {
|
||||
export async function logout({
|
||||
allSessions = false,
|
||||
config,
|
||||
}: {
|
||||
allSessions?: boolean
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||
}) {
|
||||
const payload = await getPayload({ config })
|
||||
const headers = await nextHeaders()
|
||||
const result = await payload.auth({ headers })
|
||||
const authResult = await payload.auth({ headers })
|
||||
|
||||
if (!result.user) {
|
||||
if (!authResult.user) {
|
||||
return { message: 'User already logged out', success: true }
|
||||
}
|
||||
|
||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||
const { user } = authResult
|
||||
const req = await createLocalReq({ user }, payload)
|
||||
const collection = payload.collections[user.collection]
|
||||
|
||||
const logoutResult = await logoutOperation({
|
||||
allSessions,
|
||||
collection,
|
||||
req,
|
||||
})
|
||||
|
||||
if (!logoutResult) {
|
||||
return { message: 'Logout failed', success: false }
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
return { message: 'User logged out successfully', success: true }
|
||||
}
|
||||
|
||||
@@ -3,33 +3,45 @@
|
||||
import type { CollectionSlug } from 'payload'
|
||||
|
||||
import { headers as nextHeaders } from 'next/headers.js'
|
||||
import { getPayload } from 'payload'
|
||||
import { createLocalReq, getPayload, refreshOperation } from 'payload'
|
||||
|
||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
|
||||
|
||||
export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) {
|
||||
export async function refresh({ config }: { config: any }) {
|
||||
const payload = await getPayload({ config })
|
||||
const authConfig = payload.collections[collection]?.config.auth
|
||||
const headers = await nextHeaders()
|
||||
const result = await payload.auth({ headers })
|
||||
|
||||
if (!authConfig) {
|
||||
if (!result.user) {
|
||||
throw new Error('Cannot refresh token: user not authenticated')
|
||||
}
|
||||
|
||||
const collection: CollectionSlug | undefined = result.user.collection
|
||||
const collectionConfig = payload.collections[collection]
|
||||
|
||||
if (!collectionConfig?.config.auth) {
|
||||
throw new Error(`No auth config found for collection: ${collection}`)
|
||||
}
|
||||
|
||||
const { user } = await payload.auth({ headers: await nextHeaders() })
|
||||
const req = await createLocalReq({ user: result.user }, payload)
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not authenticated')
|
||||
const refreshResult = await refreshOperation({
|
||||
collection: collectionConfig,
|
||||
req,
|
||||
})
|
||||
|
||||
if (!refreshResult) {
|
||||
return { message: 'Token refresh failed', success: false }
|
||||
}
|
||||
|
||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||
|
||||
if (!existingCookie) {
|
||||
return { message: 'No valid token found', success: false }
|
||||
return { message: 'No valid token found to refresh', success: false }
|
||||
}
|
||||
|
||||
await setPayloadAuthCookie({
|
||||
authConfig,
|
||||
authConfig: collectionConfig.config.auth,
|
||||
cookiePrefix: payload.config.cookiePrefix,
|
||||
token: existingCookie.value,
|
||||
})
|
||||
|
||||
32
packages/payload/src/auth/baseFields/sessions.ts
Normal file
32
packages/payload/src/auth/baseFields/sessions.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ArrayField } from '../../fields/config/types.js'
|
||||
|
||||
export const sessionsFieldConfig: ArrayField = {
|
||||
name: 'sessions',
|
||||
type: 'array',
|
||||
access: {
|
||||
read: ({ doc, req: { user } }) => {
|
||||
return user?.id === doc?.id
|
||||
},
|
||||
update: () => false,
|
||||
},
|
||||
admin: {
|
||||
disabled: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
defaultValue: () => new Date(),
|
||||
},
|
||||
{
|
||||
name: 'expiresAt',
|
||||
type: 'date',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import { logoutOperation } from '../operations/logout.js'
|
||||
|
||||
export const logoutHandler: PayloadHandler = async (req) => {
|
||||
const collection = getRequestCollection(req)
|
||||
const { t } = req
|
||||
const { searchParams, t } = req
|
||||
|
||||
const result = await logoutOperation({
|
||||
allSessions: searchParams.get('allSessions') === 'true',
|
||||
collection,
|
||||
req,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { accountLockFields } from './baseFields/accountLock.js'
|
||||
import { apiKeyFields } from './baseFields/apiKey.js'
|
||||
import { baseAuthFields } from './baseFields/auth.js'
|
||||
import { emailFieldConfig } from './baseFields/email.js'
|
||||
import { sessionsFieldConfig } from './baseFields/sessions.js'
|
||||
import { usernameFieldConfig } from './baseFields/username.js'
|
||||
import { verificationFields } from './baseFields/verification.js'
|
||||
|
||||
@@ -52,6 +53,10 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
|
||||
if (authConfig?.maxLoginAttempts && authConfig.maxLoginAttempts > 0) {
|
||||
authFields.push(...accountLockFields)
|
||||
}
|
||||
|
||||
if (authConfig.useSessions) {
|
||||
authFields.push(sessionsFieldConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return authFields
|
||||
|
||||
@@ -114,9 +114,10 @@ const traverseFields = ({
|
||||
export const getFieldsToSign = (args: {
|
||||
collectionConfig: CollectionConfig
|
||||
email: string
|
||||
sid?: string
|
||||
user: PayloadRequest['user']
|
||||
}): Record<string, unknown> => {
|
||||
const { collectionConfig, email, user } = args
|
||||
const { collectionConfig, email, sid, user } = args
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
id: user?.id,
|
||||
@@ -124,6 +125,10 @@ export const getFieldsToSign = (args: {
|
||||
email,
|
||||
}
|
||||
|
||||
if (sid) {
|
||||
result.sid = sid
|
||||
}
|
||||
|
||||
traverseFields({
|
||||
data: user!,
|
||||
fields: collectionConfig.fields,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type {
|
||||
AuthOperationsFromCollectionSlug,
|
||||
Collection,
|
||||
@@ -23,6 +25,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
|
||||
import { getLoginOptions } from '../getLoginOptions.js'
|
||||
import { isUserLocked } from '../isUserLocked.js'
|
||||
import { jwtSign } from '../jwt.js'
|
||||
import { removeExpiredSessions } from '../removeExpiredSessions.js'
|
||||
import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
|
||||
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js'
|
||||
import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js'
|
||||
@@ -114,7 +117,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
||||
// Login
|
||||
// /////////////////////////////////////
|
||||
|
||||
let user
|
||||
const { email: unsanitizedEmail, password } = data
|
||||
const loginWithUsername = collectionConfig.auth.loginWithUsername
|
||||
|
||||
@@ -204,7 +206,7 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
||||
whereConstraint = usernameConstraint
|
||||
}
|
||||
|
||||
user = await payload.db.findOne<any>({
|
||||
let user = await payload.db.findOne<any>({
|
||||
collection: collectionConfig.slug,
|
||||
req,
|
||||
where: whereConstraint,
|
||||
@@ -239,6 +241,41 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
||||
throw new AuthenticationError(req.t)
|
||||
}
|
||||
|
||||
const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[0] = {
|
||||
collectionConfig,
|
||||
email: sanitizedEmail!,
|
||||
user,
|
||||
}
|
||||
|
||||
if (collectionConfig.auth.useSessions) {
|
||||
// Add session to user
|
||||
const newSessionID = uuid()
|
||||
const now = new Date()
|
||||
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
|
||||
const expiresAt = new Date(now.getTime() + tokenExpInMs)
|
||||
|
||||
const session = { id: newSessionID, createdAt: now, expiresAt }
|
||||
|
||||
if (!user.sessions?.length) {
|
||||
user.sessions = [session]
|
||||
} else {
|
||||
user.sessions = removeExpiredSessions(user.sessions)
|
||||
user.sessions.push(session)
|
||||
}
|
||||
|
||||
await payload.db.updateOne({
|
||||
id: user.id,
|
||||
collection: collectionConfig.slug,
|
||||
data: user,
|
||||
req,
|
||||
returning: false,
|
||||
})
|
||||
|
||||
fieldsToSignArgs.sid = newSessionID
|
||||
}
|
||||
|
||||
const fieldsToSign = getFieldsToSign(fieldsToSignArgs)
|
||||
|
||||
if (maxLoginAttemptsEnabled) {
|
||||
await resetLoginAttempts({
|
||||
collection: collectionConfig,
|
||||
@@ -248,12 +285,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
||||
})
|
||||
}
|
||||
|
||||
const fieldsToSign = getFieldsToSign({
|
||||
collectionConfig,
|
||||
email: sanitizedEmail!,
|
||||
user,
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
// beforeLogin - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { PayloadRequest } from '../../types/index.js'
|
||||
import { APIError } from '../../errors/index.js'
|
||||
|
||||
export type Arguments = {
|
||||
allSessions?: boolean
|
||||
collection: Collection
|
||||
req: PayloadRequest
|
||||
}
|
||||
@@ -13,6 +14,7 @@ export type Arguments = {
|
||||
export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean> => {
|
||||
let args = incomingArgs
|
||||
const {
|
||||
allSessions,
|
||||
collection: { config: collectionConfig },
|
||||
req: { user },
|
||||
req,
|
||||
@@ -36,5 +38,41 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) {
|
||||
const userWithSessions = await req.payload.db.findOne<{
|
||||
id: number | string
|
||||
sessions: { id: string }[]
|
||||
}>({
|
||||
collection: collectionConfig.slug,
|
||||
req,
|
||||
where: {
|
||||
id: {
|
||||
equals: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!userWithSessions) {
|
||||
throw new APIError('No User', httpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
if (allSessions) {
|
||||
userWithSessions.sessions = []
|
||||
} else {
|
||||
const sessionsAfterLogout = (userWithSessions?.sessions || []).filter(
|
||||
(s) => s.id !== req?.user?._sid,
|
||||
)
|
||||
|
||||
userWithSessions.sessions = sessionsAfterLogout
|
||||
}
|
||||
|
||||
await req.payload.db.updateOne({
|
||||
id: user.id,
|
||||
collection: collectionConfig.slug,
|
||||
data: userWithSessions,
|
||||
returning: false,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import url from 'url'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type { Collection } from '../../collections/config/types.js'
|
||||
import type { Document, PayloadRequest } from '../../types/index.js'
|
||||
@@ -10,6 +11,7 @@ import { initTransaction } from '../../utilities/initTransaction.js'
|
||||
import { killTransaction } from '../../utilities/killTransaction.js'
|
||||
import { getFieldsToSign } from '../getFieldsToSign.js'
|
||||
import { jwtSign } from '../jwt.js'
|
||||
import { removeExpiredSessions } from '../removeExpiredSessions.js'
|
||||
|
||||
export type Result = {
|
||||
exp: number
|
||||
@@ -79,6 +81,31 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
||||
req: args.req,
|
||||
})
|
||||
|
||||
const sid = args.req.user._sid
|
||||
|
||||
if (collectionConfig.auth.useSessions && !collectionConfig.auth.disableLocalStrategy) {
|
||||
if (!Array.isArray(user.sessions) || !sid) {
|
||||
throw new Forbidden(args.req.t)
|
||||
}
|
||||
|
||||
const existingSession = user.sessions.find(({ id }) => id === sid)
|
||||
|
||||
const now = new Date()
|
||||
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
|
||||
existingSession.expiresAt = new Date(now.getTime() + tokenExpInMs)
|
||||
|
||||
await req.payload.db.updateOne({
|
||||
id: user.id,
|
||||
collection: collectionConfig.slug,
|
||||
data: {
|
||||
...user,
|
||||
sessions: removeExpiredSessions(user.sessions),
|
||||
},
|
||||
req,
|
||||
returning: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (user) {
|
||||
user.collection = args.req.user.collection
|
||||
user._strategy = args.req.user._strategy
|
||||
@@ -103,6 +130,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
||||
const fieldsToSign = getFieldsToSign({
|
||||
collectionConfig,
|
||||
email: user?.email as string,
|
||||
sid,
|
||||
user: args?.req?.user,
|
||||
})
|
||||
|
||||
|
||||
10
packages/payload/src/auth/removeExpiredSessions.ts
Normal file
10
packages/payload/src/auth/removeExpiredSessions.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { UserSession } from './types.js'
|
||||
|
||||
export const removeExpiredSessions = (sessions: UserSession[]) => {
|
||||
const now = new Date()
|
||||
|
||||
return sessions.filter(({ expiresAt }) => {
|
||||
const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)
|
||||
return expiry > now
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { extractJWT } from '../extractJWT.js'
|
||||
type JWTToken = {
|
||||
collection: string
|
||||
id: string
|
||||
sid?: string
|
||||
}
|
||||
|
||||
async function autoLogin({
|
||||
@@ -100,6 +101,18 @@ export const JWTAuthentication: AuthStrategyFunction = async ({
|
||||
})) as AuthStrategyResult['user']
|
||||
|
||||
if (user && (!collection!.config.auth.verify || user._verified)) {
|
||||
if (collection!.config.auth.useSessions) {
|
||||
const existingSession = (user.sessions || []).find(({ id }) => id === decodedPayload.sid)
|
||||
|
||||
if (!existingSession || !decodedPayload.sid) {
|
||||
return {
|
||||
user: null,
|
||||
}
|
||||
}
|
||||
|
||||
user._sid = decodedPayload.sid
|
||||
}
|
||||
|
||||
user.collection = collection!.config.slug
|
||||
user._strategy = strategyName
|
||||
return {
|
||||
|
||||
@@ -118,6 +118,7 @@ type BaseUser = {
|
||||
collection: string
|
||||
email?: string
|
||||
id: number | string
|
||||
sessions?: Array<UserSession>
|
||||
username?: string
|
||||
}
|
||||
|
||||
@@ -133,6 +134,7 @@ export type ClientUser = {
|
||||
[key: string]: any
|
||||
} & BaseUser
|
||||
|
||||
export type UserSession = { createdAt: Date | string; expiresAt: Date | string; id: string }
|
||||
type GenerateVerifyEmailHTML<TUser = any> = (args: {
|
||||
req: PayloadRequest
|
||||
token: string
|
||||
@@ -277,6 +279,13 @@ export interface IncomingAuthType {
|
||||
* @link https://payloadcms.com/docs/authentication/api-keys
|
||||
*/
|
||||
useAPIKey?: boolean
|
||||
|
||||
/**
|
||||
* Use sessions for authentication. Enabled by default.
|
||||
* @default true
|
||||
*/
|
||||
useSessions?: boolean
|
||||
|
||||
/**
|
||||
* Set to true or pass an object with verification options to require users to verify by email before they are allowed to log into your app.
|
||||
* @link https://payloadcms.com/docs/authentication/email#email-verification
|
||||
|
||||
@@ -127,6 +127,7 @@ export const authDefaults: IncomingAuthType = {
|
||||
loginWithUsername: false,
|
||||
maxLoginAttempts: 5,
|
||||
tokenExpiration: 7200,
|
||||
useSessions: true,
|
||||
verify: false,
|
||||
}
|
||||
|
||||
@@ -142,6 +143,7 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp
|
||||
auth.loginWithUsername = auth.loginWithUsername ?? false
|
||||
auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5
|
||||
auth.tokenExpiration = auth.tokenExpiration ?? 7200
|
||||
auth.useSessions = auth.useSessions ?? true
|
||||
auth.verify = auth.verify ?? false
|
||||
auth.strategies = auth.strategies ?? []
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ export const promise = async ({
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
} else if (field.hidden !== true || showHiddenFields === true) {
|
||||
siblingDoc[field.name] = []
|
||||
}
|
||||
break
|
||||
@@ -570,7 +570,7 @@ export const promise = async ({
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
} else if (field.hidden !== true || showHiddenFields === true) {
|
||||
siblingDoc[field.name] = []
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,18 @@ import { getLogger } from './utilities/logger.js'
|
||||
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
|
||||
import { traverseFields } from './utilities/traverseFields.js'
|
||||
|
||||
/**
|
||||
* Export of all base fields that could potentially be
|
||||
* useful as users wish to extend built-in fields with custom logic
|
||||
*/
|
||||
export { accountLockFields as baseAccountLockFields } from './auth/baseFields/accountLock.js'
|
||||
export { apiKeyFields as baseAPIKeyFields } from './auth/baseFields/apiKey.js'
|
||||
export { baseAuthFields } from './auth/baseFields/auth.js'
|
||||
export { emailFieldConfig as baseEmailField } from './auth/baseFields/email.js'
|
||||
export { sessionsFieldConfig as baseSessionsField } from './auth/baseFields/sessions.js'
|
||||
export { usernameFieldConfig as baseUsernameField } from './auth/baseFields/username.js'
|
||||
|
||||
export { verificationFields as baseVerificationFields } from './auth/baseFields/verification.js'
|
||||
export { executeAccess } from './auth/executeAccess.js'
|
||||
export { executeAuthStrategies } from './auth/executeAuthStrategies.js'
|
||||
export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js'
|
||||
|
||||
@@ -58,27 +58,27 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@playwright/test": "1.50.0",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@types/escape-html": "^1.0.2",
|
||||
"@types/node": "22.5.4",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"copyfiles": "^2.4.1",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"jsdom": "26.1.0",
|
||||
"playwright": "1.50.0",
|
||||
"playwright-core": "1.50.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"@playwright/test": "1.50.0",
|
||||
"jsdom": "26.1.0",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"playwright": "1.50.0",
|
||||
"playwright-core": "1.50.0",
|
||||
"typescript": "5.7.3",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.3",
|
||||
"typescript": "5.7.3"
|
||||
"vitest": "3.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
|
||||
@@ -25,29 +25,47 @@ import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/component
|
||||
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
"@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||
"@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||
"@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6,
|
||||
"@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
|
||||
FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/plugin-seo/client#OverviewComponent':
|
||||
OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaTitleComponent':
|
||||
MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaImageComponent':
|
||||
MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#MetaDescriptionComponent':
|
||||
MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@payloadcms/plugin-seo/client#PreviewComponent':
|
||||
PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
'@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
'@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||
'@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||
'@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6,
|
||||
'@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e,
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface Config {
|
||||
'auth-collection': AuthCollection;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-sessions': PayloadSession;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
@@ -125,6 +126,7 @@ export interface Config {
|
||||
'auth-collection': AuthCollectionSelect<false> | AuthCollectionSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-sessions': PayloadSessionsSelect<false> | PayloadSessionsSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
@@ -891,6 +893,26 @@ export interface PayloadPreference {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-sessions".
|
||||
*/
|
||||
export interface PayloadSession {
|
||||
id: string;
|
||||
session: string;
|
||||
expiration: string;
|
||||
user:
|
||||
| {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
}
|
||||
| {
|
||||
relationTo: 'public-users';
|
||||
value: string | PublicUser;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
@@ -1295,6 +1317,17 @@ export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-sessions_select".
|
||||
*/
|
||||
export interface PayloadSessionsSelect<T extends boolean = true> {
|
||||
session?: T;
|
||||
expiration?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable jest/no-conditional-in-test */
|
||||
import type {
|
||||
BasePayload,
|
||||
EmailFieldValidation,
|
||||
@@ -737,7 +738,7 @@ describe('Auth', () => {
|
||||
|
||||
it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => {
|
||||
const authFields = payload.collections[partialDisableLocalStrategiesSlug].config.fields
|
||||
// eslint-disable-next-line jest/no-conditional-in-test
|
||||
|
||||
.filter((field) => 'name' in field && field.name)
|
||||
.map((field) => (field as FieldAffectingData).name)
|
||||
|
||||
@@ -751,6 +752,7 @@ describe('Auth', () => {
|
||||
'hash',
|
||||
'loginAttempts',
|
||||
'lockUntil',
|
||||
'sessions',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1051,4 +1053,215 @@ describe('Auth', () => {
|
||||
expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sessions', () => {
|
||||
it('should set a session on a user', async () => {
|
||||
const authenticated = await payload.login({
|
||||
collection: slug,
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
expect(authenticated.token).toBeTruthy()
|
||||
|
||||
const user = await payload.db.find<User>({
|
||||
collection: slug,
|
||||
where: {
|
||||
id: {
|
||||
equals: authenticated.user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(Array.isArray(user.docs[0]?.sessions)).toBeTruthy()
|
||||
|
||||
const decoded = jwtDecode<{ sid: string }>(String(authenticated.token))
|
||||
|
||||
expect(decoded.sid).toBeDefined()
|
||||
|
||||
const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid)
|
||||
|
||||
expect(matchedSession).toBeDefined()
|
||||
expect(matchedSession?.createdAt).toBeDefined()
|
||||
expect(matchedSession?.expiresAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('should log out a user and delete only the session being logged out', async () => {
|
||||
const authenticated = await payload.login({
|
||||
collection: slug,
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
const authenticated2 = await payload.login({
|
||||
collection: slug,
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
await restClient.POST(`/${slug}/logout`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${authenticated.token}`,
|
||||
},
|
||||
})
|
||||
|
||||
const user = await payload.db.find<User>({
|
||||
collection: slug,
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const decoded = jwtDecode<{ sid: string }>(String(authenticated.token))
|
||||
expect(decoded.sid).toBeDefined()
|
||||
|
||||
const remainingSessions = user.docs[0]?.sessions ?? []
|
||||
|
||||
const loggedOutSession = remainingSessions.find(({ id }) => id === decoded.sid)
|
||||
expect(loggedOutSession).toBeUndefined()
|
||||
|
||||
const decoded2 = jwtDecode<{ sid: string }>(String(authenticated2.token))
|
||||
expect(decoded2.sid).toBeDefined()
|
||||
|
||||
const existingSession = remainingSessions.find(({ id }) => id === decoded2.sid)
|
||||
expect(existingSession?.id).toStrictEqual(decoded2.sid)
|
||||
})
|
||||
|
||||
it('should refresh an existing session', async () => {
|
||||
const authenticated = await payload.login({
|
||||
collection: slug,
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
const decoded = jwtDecode<{ sid: string }>(String(authenticated.token))
|
||||
|
||||
const user = await payload.db.find<User>({
|
||||
collection: slug,
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid)
|
||||
|
||||
const refreshed = await restClient
|
||||
.POST(`/${slug}/refresh-token`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${authenticated.token}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
||||
const refreshedUser = await payload.db.find<User>({
|
||||
collection: slug,
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const decodedRefreshed = jwtDecode<{ sid: string }>(String(refreshed.refreshedToken))
|
||||
|
||||
const matchedRefreshedSession = refreshedUser.docs[0]?.sessions?.find(
|
||||
({ id }) => id === decodedRefreshed.sid,
|
||||
)
|
||||
|
||||
expect(decodedRefreshed.sid).toStrictEqual(decoded.sid)
|
||||
|
||||
expect(new Date(matchedSession?.expiresAt as unknown as string).getTime()).toBeLessThan(
|
||||
new Date(matchedRefreshedSession?.expiresAt as unknown as string).getTime(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not authenticate a user who has a JWT but its session has been terminated', async () => {
|
||||
const authenticated = await payload.login({
|
||||
collection: slug,
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
await restClient.POST(`/${slug}/logout?allSessions=true`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${authenticated.token}`,
|
||||
},
|
||||
})
|
||||
|
||||
const user = await payload.db.find<User>({
|
||||
collection: slug,
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const remainingSessions = user.docs[0]?.sessions
|
||||
expect(remainingSessions).toHaveLength(0)
|
||||
|
||||
const meQuery = await restClient
|
||||
.GET(`/${slug}/me`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${authenticated.token}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
||||
expect(meQuery.user).toBeNull()
|
||||
})
|
||||
|
||||
it('should clean up expired sessions when logging in', async () => {
|
||||
const userWithExpiredSession = await payload.create({
|
||||
collection: slug,
|
||||
data: {
|
||||
email: `${devUser.email}.au`,
|
||||
password: devUser.password,
|
||||
roles: ['admin'],
|
||||
sessions: [
|
||||
{
|
||||
id: uuid(),
|
||||
createdAt: new Date().toDateString(),
|
||||
expiresAt: new Date(new Date().getTime() - 5000).toDateString(), // Set an expired session
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(userWithExpiredSession.sessions).toHaveLength(1)
|
||||
|
||||
await payload.login({
|
||||
collection: slug,
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
const user2 = await payload.db.find<User>({
|
||||
collection: slug,
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(user2.docs[0]?.sessions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,6 +248,11 @@ export interface User {
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions: {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[];
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
@@ -265,6 +270,11 @@ export interface PartialDisableLocalStrategy {
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions: {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[];
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
@@ -306,6 +316,11 @@ export interface PublicUser {
|
||||
_verificationToken?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions: {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[];
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
@@ -471,6 +486,13 @@ export interface UsersSelect<T extends boolean = true> {
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -486,6 +508,13 @@ export interface PartialDisableLocalStrategiesSelect<T extends boolean = true> {
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -523,6 +552,13 @@ export interface PublicUsersSelect<T extends boolean = true> {
|
||||
_verificationToken?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
|
||||
@@ -96,5 +96,8 @@
|
||||
"ts-essentials": "10.0.3",
|
||||
"typescript": "5.7.3",
|
||||
"uuid": "10.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export async function loginFunction({ email, password }: LoginArgs) {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import config from '../config.js'
|
||||
export async function refreshFunction() {
|
||||
try {
|
||||
return await refresh({
|
||||
collection: 'users', // update this to your collection slug
|
||||
config,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user