Files
Alessio Gravili b77493d41a feat!: replace TypedUser with User and add AuthenticatedUser (#17151)
## What

This completes the user-type cleanup planned for Payload 4.0:

- `UntypedUser` was deprecated for removal in 4.0.
- `TypedUser` was marked to be renamed to `User` in 4.0.

Previously, the public `User` type was the loose, deprecated
`UntypedUser`, while `TypedUser` was the generated type for auth-enabled
collections. `ClientUser` was also loose, and `req.user` did not include
the runtime auth fields `_strategy` and `_sid`.

The types now have clear roles:

| Type | Purpose |
| --- | --- |
| `User` | The generated user type for auth-enabled collections. This
replaces `TypedUser`. Without generated types, it falls back to a
documented shape containing Payload's built-in auth fields. Contains
both read and write fields. |
| `AuthenticatedUser` | `User` plus the optional runtime fields
`_strategy` and `_sid`. Used by `PayloadRequest.user`, `payload.auth()`,
auth strategy results, and auth internals. |
| `ClientUser` | The type used by `useAuth().user` and `me` responses.
It is now an alias of `AuthenticatedUser` |

I'm considering replacing `ClientUser` in favor of just
`AuthenticatedUser` in a separate PR.

## Breaking changes

- `TypedUser` has been removed. Use `User`.
- `UntypedUser` has been removed. Use `User` for a user document,
`AuthenticatedUser` for a signed-in request user, or `ClientUser` in
client code.
- `User` and `ClientUser` no longer have an `[key: string]: any` index
signature. Custom auth-collection fields require generated types or an
explicit augmented type.
- The Local API `user` option is now `User | null` instead of the loose
`Document` type for:
- collection `count`, `create`, `delete`, `duplicate`, `find`,
`findByID`, `findDistinct`, and `update`
- collection and global version count, find, find-by-ID, and restore
operations
  - global `findOne` and `update`
- `UserSession.createdAt` is now optional and nullable: `createdAt?:
Date | null | string`. This matches generated session types, but callers
must handle a missing value.

`AuthenticatedUser` is assignable to `User`, so passing `req.user` to
these Local API operations continues to work.

## Other changes

- Adds a strict untyped fallback containing all built-in user fields,
without an index signature. A type test verifies that generated user
types are assignable to this fallback.
- Types `payload.auth()` and login results with the runtime auth fields,
and uses `AuthenticatedUser` while login, `me`, refresh, and session
code build signed-in users.
- Fixes the session lookup ID type and updates session handling for
nullable `createdAt` values.
- Hardens refresh/session handling when a user or session is missing and
avoids mutating the in-memory user's `updatedAt` solely to control
database timestamps.
- Updates Payload, the admin UI, Lexical, tests, and first-party plugins
to use the new types:
  - `plugin-mcp` uses `User` for the authorized caller.
  - `plugin-import-export` removes obsolete `req.user.user` handling
- `plugin-multi-tenant` explicitly casts accesses to plugin-defined user
fields.
- `plugin-ecommerce` adds `UserWithCart` for the optional reverse `cart`
join that projects may
    define on their user collection.


## Migration

```diff
- import type { TypedUser, UntypedUser } from 'payload'
+ import type { User } from 'payload'
```

Use `User` for stored/read user documents and `AuthenticatedUser` when
code specifically receives the signed-in user from `req.user`,
`payload.auth()`, or an auth strategy.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1215866573673868
2026-07-02 16:13:05 -04:00

164 lines
4.4 KiB
TypeScript

import type { Payload, RequestContext, TypedLocale, User } from '../index.js'
import type { PayloadRequest } from '../types/index.js'
import { getDataLoader } from '../collections/dataloader.js'
import { getLocalI18n } from '../translations/getLocalI18n.js'
import { sanitizeFallbackLocale } from '../utilities/sanitizeFallbackLocale.js'
function getRequestContext(
req: Partial<PayloadRequest> = { context: null } as unknown as PayloadRequest,
context: RequestContext = {},
): RequestContext {
if (req.context) {
if (Object.keys(req.context).length === 0 && req.context.constructor === Object) {
// if req.context is `{}` avoid unnecessary spread
return context
} else {
return { ...req.context, ...context }
}
} else {
return context
}
}
const attachFakeURLProperties = (req: Partial<PayloadRequest>, urlSuffix?: string) => {
/**
* *NOTE*
* If no URL is provided, the local API was called outside
* the context of a request. Therefore we create a fake URL object.
* `ts-expect-error` is used below for properties that are 'read-only'.
* Since they do not exist yet we can safely ignore the error.
*/
let urlObject: undefined | URL
function getURLObject() {
if (urlObject) {
return urlObject
}
const fallbackURL = `http://${req.host || 'localhost'}${urlSuffix || ''}`
const urlToUse =
req?.url ||
(req.payload?.config?.serverURL
? `${req.payload?.config.serverURL}${urlSuffix || ''}`
: fallbackURL)
try {
urlObject = new URL(urlToUse)
} catch (_err) {
req.payload?.logger.error(
`Failed to create URL object from URL: ${urlToUse}, falling back to ${fallbackURL}`,
)
urlObject = new URL(fallbackURL)
}
return urlObject
}
if (!req.host) {
req.host = getURLObject().host
}
if (!req.protocol) {
req.protocol = getURLObject().protocol
}
if (!req.pathname) {
req.pathname = getURLObject().pathname
}
if (!req.searchParams) {
// @ts-expect-error eslint-disable-next-line no-param-reassign
req.searchParams = getURLObject().searchParams
}
if (!req.origin) {
// @ts-expect-error eslint-disable-next-line no-param-reassign
req.origin = getURLObject().origin
}
if (!req?.url) {
// @ts-expect-error eslint-disable-next-line no-param-reassign
req.url = getURLObject().href
}
}
export type CreateLocalReqOptions = {
context?: RequestContext
depth?: number
fallbackLocale?: false | TypedLocale
locale?: string
req?: Partial<PayloadRequest>
urlSuffix?: string
user?: User
}
type CreateLocalReq = (options: CreateLocalReqOptions, payload: Payload) => Promise<PayloadRequest>
export const createLocalReq: CreateLocalReq = async (
{
context,
depth,
fallbackLocale,
locale: localeArg,
req = {} as PayloadRequest,
urlSuffix,
user,
},
payload,
): Promise<PayloadRequest> => {
const localization = payload.config?.localization
if (localization) {
const locale = localeArg === '*' ? 'all' : localeArg
const defaultLocale = localization.defaultLocale
const localeCandidate = locale || req?.locale || req?.query?.locale
req.locale =
localeCandidate && typeof localeCandidate === 'string' ? localeCandidate : defaultLocale
const sanitizedFallback = sanitizeFallbackLocale({
fallbackLocale: fallbackLocale!,
locale: req.locale,
localization,
})
req.fallbackLocale = sanitizedFallback!
}
const i18n =
req?.i18n ||
(await getLocalI18n({ config: payload.config, language: payload.config.i18n.fallbackLanguage }))
if (!req.headers) {
req.headers = new Headers()
}
req.context = getRequestContext(req, context)
req.payloadAPI = req?.payloadAPI || 'local'
req.payload = payload
req.i18n = i18n
req.t = i18n.t
req.user = user || req?.user || null
// Ensure user.collection is set for auth-related access control
// TODO (4.0): Instead of silently falling back, throw an error if user.collection is missing
if (req.user && !req.user.collection) {
req.user = { ...req.user, collection: payload.config.admin.user }
}
req.payloadDataLoader = req?.payloadDataLoader || getDataLoader(req as PayloadRequest)
req.routeParams = req?.routeParams || {}
req.query = req?.query || {}
if (typeof depth !== 'undefined') {
req.query.depth = depth
}
attachFakeURLProperties(req, urlSuffix)
return req as PayloadRequest
}