Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/migration-guide/v4.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,19 @@ import type { DefaultValue } from 'payload'

If you only use `DefaultValue` to type a `defaultValue` property passed into a Collection, Global, or Field config, no changes are needed.

### Sanitized collection and auth config types now match runtime defaults

`SanitizedCollectionConfig` and its auth type no longer use `DeepRequired`. This reduces TypeScript checker work and makes optionality match runtime defaults.

Properties Payload does not default, such as `access.readVersions`, `auth.cookies.domain`, `auth.useAPIKey`, and `auth.depth`, now require undefined handling:

```ts
if (collection.access.readVersions) await collection.access.readVersions(args)
const useAPIKey = collection.auth.useAPIKey ?? false
```

Properties sanitization does fill, including standard access functions, hook arrays, cookie defaults, and normalized auth options, are now required. Use `CollectionConfig` or `IncomingAuthType` for input and `SanitizedCollectionConfig` after sanitization.

### User types consolidated into `User` and `AuthenticatedUser`

Payload's user types have been consolidated so each one has a clear job. This completes a cleanup already planned in 3.x: `UntypedUser` was deprecated for removal in 4.0, and `TypedUser` was marked to be renamed to `User` in 4.0.
Expand Down
2 changes: 1 addition & 1 deletion packages/payload/src/auth/executeAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type OperationArgs = {
}
export const executeAccess = async (
{ id, data, disableErrors, isReadingStaticFile = false, req }: OperationArgs,
access: Access,
access?: Access,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not all access fns are required, e.g. readVersions. So this is more accurate

): Promise<AccessResult> => {
if (access) {
const resolvedConstraint = await access({
Expand Down
2 changes: 1 addition & 1 deletion packages/payload/src/auth/getLoginOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const getLoginOptions = (
canLoginWithUsername: boolean
} => {
return {
canLoginWithEmail: !loginWithUsername || loginWithUsername.allowEmailLogin!,
canLoginWithEmail: !loginWithUsername || loginWithUsername.allowEmailLogin,
canLoginWithUsername: Boolean(loginWithUsername),
}
}
48 changes: 33 additions & 15 deletions packages/payload/src/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { DeepRequired } from 'ts-essentials'

import type { CollectionSlug, GlobalSlug, Payload, User } from '../index.js'
import type { PayloadRequest, Where } from '../types/index.js'

Expand Down Expand Up @@ -216,15 +214,17 @@ export type LoginWithUsernameOptions =
requireUsername?: boolean
}

type AuthCookies = {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just extracting it out so we dont have to do NonNullable<IncomingAuthType['cookies']>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any benefit in exporting these?

domain?: string
sameSite?: 'Lax' | 'None' | 'Strict' | boolean
secure?: boolean
}

export interface IncomingAuthType {
/**
* Set cookie options, including secure, sameSite, and domain. For advanced users.
*/
cookies?: {
domain?: string
sameSite?: 'Lax' | 'None' | 'Strict' | boolean
secure?: boolean
}
cookies?: AuthCookies
/**
* How many levels deep a user document should be populated when creating the JWT and binding the user to the req. Defaults to 0 and should only be modified if absolutely necessary, as this will affect performance.
* @default 0
Expand Down Expand Up @@ -316,14 +316,32 @@ export type VerifyConfig = {
}

export interface Auth
extends Omit<DeepRequired<IncomingAuthType>, 'forgotPassword' | 'loginWithUsername' | 'verify'> {
forgotPassword?: {
expiration?: number
generateEmailHTML?: GenerateForgotPasswordEmailHTML
generateEmailSubject?: GenerateForgotPasswordEmailSubject
}
loginWithUsername: false | LoginWithUsernameOptions
verify?: boolean | VerifyConfig
extends Omit<
IncomingAuthType,
| 'cookies'
| 'forgotPassword'
| 'lockTime'
| 'loginWithUsername'
| 'maxLoginAttempts'
| 'strategies'
| 'tokenExpiration'
| 'useSessions'
| 'verify'
>,
Required<
Pick<
IncomingAuthType,
| 'forgotPassword'
| 'lockTime'
| 'maxLoginAttempts'
| 'strategies'
| 'tokenExpiration'
| 'useSessions'
| 'verify'
>
> {
cookies: Pick<AuthCookies, 'domain'> & Required<Pick<AuthCookies, 'sameSite' | 'secure'>>
loginWithUsername: false | Required<LoginWithUsernameOptions>
}

export function hasWhereAccessResult(result: boolean | Where): result is Where {
Expand Down
94 changes: 53 additions & 41 deletions packages/payload/src/collections/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IncomingAuthType, LoginWithUsernameOptions } from '../../auth/types.js'
import type { CollectionConfig } from './types.js'
import type { Auth, IncomingAuthType, LoginWithUsernameOptions } from '../../auth/types.js'
import type { CollectionConfig, SanitizedCollectionConfig } from './types.js'

import { defaultAccess } from '../../auth/defaultAccess.js'

Expand Down Expand Up @@ -32,6 +32,7 @@ export const defaults: Partial<CollectionConfig> = {
hooks: {
afterChange: [],
afterDelete: [],
afterError: [],
afterForgotPassword: [],
afterLogin: [],
afterLogout: [],
Expand All @@ -55,14 +56,16 @@ export const defaults: Partial<CollectionConfig> = {
}

export const addDefaultsToCollectionConfig = (collection: CollectionConfig): CollectionConfig => {
const access = collection.access

collection.access = {
create: defaultAccess,
delete: defaultAccess,
read: defaultAccess,
unlock: defaultAccess,
update: defaultAccess,
...(collection.access || {}),
}
...access,
create: access?.create ?? defaultAccess,
delete: access?.delete ?? defaultAccess,
read: access?.read ?? defaultAccess,
unlock: access?.unlock ?? defaultAccess,
update: access?.update ?? defaultAccess,
} satisfies SanitizedCollectionConfig['access']

collection.admin = {
components: {},
Expand All @@ -84,26 +87,29 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col
collection.fields = collection.fields ?? []
collection.hierarchy = collection.hierarchy ?? false

const hooks = collection.hooks

collection.hooks = {
afterChange: [],
afterDelete: [],
afterForgotPassword: [],
afterLogin: [],
afterLogout: [],
afterMe: [],
afterOperation: [],
afterRead: [],
afterRefresh: [],
beforeChange: [],
beforeDelete: [],
beforeLogin: [],
beforeOperation: [],
beforeRead: [],
beforeValidate: [],
me: [],
refresh: [],
...(collection.hooks || {}),
}
...hooks,
afterChange: hooks?.afterChange ?? [],
afterDelete: hooks?.afterDelete ?? [],
afterError: hooks?.afterError ?? [],
afterForgotPassword: hooks?.afterForgotPassword ?? [],
afterLogin: hooks?.afterLogin ?? [],
afterLogout: hooks?.afterLogout ?? [],
afterMe: hooks?.afterMe ?? [],
afterOperation: hooks?.afterOperation ?? [],
afterRead: hooks?.afterRead ?? [],
afterRefresh: hooks?.afterRefresh ?? [],
beforeChange: hooks?.beforeChange ?? [],
beforeDelete: hooks?.beforeDelete ?? [],
beforeLogin: hooks?.beforeLogin ?? [],
beforeOperation: hooks?.beforeOperation ?? [],
beforeRead: hooks?.beforeRead ?? [],
beforeValidate: hooks?.beforeValidate ?? [],
me: hooks?.me ?? [],
refresh: hooks?.refresh ?? [],
} satisfies SanitizedCollectionConfig['hooks']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By using satisfies, we get proper errors if this does not match the sanitized types. If we had that before, we would have caught the type <=> runtime mismatch


collection.timestamps = collection.timestamps ?? true
collection.upload = collection.upload ?? false
Expand All @@ -114,16 +120,20 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col
return collection
}

export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthType => {
export const addDefaultsToAuthConfig = (auth: IncomingAuthType): Auth => {
auth.cookies = {
sameSite: 'Lax',
secure: false,
...(auth.cookies || {}),
}
sameSite: auth.cookies?.sameSite ?? 'Lax',
secure: auth.cookies?.secure ?? false,
} satisfies Auth['cookies']

auth.forgotPassword = auth.forgotPassword ?? {}
auth.lockTime = auth.lockTime ?? 600000 // 10 minutes
auth.loginWithUsername = auth.loginWithUsername ?? false
auth.loginWithUsername = auth.loginWithUsername
? addDefaultsToLoginWithUsernameConfig(
auth.loginWithUsername === true ? {} : auth.loginWithUsername,
)
: false
auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5
auth.tokenExpiration = auth.tokenExpiration ?? 7200
auth.useSessions = auth.useSessions ?? true
Expand All @@ -134,24 +144,26 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp
auth.verify = {}
}

return auth
return auth as Auth
}

/**
* @deprecated - remove in 4.0. This is error-prone, as mutating this object will affect any objects that use the defaults as a base.
*/
export const loginWithUsernameDefaults: LoginWithUsernameOptions = {
export const loginWithUsernameDefaults: Required<LoginWithUsernameOptions> = {
allowEmailLogin: false,
requireEmail: false,
requireUsername: true,
}

export const addDefaultsToLoginWithUsernameConfig = (
loginWithUsername: LoginWithUsernameOptions,
): LoginWithUsernameOptions =>
): Required<LoginWithUsernameOptions> =>
({
allowEmailLogin: false,
requireEmail: false,
requireUsername: true,
...(loginWithUsername || {}),
}) as LoginWithUsernameOptions
...loginWithUsername,
allowEmailLogin: loginWithUsername.allowEmailLogin ?? false,
requireEmail: loginWithUsername.requireEmail ?? false,
requireUsername: loginWithUsername.allowEmailLogin
? (loginWithUsername.requireUsername ?? true)
: true,
}) as Required<LoginWithUsernameOptions>
24 changes: 1 addition & 23 deletions packages/payload/src/collections/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,7 @@ import { traverseForLocalizedFields } from '../../utilities/traverseForLocalized
import { baseVersionFields } from '../../versions/baseFields.js'
import { versionDefaults } from '../../versions/defaults.js'
import { defaultCollectionEndpoints } from '../endpoints/index.js'
import {
addDefaultsToAuthConfig,
addDefaultsToCollectionConfig,
addDefaultsToLoginWithUsernameConfig,
} from './defaults.js'
import { addDefaultsToAuthConfig, addDefaultsToCollectionConfig } from './defaults.js'
import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js'
import { validateUseAsTitle } from './useAsTitle.js'

Expand Down Expand Up @@ -333,24 +329,6 @@ export const sanitizeCollection = async (
// disable duplicate for auth enabled collections by default
sanitized.disableDuplicate = sanitized.disableDuplicate ?? true

if (sanitized.auth.loginWithUsername) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved into defaults.ts

if (sanitized.auth.loginWithUsername === true) {
sanitized.auth.loginWithUsername = addDefaultsToLoginWithUsernameConfig({})
} else {
const loginWithUsernameWithDefaults = addDefaultsToLoginWithUsernameConfig(
sanitized.auth.loginWithUsername,
)

// if allowEmailLogin is false, requireUsername must be true
if (loginWithUsernameWithDefaults.allowEmailLogin === false) {
loginWithUsernameWithDefaults.requireUsername = true
}
sanitized.auth.loginWithUsername = loginWithUsernameWithDefaults
}
} else {
sanitized.auth.loginWithUsername = false
}

if (!collection?.admin?.useAsTitle) {
sanitized.admin!.useAsTitle = sanitized.auth.loginWithUsername ? 'username' : 'email'
}
Expand Down
Loading
Loading